From 35c624d418926dae9975277ede3aa04903e76c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 17:20:40 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=ED=8E=B8=EC=A7=91=20=EC=BB=AC?= =?UTF-8?q?=EB=A0=89=EC=85=98=20=EC=A0=80=EC=9E=A5=EC=9D=84=20editor=5Fcom?= =?UTF-8?q?mit=20=EC=9B=90=EC=9E=90=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.toml | 1 + src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/permissions/dmnote-allow-all.json | 5 + src-tauri/src/commands/app/system.rs | 24 +- src-tauri/src/commands/app/update.rs | 16 +- src-tauri/src/commands/editor/mod.rs | 1 + src-tauri/src/commands/editor/state.rs | 153 ++ src-tauri/src/commands/keys/keys.rs | 628 +++++-- src-tauri/src/commands/keys/sound.rs | 175 +- src-tauri/src/commands/layout/graph_items.rs | 26 +- src-tauri/src/commands/layout/knob_items.rs | 26 +- src-tauri/src/commands/layout/stat_items.rs | 26 +- .../src/commands/media/counter_animation.rs | 260 ++- src-tauri/src/commands/media/image.rs | 206 ++- src-tauri/src/commands/preset/load.rs | 916 ++++++++-- src-tauri/src/commands/preset/mod.rs | 232 +++ src-tauri/src/commands/preset/save.rs | 16 +- src-tauri/src/errors.rs | 104 +- src-tauri/src/keyboard/manager.rs | 28 +- src-tauri/src/main.rs | 17 + src-tauri/src/models/editor.rs | 265 +++ src-tauri/src/models/mod.rs | 7 + src-tauri/src/services/obs_bridge.rs | 6 +- src-tauri/src/state/app_state.rs | 289 ++- src-tauri/src/state/atomic_file.rs | 266 ++- src-tauri/src/state/editor.rs | 1593 +++++++++++++++++ src-tauri/src/state/migration.rs | 294 ++- src-tauri/src/state/mod.rs | 1 + src-tauri/src/state/store.rs | 1558 +++++++++++++--- 29 files changed, 6279 insertions(+), 862 deletions(-) create mode 100644 src-tauri/src/commands/editor/state.rs create mode 100644 src-tauri/src/models/editor.rs create mode 100644 src-tauri/src/state/editor.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e0b23fd8..ca4ad383 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -72,6 +72,7 @@ self-replace = "1.5" tempfile = "3" [target."cfg(target_os = \"macos\")".dependencies] +libc = "0.2" rdev = "0.5.3" objc = "0.2" objc-foundation = "0.1" diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index dfe77f74..8196b09e 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_open_external","app_quit","app_restart","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_get","css_get_use","css_load","css_reset","css_set_content","css_tab_clear","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","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_output_state","key_sound_get_status","key_sound_list_output_devices","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_output_backend","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_get_counters","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","keys_update","keys_update_with_positions","knob_positions_get","knob_positions_update","layer_groups_get","layer_groups_update","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_regenerate_token","obs_start","obs_status","obs_stop","overlay_get","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","plugin_bridge_send","plugin_bridge_send_to","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","positions_update","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_rename","sound_save_processed_wav","sound_set_enabled","sound_set_hidden","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_cancel_editor_flush","app_open_external","app_quit","app_quit_after_editor_flush","app_restart","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_get","css_get_use","css_load","css_reset","css_set_content","css_tab_clear","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","editor_commit","editor_get","editor_history_restore","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_output_state","key_sound_get_status","key_sound_list_output_devices","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_output_backend","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_get_counters","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","keys_update","keys_update_with_positions","knob_positions_get","knob_positions_update","layer_groups_get","layer_groups_update","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_regenerate_token","obs_start","obs_status","obs_stop","overlay_get","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","plugin_bridge_send","plugin_bridge_send_to","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","positions_update","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_rename","sound_save_processed_wav","sound_set_enabled","sound_set_hidden","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/permissions/dmnote-allow-all.json b/src-tauri/permissions/dmnote-allow-all.json index 344f0b4f..4c062d65 100644 --- a/src-tauri/permissions/dmnote-allow-all.json +++ b/src-tauri/permissions/dmnote-allow-all.json @@ -8,8 +8,10 @@ "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", @@ -32,6 +34,9 @@ "custom_tabs_list", "custom_tabs_restore", "custom_tabs_select", + "editor_commit", + "editor_get", + "editor_history_restore", "font_load", "get_cursor_settings", "graph_positions_get", diff --git a/src-tauri/src/commands/app/system.rs b/src-tauri/src/commands/app/system.rs index 17bd0d16..6fc08523 100644 --- a/src-tauri/src/commands/app/system.rs +++ b/src-tauri/src/commands/app/system.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, Manager, State, WebviewWindow}; use crate::cursor::{get_macos_cursor_settings, rgb_to_hex}; use crate::errors::CmdResult; @@ -30,8 +30,7 @@ pub fn app_open_external(_app: AppHandle, url: String) -> CmdResult<()> { #[tauri::command] pub fn app_restart(app: AppHandle, state: State<'_, AppState>) -> CmdResult<()> { - state.shutdown(); - app.request_restart(); + state.request_frontend_restart(app); Ok(()) } @@ -43,7 +42,24 @@ pub fn window_show_main(app: AppHandle, state: State<'_, AppState>) -> CmdResult #[tauri::command] pub fn app_quit(app: AppHandle, state: State<'_, AppState>) -> CmdResult<()> { - state.request_shutdown(app); + state.request_frontend_shutdown(app); + Ok(()) +} + +#[tauri::command] +pub fn app_quit_after_editor_flush( + app: AppHandle, + state: State<'_, AppState>, + window: WebviewWindow, + handshake_id: String, +) -> CmdResult<()> { + state.acknowledge_frontend_lifecycle(app, &handshake_id, window.label()); + Ok(()) +} + +#[tauri::command] +pub fn app_cancel_editor_flush(state: State<'_, AppState>, handshake_id: String) -> CmdResult<()> { + state.cancel_frontend_lifecycle(&handshake_id); Ok(()) } diff --git a/src-tauri/src/commands/app/update.rs b/src-tauri/src/commands/app/update.rs index 8237af3e..ccf9525e 100644 --- a/src-tauri/src/commands/app/update.rs +++ b/src-tauri/src/commands/app/update.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, State}; +use tauri::{AppHandle, State, WebviewWindow}; use crate::errors::{CmdResult, CommandError}; use crate::state::AppState; @@ -23,8 +23,15 @@ pub struct AutoUpdateResult { pub fn app_auto_update( app: AppHandle, state: State<'_, AppState>, + window: WebviewWindow, tag: String, ) -> CmdResult { + if window.label() != "main" { + return Err(CommandError::msg( + "automatic update is only available in the main window", + )); + } + #[cfg(target_os = "windows")] { app_auto_update_windows(app, &state, &tag) @@ -44,7 +51,7 @@ pub fn app_auto_update( #[cfg(target_os = "windows")] fn app_auto_update_windows( app: AppHandle, - state: &AppState, + _state: &AppState, tag: &str, ) -> CmdResult { use std::time::Duration; @@ -112,11 +119,6 @@ fn app_auto_update_windows( self_replace::self_replace(&temp_exe)?; - // 업데이트 종료 경로도 워치독 커버 — 여기서 행이면 커맨드가 무기한 매달림 - state.arm_shutdown_watchdog("update-restart"); - state.shutdown(); - app.request_restart(); - Ok(AutoUpdateResult { previous_version, updated_to: target_version.to_string(), diff --git a/src-tauri/src/commands/editor/mod.rs b/src-tauri/src/commands/editor/mod.rs index d12f5790..b847a3e0 100644 --- a/src-tauri/src/commands/editor/mod.rs +++ b/src-tauri/src/commands/editor/mod.rs @@ -1,3 +1,4 @@ pub mod css; pub mod js; pub mod note_tab; +pub mod state; diff --git a/src-tauri/src/commands/editor/state.rs b/src-tauri/src/commands/editor/state.rs new file mode 100644 index 00000000..00eeda68 --- /dev/null +++ b/src-tauri/src/commands/editor/state.rs @@ -0,0 +1,153 @@ +use tauri::{AppHandle, Emitter, State}; + +use crate::{ + errors::CmdResult, + models::{ + CommittedEditorChange, EditorCommitRequest, EditorCommitResult, EditorField, + EditorGetResult, + }, + state::AppState, +}; + +pub(crate) fn emit_best_effort(app: &AppHandle, event: &str, payload: &T) { + if let Err(error) = app.emit(event, payload) { + log::error!("[Editor] failed to emit {event}: {error}"); + } +} + +pub(crate) fn publish_legacy_editor_fields( + state: &AppState, + app: &AppHandle, + change: &CommittedEditorChange, + fields: &[EditorField], +) { + for field in fields { + match field { + EditorField::Keys => { + emit_best_effort(app, "keys:changed", &change.document.keys); + } + EditorField::KeyPositions => { + emit_best_effort(app, "positions:changed", &change.document.key_positions); + } + EditorField::StatPositions => { + emit_best_effort( + app, + "statPositions:changed", + &change.document.stat_positions, + ); + } + EditorField::GraphPositions => { + emit_best_effort( + app, + "graphPositions:changed", + &change.document.graph_positions, + ); + } + EditorField::KnobPositions => { + emit_best_effort( + app, + "knobPositions:changed", + &change.document.knob_positions, + ); + } + EditorField::LayerGroups => { + emit_best_effort(app, "layerGroups:changed", &change.document.layer_groups); + } + } + } + + if fields.contains(&EditorField::Keys) + && !change.result.changed_fields.contains(&EditorField::Keys) + { + if let Err(error) = state.apply_committed_editor_key_runtime( + app, + &change.document.keys, + &change.selected_key_type, + &change.key_counters, + ) { + log::error!("[Editor] failed to publish legacy key refresh: {error:#}"); + } + state.obs_broadcast_counters(); + } + + if !fields.is_empty() && change.result.changed_fields.is_empty() { + state.refresh_obs_snapshot(); + } +} + +pub(crate) fn publish_editor_change( + state: &AppState, + app: &AppHandle, + change: &CommittedEditorChange, + project_legacy_events: bool, +) { + let Some(event) = change.event.as_ref() else { + return; + }; + emit_best_effort(app, "editor:committed", event); + + let keys_changed = change.result.changed_fields.contains(&EditorField::Keys); + let previous_mode = keys_changed.then(|| state.keyboard.current_mode()); + if keys_changed { + if let Err(error) = state.apply_committed_editor_key_runtime( + app, + &change.document.keys, + &change.selected_key_type, + &change.key_counters, + ) { + log::error!("[Editor] failed to publish committed key counters: {error:#}"); + } + } + + if project_legacy_events && !change.result.changed_fields.is_empty() { + publish_legacy_editor_fields(state, app, change, &change.result.changed_fields); + } + + if project_legacy_events && 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 keys_changed { + state.obs_broadcast_counters(); + } + + if !change.result.changed_fields.is_empty() { + state.refresh_obs_snapshot(); + } +} + +#[tauri::command] +pub fn editor_get(state: State<'_, AppState>) -> CmdResult { + Ok(state.store.editor_get()) +} + +#[tauri::command] +pub fn editor_commit( + state: State<'_, AppState>, + app: AppHandle, + request: EditorCommitRequest, +) -> CmdResult { + let requested_fields = request.changes.included_fields(); + let previous_mode = requested_fields + .contains(&EditorField::Keys) + .then(|| state.keyboard.current_mode()); + let change = state.store.commit_editor_document(request)?; + if change.event.is_some() { + publish_editor_change(state.inner(), &app, &change, false); + } + if !change.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 }), + ); + } + } + Ok(change.result) +} diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index fe12fa69..5c79fb86 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -1,20 +1,43 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::Serialize; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; use crate::{ + commands::editor::state::{ + emit_best_effort, publish_editor_change, publish_legacy_editor_fields, + }, defaults::{default_keys, default_positions}, errors::CmdResult, models::{ - AppStoreData, CustomCssPatch, CustomTab, KeyCounters, KeyMappings, KeyPositions, - LayerGroups, NoteSettings, NoteSettingsPatch, SettingsPatchInput, + AppStoreData, CommittedEditorChange, CustomCssPatch, CustomTab, EditorCommitOrigin, + EditorCommitResult, EditorDocumentV1, EditorField, EditorHistoryRestoreRequest, + KeyCounters, KeyMappings, KeyPositions, LayerGroups, NoteSettings, NoteSettingsPatch, + SettingsPatchInput, }, - state::AppState, + services::settings::apply_patch_to_store, + state::{editor::validate_history_restore_metadata, AppState}, }; const MAX_CUSTOM_TABS: usize = 30; +fn publish_legacy_key_noop_runtime( + state: &AppState, + app: &AppHandle, + change: &CommittedEditorChange, +) { + if let Err(error) = state.apply_committed_editor_key_runtime( + app, + &change.document.keys, + &change.selected_key_type, + &change.key_counters, + ) { + log::error!("[Keys] failed to publish committed key counters: {error:#}"); + } + state.obs_broadcast_counters(); + state.refresh_obs_snapshot(); +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ModeResetKind { Default, @@ -237,20 +260,32 @@ pub fn keys_update( mappings: KeyMappings, ) -> CmdResult { let previous_mode = state.keyboard.current_mode(); - let (updated, selected_key_type) = state.store.update_keys(mappings)?; - state - .keyboard - .update_mappings_and_set_mode(updated.clone(), selected_key_type.clone()); - app.emit("keys:changed", &updated)?; - if previous_mode != selected_key_type { - app.emit( + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("keys_update".to_string()), + &[EditorField::Keys], + move |store| { + store.keys = mappings; + Ok(()) + }, + )?; + publish_editor_change(state.inner(), &app, &transaction.change, false); + if !transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys) + { + publish_legacy_key_noop_runtime(state.inner(), &app, &transaction.change); + } + let updated = transaction.change.document.keys; + emit_best_effort(&app, "keys:changed", &updated); + if previous_mode != transaction.change.selected_key_type { + emit_best_effort( + &app, "keys:mode-changed", - &serde_json::json!({ "mode": &selected_key_type }), - )?; + &serde_json::json!({ "mode": &transaction.change.selected_key_type }), + ); } - state.sync_counters_with_keys_and_emit(&app, &updated)?; - state.obs_broadcast_counters(); - state.refresh_obs_snapshot(); Ok(updated) } @@ -262,23 +297,35 @@ pub fn keys_update_with_positions( positions: KeyPositions, ) -> CmdResult { let previous_mode = state.keyboard.current_mode(); - let (keys, positions, selected_key_type) = state - .store - .update_keys_with_positions(mappings, positions)?; - state - .keyboard - .update_mappings_and_set_mode(keys.clone(), selected_key_type.clone()); - app.emit("keys:changed", &keys)?; - app.emit("positions:changed", &positions)?; - if previous_mode != selected_key_type { - app.emit( + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("keys_update_with_positions".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + move |store| { + store.keys = mappings; + store.key_positions = positions; + Ok(()) + }, + )?; + publish_editor_change(state.inner(), &app, &transaction.change, false); + if !transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys) + { + publish_legacy_key_noop_runtime(state.inner(), &app, &transaction.change); + } + let keys = transaction.change.document.keys; + let positions = transaction.change.document.key_positions; + emit_best_effort(&app, "keys:changed", &keys); + emit_best_effort(&app, "positions:changed", &positions); + if previous_mode != transaction.change.selected_key_type { + emit_best_effort( + &app, "keys:mode-changed", - &serde_json::json!({ "mode": &selected_key_type }), - )?; + &serde_json::json!({ "mode": &transaction.change.selected_key_type }), + ); } - state.sync_counters_with_keys_and_emit(&app, &keys)?; - state.obs_broadcast_counters(); - state.refresh_obs_snapshot(); Ok(KeysWithPositionsResponse { keys, positions }) } @@ -288,9 +335,20 @@ pub fn positions_update( app: AppHandle, positions: KeyPositions, ) -> CmdResult { - let updated = state.store.update_positions(positions)?; - app.emit("positions:changed", &updated)?; - state.refresh_obs_snapshot(); + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("positions_update".to_string()), + &[EditorField::KeyPositions], + move |store| { + store.key_positions = positions; + Ok(()) + }, + )?; + publish_editor_change(state.inner(), &app, &transaction.change, false); + let updated = transaction.change.document.key_positions; + emit_best_effort(&app, "positions:changed", &updated); + if transaction.change.event.is_none() { + state.refresh_obs_snapshot(); + } Ok(updated) } @@ -312,10 +370,11 @@ pub fn keys_set_mode( }, |effective| { state.keyboard.set_mode(effective.to_string()); - app.emit( + emit_best_effort( + &app, "keys:mode-changed", &serde_json::json!({ "mode": effective }), - )?; + ); state.refresh_obs_snapshot(); Ok(()) }, @@ -326,33 +385,6 @@ pub fn keys_set_mode( pub fn keys_reset_all(state: State<'_, AppState>, app: AppHandle) -> CmdResult { let keys = default_keys().clone(); let positions = default_positions().clone(); - let stat_positions = crate::models::StatPositions::new(); - let graph_positions = crate::models::GraphPositions::new(); - let knob_positions = crate::models::KnobPositions::new(); - let layer_groups = LayerGroups::new(); - let tab_note_overrides = crate::models::TabNoteOverrides::new(); - let selected_key_type = "4key".to_string(); - let custom_tabs: Vec = Vec::new(); - let cleared_tab_css_ids: Vec = state - .store - .snapshot() - .tab_css_overrides - .keys() - .cloned() - .collect(); - - state.update_store_with_key_counter_mirror_and_emit(&app, |store| { - reset_all_editor_data(store, &keys, &positions); - })?; - - state - .keyboard - .update_mappings_and_set_mode(keys.clone(), selected_key_type.clone()); - - for tab_id in &cleared_tab_css_ids { - state.unwatch_tab_css(tab_id); - } - let mut note_patch = NoteSettingsPatch::default(); let defaults = NoteSettings::default(); note_patch.frame_limit = Some(defaults.frame_limit); @@ -368,8 +400,7 @@ pub fn keys_reset_all(state: State<'_, AppState>, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult>(); + reset_all_editor_data(store, &keys, &positions); + let settings_diff = apply_patch_to_store(store, &settings_patch); + Ok((settings_diff, cleared_tab_css_ids)) + }, + )?; + publish_editor_change(state.inner(), &app, &transaction.change, false); + if !transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys) + { + publish_legacy_key_noop_runtime(state.inner(), &app, &transaction.change); + } + + let (settings_diff, cleared_tab_css_ids) = transaction.value; + let keys = transaction.change.document.keys; + let positions = transaction.change.document.key_positions; + let stat_positions = transaction.change.document.stat_positions; + let graph_positions = transaction.change.document.graph_positions; + let knob_positions = transaction.change.document.knob_positions; + let layer_groups = transaction.change.document.layer_groups; + let selected_key_type = transaction.change.selected_key_type; + let custom_tabs: Vec = Vec::new(); + let tab_note_overrides = crate::models::TabNoteOverrides::new(); - state.emit_settings_changed(&settings_diff, &app)?; + for tab_id in &cleared_tab_css_ids { + state.unwatch_tab_css(tab_id); + } + if let Err(error) = state.emit_settings_changed(&settings_diff, &app) { + log::error!("[Keys] failed to publish reset settings: {error:#}"); + } - app.emit("keys:changed", &keys)?; - app.emit("positions:changed", &positions)?; - app.emit("statPositions:changed", &stat_positions)?; - app.emit("graphPositions:changed", &graph_positions)?; - app.emit("knobPositions:changed", &knob_positions)?; - app.emit("layerGroups:changed", &layer_groups)?; - app.emit( + emit_best_effort(&app, "keys:changed", &keys); + emit_best_effort(&app, "positions:changed", &positions); + emit_best_effort(&app, "statPositions:changed", &stat_positions); + emit_best_effort(&app, "graphPositions:changed", &graph_positions); + emit_best_effort(&app, "knobPositions:changed", &knob_positions); + emit_best_effort(&app, "layerGroups:changed", &layer_groups); + emit_best_effort( + &app, "customTabs:changed", &CustomTabChangePayload { custom_tabs: custom_tabs.clone(), selected_key_type: selected_key_type.clone(), }, - )?; - app.emit( + ); + emit_best_effort( + &app, "keys:mode-changed", &serde_json::json!({ "mode": &selected_key_type }), - )?; - app.emit("css:use", &serde_json::json!({ "enabled": false }))?; - app.emit( + ); + emit_best_effort(&app, "css:use", &serde_json::json!({ "enabled": false })); + emit_best_effort( + &app, "css:content", &serde_json::json!({ "path": serde_json::Value::Null, "content": "" }), - )?; - app.emit("tabNote:changed_all", &tab_note_overrides)?; + ); + emit_best_effort(&app, "tabNote:changed_all", &tab_note_overrides); for tab_id in cleared_tab_css_ids { - app.emit( + emit_best_effort( + &app, "tabCss:changed", &crate::commands::editor::css::TabCssResponse { tab_id, css: None }, - )?; + ); } - state.obs_broadcast_counters(); - state.refresh_obs_snapshot(); Ok(ResetAllResponse { keys, @@ -431,42 +506,82 @@ pub fn keys_reset_mode( app: AppHandle, mode: String, ) -> CmdResult { - let snapshot = state.store.snapshot(); - let Some(kind) = reset_mode_kind(&snapshot, &mode) else { + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + |store| { + let Some(kind) = reset_mode_kind(store, &mode) else { + return Ok(None); + }; + let cleared_tab_css = store.tab_css_overrides.contains_key(&mode); + reset_mode_data(store, &mode, kind); + Ok(Some((cleared_tab_css, store.tab_note_overrides.clone()))) + }, + )?; + let Some((cleared_tab_css, tab_note_overrides)) = transaction.value else { return Ok(ResetModeResponse { success: false, mode, }); }; - let cleared_tab_css = snapshot.tab_css_overrides.contains_key(&mode); - let updated = state.update_store_with_key_counter_mirror_and_emit(&app, |store| { - reset_mode_data(store, &mode, kind); - })?; - - state.keyboard.update_mappings(updated.keys.clone()); + publish_editor_change(state.inner(), &app, &transaction.change, false); + if !transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys) + { + publish_legacy_key_noop_runtime(state.inner(), &app, &transaction.change); + } if cleared_tab_css { state.unwatch_tab_css(&mode); } - app.emit("keys:changed", &updated.keys)?; - app.emit("positions:changed", &updated.key_positions)?; - app.emit("statPositions:changed", &updated.stat_positions)?; - app.emit("graphPositions:changed", &updated.graph_positions)?; - app.emit("knobPositions:changed", &updated.knob_positions)?; - app.emit("layerGroups:changed", &updated.layer_groups)?; - app.emit("tabNote:changed_all", &updated.tab_note_overrides)?; + emit_best_effort(&app, "keys:changed", &transaction.change.document.keys); + emit_best_effort( + &app, + "positions:changed", + &transaction.change.document.key_positions, + ); + emit_best_effort( + &app, + "statPositions:changed", + &transaction.change.document.stat_positions, + ); + emit_best_effort( + &app, + "graphPositions:changed", + &transaction.change.document.graph_positions, + ); + emit_best_effort( + &app, + "knobPositions:changed", + &transaction.change.document.knob_positions, + ); + emit_best_effort( + &app, + "layerGroups:changed", + &transaction.change.document.layer_groups, + ); + emit_best_effort(&app, "tabNote:changed_all", &tab_note_overrides); if cleared_tab_css { - app.emit( + emit_best_effort( + &app, "tabCss:changed", &crate::commands::editor::css::TabCssResponse { tab_id: mode.clone(), css: None, }, - )?; + ); } - state.obs_broadcast_counters(); - state.refresh_obs_snapshot(); Ok(ResetModeResponse { success: true, @@ -493,59 +608,62 @@ pub fn custom_tabs_create( } let trimmed = name.trim().to_string(); - let snapshot = state.store.snapshot(); - if snapshot.custom_tabs.iter().any(|tab| tab.name == trimmed) { - return Ok(CustomTabCreateResult { - result: None, - error: Some("duplicate-name".to_string()), - }); - } - if snapshot.custom_tabs.len() >= MAX_CUSTOM_TABS { - return Ok(CustomTabCreateResult { - result: None, - error: Some("max-reached".to_string()), - }); - } - let id = generate_custom_tab_id(); let tab = CustomTab { id: id.clone(), - name: trimmed.clone(), + name: trimmed, }; + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("custom_tabs_create".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |store| { + if store + .custom_tabs + .iter() + .any(|existing| existing.name == tab.name) + { + return Ok(Err("duplicate-name".to_string())); + } + if store.custom_tabs.len() >= MAX_CUSTOM_TABS { + return Ok(Err("max-reached".to_string())); + } + store.custom_tabs.push(tab.clone()); + store.keys.insert(id.clone(), Vec::new()); + store.key_positions.insert(id.clone(), Vec::new()); + store.selected_key_type = id.clone(); + Ok(Ok((tab.clone(), store.custom_tabs.clone()))) + }, + )?; + let (tab, custom_tabs) = match transaction.value { + Ok(result) => result, + Err(error) => { + return Ok(CustomTabCreateResult { + result: None, + error: Some(error), + }); + } + }; + publish_editor_change(state.inner(), &app, &transaction.change, false); - let mut custom_tabs = snapshot.custom_tabs.clone(); - custom_tabs.push(tab.clone()); - - let mut keys = snapshot.keys.clone(); - keys.insert(id.clone(), Vec::new()); - let mut positions = snapshot.key_positions.clone(); - positions.insert(id.clone(), Vec::new()); - - state.store.update(|store| { - store.custom_tabs = custom_tabs.clone(); - store.keys = keys.clone(); - store.key_positions = positions.clone(); - store.selected_key_type = id.clone(); - })?; - - state - .keyboard - .update_mappings_and_set_mode(keys.clone(), id.clone()); - state.sync_counters_with_keys(&keys); - state.reset_mode_counters(&app, &id)?; - - app.emit( + emit_best_effort( + &app, "customTabs:changed", &CustomTabChangePayload { custom_tabs: custom_tabs.clone(), selected_key_type: id.clone(), }, - )?; - app.emit("keys:changed", &keys)?; - app.emit("positions:changed", &positions)?; - app.emit("keys:mode-changed", &serde_json::json!({ "mode": &id }))?; - state.obs_broadcast_counters(); - state.refresh_obs_snapshot(); + ); + emit_best_effort(&app, "keys:changed", &transaction.change.document.keys); + emit_best_effort( + &app, + "positions:changed", + &transaction.change.document.key_positions, + ); + emit_best_effort( + &app, + "keys:mode-changed", + &serde_json::json!({ "mode": &id }), + ); Ok(CustomTabCreateResult { result: Some(tab), @@ -559,54 +677,93 @@ pub fn custom_tabs_delete( app: AppHandle, id: String, ) -> CmdResult { - let snapshot = state.store.snapshot(); - let Some(plan) = plan_custom_tab_delete(&snapshot, &id) else { - return Ok(CustomTabDeleteResult { - success: false, - selected: snapshot.selected_key_type, - error: Some("not-found".to_string()), - }); + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("custom_tabs_delete".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + |store| { + let Some(plan) = plan_custom_tab_delete(store, &id) else { + return Ok(Err(store.selected_key_type.clone())); + }; + delete_custom_tab_data(store, &id, &plan); + Ok(Ok(( + store.custom_tabs.clone(), + store.selected_key_type.clone(), + store.tab_note_overrides.clone(), + ))) + }, + )?; + let (custom_tabs, selected_key_type, tab_note_overrides) = match transaction.value { + Ok(result) => result, + Err(selected) => { + return Ok(CustomTabDeleteResult { + success: false, + selected, + error: Some("not-found".to_string()), + }); + } }; - let updated = state.update_store_with_key_counter_mirror_and_emit(&app, |store| { - delete_custom_tab_data(store, &id, &plan); - })?; - - state - .keyboard - .update_mappings_and_set_mode(updated.keys.clone(), updated.selected_key_type.clone()); + publish_editor_change(state.inner(), &app, &transaction.change, false); state.unwatch_tab_css(&id); - app.emit( + emit_best_effort( + &app, "customTabs:changed", &CustomTabChangePayload { - custom_tabs: updated.custom_tabs.clone(), - selected_key_type: updated.selected_key_type.clone(), + custom_tabs, + selected_key_type: selected_key_type.clone(), }, - )?; - app.emit("keys:changed", &updated.keys)?; - app.emit("positions:changed", &updated.key_positions)?; - app.emit("statPositions:changed", &updated.stat_positions)?; - app.emit("graphPositions:changed", &updated.graph_positions)?; - app.emit("knobPositions:changed", &updated.knob_positions)?; - app.emit("layerGroups:changed", &updated.layer_groups)?; - app.emit("tabNote:changed_all", &updated.tab_note_overrides)?; - app.emit( + ); + emit_best_effort(&app, "keys:changed", &transaction.change.document.keys); + emit_best_effort( + &app, + "positions:changed", + &transaction.change.document.key_positions, + ); + emit_best_effort( + &app, + "statPositions:changed", + &transaction.change.document.stat_positions, + ); + emit_best_effort( + &app, + "graphPositions:changed", + &transaction.change.document.graph_positions, + ); + emit_best_effort( + &app, + "knobPositions:changed", + &transaction.change.document.knob_positions, + ); + emit_best_effort( + &app, + "layerGroups:changed", + &transaction.change.document.layer_groups, + ); + emit_best_effort(&app, "tabNote:changed_all", &tab_note_overrides); + emit_best_effort( + &app, "tabCss:changed", &crate::commands::editor::css::TabCssResponse { tab_id: id, css: None, }, - )?; - app.emit( + ); + emit_best_effort( + &app, "keys:mode-changed", - &serde_json::json!({ "mode": &updated.selected_key_type }), - )?; - state.obs_broadcast_counters(); - state.refresh_obs_snapshot(); + &serde_json::json!({ "mode": &selected_key_type }), + ); Ok(CustomTabDeleteResult { success: true, - selected: updated.selected_key_type, + selected: selected_key_type, error: None, }) } @@ -637,10 +794,11 @@ pub fn custom_tabs_select( let selected = state.store.set_selected_key_type(id)?; state.keyboard.set_mode(selected.clone()); - app.emit( + emit_best_effort( + &app, "keys:mode-changed", &serde_json::json!({ "mode": &selected }), - )?; + ); state.refresh_obs_snapshot(); Ok(CustomTabSelectResult { @@ -658,28 +816,93 @@ pub fn custom_tabs_restore( custom_tabs: Vec, selected_key_type: String, ) -> CmdResult<()> { - let updated = state.store.update(|store| { - store.custom_tabs = custom_tabs.clone(); - store.selected_key_type = selected_key_type.clone(); - })?; + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("custom_tabs_restore".to_string()), + &[], + move |store| { + validate_history_restore_metadata( + &EditorDocumentV1::from_store(store), + &custom_tabs, + &selected_key_type, + )?; + store.custom_tabs = custom_tabs; + store.selected_key_type = selected_key_type; + Ok((store.custom_tabs.clone(), store.selected_key_type.clone())) + }, + )?; + let (custom_tabs, selected_key_type) = transaction.value; - state.keyboard.set_mode(updated.selected_key_type.clone()); + state.keyboard.set_mode(selected_key_type.clone()); - app.emit( + emit_best_effort( + &app, "customTabs:changed", &CustomTabChangePayload { - custom_tabs: updated.custom_tabs, - selected_key_type: updated.selected_key_type.clone(), + custom_tabs, + selected_key_type: selected_key_type.clone(), }, - )?; - app.emit( + ); + emit_best_effort( + &app, "keys:mode-changed", - &serde_json::json!({ "mode": &updated.selected_key_type }), - )?; + &serde_json::json!({ "mode": &selected_key_type }), + ); state.refresh_obs_snapshot(); Ok(()) } +/// Undo/Redo의 결합 상태를 revision 선조건 아래 한 번에 복원 +#[tauri::command] +pub fn editor_history_restore( + state: State<'_, AppState>, + app: AppHandle, + request: EditorHistoryRestoreRequest, +) -> CmdResult { + let transaction = state.store.restore_editor_history(request)?; + let changed_fields = transaction.change.result.changed_fields.clone(); + + publish_editor_change(state.inner(), &app, &transaction.change, false); + publish_legacy_editor_fields(state.inner(), &app, &transaction.change, &changed_fields); + if !changed_fields.contains(&EditorField::Keys) { + publish_legacy_key_noop_runtime(state.inner(), &app, &transaction.change); + } + + let snapshot = state.store.snapshot(); + emit_best_effort( + &app, + "customTabs:changed", + &CustomTabChangePayload { + custom_tabs: snapshot.custom_tabs.clone(), + selected_key_type: snapshot.selected_key_type.clone(), + }, + ); + emit_best_effort( + &app, + "keys:mode-changed", + &serde_json::json!({ "mode": &snapshot.selected_key_type }), + ); + emit_best_effort(&app, "tabNote:changed_all", &snapshot.tab_note_overrides); + emit_best_effort( + &app, + "css:use", + &serde_json::json!({ "enabled": snapshot.use_custom_css }), + ); + emit_best_effort(&app, "css:content", &snapshot.custom_css); + emit_best_effort( + &app, + "js:use", + &serde_json::json!({ "enabled": snapshot.use_custom_js }), + ); + emit_best_effort(&app, "js:content", &snapshot.custom_js); + + if let Some(diff) = transaction.value.as_ref() { + if let Err(error) = state.emit_settings_changed(diff, &app) { + log::error!("[History] failed to publish restored settings: {error:#}"); + } + } + Ok(transaction.change.result) +} + #[tauri::command] pub fn keys_reset_counters(state: State<'_, AppState>, app: AppHandle) -> CmdResult { let snapshot = state.reset_key_counters(&app)?; @@ -733,9 +956,20 @@ pub fn layer_groups_update( app: AppHandle, groups: LayerGroups, ) -> CmdResult { - let updated = state.store.update_layer_groups(groups)?; - app.emit("layerGroups:changed", &updated)?; - state.refresh_obs_snapshot(); + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("layer_groups_update".to_string()), + &[EditorField::LayerGroups], + move |store| { + store.layer_groups = groups; + Ok(()) + }, + )?; + publish_editor_change(state.inner(), &app, &transaction.change, false); + let updated = transaction.change.document.layer_groups; + emit_best_effort(&app, "layerGroups:changed", &updated); + if transaction.change.event.is_none() { + state.refresh_obs_snapshot(); + } Ok(updated) } diff --git a/src-tauri/src/commands/keys/sound.rs b/src-tauri/src/commands/keys/sound.rs index c6fe5879..7624ec38 100644 --- a/src-tauri/src/commands/keys/sound.rs +++ b/src-tauri/src/commands/keys/sound.rs @@ -10,8 +10,12 @@ use std::{ use tauri::{Emitter, Manager, State}; use uuid::Uuid; +use crate::commands::editor::state::publish_editor_change; use crate::errors::{CmdResult, CommandError}; -use crate::models::{AppStoreData, PendingProcessedWavReplacement, SoundLibraryEntry, SoundSource}; +use crate::models::{ + AppStoreData, EditorCommitOrigin, EditorField, PendingProcessedWavReplacement, + SoundLibraryEntry, SoundSource, +}; use crate::state::{ atomic_file::{prepare_atomic_replace, PreparedAtomicReplace}, local_asset_path::paths_have_same_identity, @@ -42,6 +46,16 @@ impl SoundReferenceChangeEvent { Self::Knob => "knobPositions:changed", } } + + fn from_editor_field(field: EditorField) -> Option { + match field { + EditorField::KeyPositions => Some(Self::Key), + EditorField::StatPositions => Some(Self::Stat), + EditorField::GraphPositions => Some(Self::Graph), + EditorField::KnobPositions => Some(Self::Knob), + _ => None, + } + } } #[derive(Serialize)] @@ -469,14 +483,17 @@ pub fn sound_delete( let staged = stage_sound_files_for_deletion(&source_paths) .map_err(|error| CommandError::msg(format!("사운드 파일 삭제 준비 실패: {error:#}")))?; - let mut references_changed = false; - let updated = commit_staged_sound_deletion(&staged, || { - state - .store - .update(|store| { - references_changed = remove_sound_entry_and_references(store, &path_key); - }) - .map_err(Into::into) + let transaction = commit_staged_sound_deletion(&staged, || { + Ok(state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("sound_delete".to_string()), + &[ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ], + |store| Ok(remove_sound_entry_and_references(store, &path_key)), + )?) })?; state.key_sound_invalidate_file_cache(&path_key); @@ -485,12 +502,23 @@ pub fn sound_delete( log::warn!("[Sound] 삭제 파일 trash 이동 지연: {error:#}"); } - if references_changed { - emit_sound_reference_changes_with(|event| match event { - SoundReferenceChangeEvent::Key => app.emit(event.name(), &updated.key_positions), - SoundReferenceChangeEvent::Stat => app.emit(event.name(), &updated.stat_positions), - SoundReferenceChangeEvent::Graph => app.emit(event.name(), &updated.graph_positions), - SoundReferenceChangeEvent::Knob => app.emit(event.name(), &updated.knob_positions), + publish_editor_change(state.inner(), &app, &transaction.change, false); + if transaction.value { + emit_sound_reference_changes_with(&transaction.change.result.changed_fields, |event| { + match event { + SoundReferenceChangeEvent::Key => { + app.emit(event.name(), &transaction.change.document.key_positions) + } + SoundReferenceChangeEvent::Stat => { + app.emit(event.name(), &transaction.change.document.stat_positions) + } + SoundReferenceChangeEvent::Graph => { + app.emit(event.name(), &transaction.change.document.graph_positions) + } + SoundReferenceChangeEvent::Knob => { + app.emit(event.name(), &transaction.change.document.knob_positions) + } + } }); } @@ -572,17 +600,15 @@ where } } -fn emit_sound_reference_changes_with(mut emit: Emit) +fn emit_sound_reference_changes_with(changed_fields: &[EditorField], mut emit: Emit) where Emit: FnMut(SoundReferenceChangeEvent) -> Result<(), Error>, Error: std::fmt::Display, { - for event in [ - SoundReferenceChangeEvent::Key, - SoundReferenceChangeEvent::Stat, - SoundReferenceChangeEvent::Graph, - SoundReferenceChangeEvent::Knob, - ] { + for event in changed_fields + .iter() + .filter_map(|field| SoundReferenceChangeEvent::from_editor_field(*field)) + { if let Err(error) = emit(event) { log::warn!( "[Sound] 삭제 후 '{}' 이벤트 전송 실패: {error}", @@ -1188,7 +1214,10 @@ mod tests { use crate::{ defaults::default_positions, errors::{CmdResult, CommandError}, - models::AppStoreData, + models::{ + AppStoreData, EditorDocumentV1, EditorField, GraphPosition, GraphStatType, GraphType, + KeyPosition, KnobPosition, StatPosition, StatType, + }, state::{ atomic_file::prepare_atomic_replace, store::{ @@ -1247,6 +1276,82 @@ mod tests { data } + fn position_with_sound(path_key: &str) -> KeyPosition { + KeyPosition { + sound_path: Some(path_key.to_string()), + sound_enabled: Some(true), + ..Default::default() + } + } + + fn sound_delete_all_position_data(path_key: &str) -> AppStoreData { + let mut data = AppStoreData::default(); + data.sound_library + .insert(path_key.to_string(), Default::default()); + data.keys + .insert("4key".to_string(), vec!["KeyA".to_string()]); + data.key_positions + .insert("4key".to_string(), vec![position_with_sound(path_key)]); + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position_with_sound(path_key), + }], + ); + data.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#ffffff".to_string(), + show_avg_line: true, + position: position_with_sound(path_key), + }], + ); + data.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: position_with_sound(path_key), + }], + ); + data + } + + #[test] + fn sound_delete_clears_all_position_references_and_reports_actual_fields() { + let path_key = "/sounds/deleted.wav"; + let mut data = sound_delete_all_position_data(path_key); + let before = EditorDocumentV1::from_store(&data); + + assert!(remove_sound_entry_and_references(&mut data, path_key)); + + let after = EditorDocumentV1::from_store(&data); + assert!(!data.sound_library.contains_key(path_key)); + assert_eq!( + before.changed_fields(&after), + vec![ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + for position in [ + &data.key_positions["4key"][0], + &data.stat_positions["4key"][0].position, + &data.graph_positions["4key"][0].position, + &data.knob_positions["4key"][0].position, + ] { + assert_eq!(position.sound_path, None); + assert_eq!(position.sound_enabled, Some(false)); + } + } + #[test] fn incomplete_sound_scan_never_prunes_library_metadata() { let library = std::collections::HashMap::from([ @@ -1609,20 +1714,26 @@ mod tests { fn sound_delete_event_failure_does_not_stop_remaining_notifications() { let attempted = RefCell::new(Vec::new()); - emit_sound_reference_changes_with(|event| { - attempted.borrow_mut().push(event.name()); - if event == SoundReferenceChangeEvent::Key { - Err("injected emit failure") - } else { - Ok(()) - } - }); + emit_sound_reference_changes_with( + &[ + EditorField::KeyPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ], + |event| { + attempted.borrow_mut().push(event.name()); + if event == SoundReferenceChangeEvent::Key { + Err("injected emit failure") + } else { + Ok(()) + } + }, + ); assert_eq!( *attempted.borrow(), [ "positions:changed", - "statPositions:changed", "graphPositions:changed", "knobPositions:changed", ] diff --git a/src-tauri/src/commands/layout/graph_items.rs b/src-tauri/src/commands/layout/graph_items.rs index 2c7a0628..62ae6fe0 100644 --- a/src-tauri/src/commands/layout/graph_items.rs +++ b/src-tauri/src/commands/layout/graph_items.rs @@ -1,6 +1,11 @@ -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; -use crate::{errors::CmdResult, models::GraphPositions, state::AppState}; +use crate::{ + commands::editor::state::{emit_best_effort, publish_editor_change}, + errors::CmdResult, + models::{EditorCommitOrigin, EditorField, GraphPositions}, + state::AppState, +}; #[tauri::command] pub fn graph_positions_get(state: State<'_, AppState>) -> CmdResult { @@ -13,8 +18,19 @@ pub fn graph_positions_update( app: AppHandle, positions: GraphPositions, ) -> CmdResult { - let updated = state.store.update_graph_positions(positions)?; - app.emit("graphPositions:changed", &updated)?; - state.refresh_obs_snapshot(); + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("graph_positions_update".to_string()), + &[EditorField::GraphPositions], + move |store| { + store.graph_positions = positions; + Ok(()) + }, + )?; + publish_editor_change(state.inner(), &app, &transaction.change, false); + let updated = transaction.change.document.graph_positions; + emit_best_effort(&app, "graphPositions:changed", &updated); + if transaction.change.event.is_none() { + state.refresh_obs_snapshot(); + } Ok(updated) } diff --git a/src-tauri/src/commands/layout/knob_items.rs b/src-tauri/src/commands/layout/knob_items.rs index d9177bbb..d994e7ab 100644 --- a/src-tauri/src/commands/layout/knob_items.rs +++ b/src-tauri/src/commands/layout/knob_items.rs @@ -1,6 +1,11 @@ -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; -use crate::{errors::CmdResult, models::KnobPositions, state::AppState}; +use crate::{ + commands::editor::state::{emit_best_effort, publish_editor_change}, + errors::CmdResult, + models::{EditorCommitOrigin, EditorField, KnobPositions}, + state::AppState, +}; #[tauri::command] pub fn knob_positions_get(state: State<'_, AppState>) -> CmdResult { @@ -13,8 +18,19 @@ pub fn knob_positions_update( app: AppHandle, positions: KnobPositions, ) -> CmdResult { - let updated = state.store.update_knob_positions(positions)?; - app.emit("knobPositions:changed", &updated)?; - state.refresh_obs_snapshot(); + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("knob_positions_update".to_string()), + &[EditorField::KnobPositions], + move |store| { + store.knob_positions = positions; + Ok(()) + }, + )?; + publish_editor_change(state.inner(), &app, &transaction.change, false); + let updated = transaction.change.document.knob_positions; + emit_best_effort(&app, "knobPositions:changed", &updated); + if transaction.change.event.is_none() { + state.refresh_obs_snapshot(); + } Ok(updated) } diff --git a/src-tauri/src/commands/layout/stat_items.rs b/src-tauri/src/commands/layout/stat_items.rs index 06294986..8a5b6dd5 100644 --- a/src-tauri/src/commands/layout/stat_items.rs +++ b/src-tauri/src/commands/layout/stat_items.rs @@ -1,6 +1,11 @@ -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; -use crate::{errors::CmdResult, models::StatPositions, state::AppState}; +use crate::{ + commands::editor::state::{emit_best_effort, publish_editor_change}, + errors::CmdResult, + models::{EditorCommitOrigin, EditorField, StatPositions}, + state::AppState, +}; #[tauri::command] pub fn stat_positions_get(state: State<'_, AppState>) -> CmdResult { @@ -13,8 +18,19 @@ pub fn stat_positions_update( app: AppHandle, positions: StatPositions, ) -> CmdResult { - let updated = state.store.update_stat_positions(positions)?; - app.emit("statPositions:changed", &updated)?; - state.refresh_obs_snapshot(); + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("stat_positions_update".to_string()), + &[EditorField::StatPositions], + move |store| { + store.stat_positions = positions; + Ok(()) + }, + )?; + publish_editor_change(state.inner(), &app, &transaction.change, false); + let updated = transaction.change.document.stat_positions; + emit_best_effort(&app, "statPositions:changed", &updated); + if transaction.change.event.is_none() { + state.refresh_obs_snapshot(); + } Ok(updated) } diff --git a/src-tauri/src/commands/media/counter_animation.rs b/src-tauri/src/commands/media/counter_animation.rs index 287b5cfd..e7e87429 100644 --- a/src-tauri/src/commands/media/counter_animation.rs +++ b/src-tauri/src/commands/media/counter_animation.rs @@ -1,13 +1,14 @@ use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; use uuid::Uuid; use crate::{ + commands::editor::state::{emit_best_effort, publish_editor_change}, errors::{CmdResult, CommandError}, models::{ default_counter_animation_builtin_presets, default_counter_animation_preset_id, - find_builtin_counter_animation_preset_by_id, CounterAnimationPreset, - CounterAnimationSource, + find_builtin_counter_animation_preset_by_id, CommittedEditorChange, CounterAnimationPreset, + CounterAnimationSource, EditorCommitOrigin, EditorField, }, state::AppState, }; @@ -88,7 +89,7 @@ pub fn counter_animation_create( store.counter_animation_presets.push(next_preset.clone()); })?; - emit_counter_animation_changed(&app, &updated.counter_animation_presets)?; + emit_counter_animation_changed(&app, &updated.counter_animation_presets); Ok(CounterAnimationUpsertResponse { preset, @@ -134,29 +135,35 @@ pub fn counter_animation_update( } let next_preset = preset.clone(); - let mut affected_usage_count = 0u32; - - let updated = state.store.update(|store| { - if let Some(item) = store - .counter_animation_presets - .iter_mut() - .find(|item| item.id == target_id) - { - *item = next_preset.clone(); - } + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("counter_animation_update".to_string()), + &[ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + ], + |store| { + if let Some(item) = store + .counter_animation_presets + .iter_mut() + .find(|item| item.id == target_id) + { + *item = next_preset.clone(); + } - affected_usage_count = apply_preset_to_bound_counters(store, &target_id, &next_preset); - })?; + let affected_usage_count = + apply_preset_to_bound_counters(store, &target_id, &next_preset); + Ok(( + store.counter_animation_presets.clone(), + affected_usage_count, + )) + }, + )?; + let (user_presets, affected_usage_count) = transaction.value; - emit_counter_animation_changed(&app, &updated.counter_animation_presets)?; - if affected_usage_count > 0 { - emit_positions_changed( - &app, - &updated.key_positions, - &updated.stat_positions, - &updated.graph_positions, - )?; - } + publish_editor_change(state.inner(), &app, &transaction.change, false); + emit_counter_animation_changed(&app, &user_presets); + emit_positions_changed(&app, &transaction.change, affected_usage_count); Ok(CounterAnimationUpsertResponse { preset, @@ -191,27 +198,33 @@ pub fn counter_animation_delete( .ok_or_else(|| CommandError::msg("default builtin counter animation preset missing"))?; let fallback_preset_id = fallback_preset.id.clone(); - let mut affected_usage_count = 0u32; let fallback_target = fallback_preset.clone(); - let updated = state.store.update(|store| { - store - .counter_animation_presets - .retain(|preset| preset.id != target_id); - - affected_usage_count = - apply_fallback_to_bound_counters(store, &target_id, &fallback_target); - })?; + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("counter_animation_delete".to_string()), + &[ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + ], + |store| { + store + .counter_animation_presets + .retain(|preset| preset.id != target_id); + + let affected_usage_count = + apply_fallback_to_bound_counters(store, &target_id, &fallback_target); + Ok(( + store.counter_animation_presets.clone(), + affected_usage_count, + )) + }, + )?; + let (user_presets, affected_usage_count) = transaction.value; - emit_counter_animation_changed(&app, &updated.counter_animation_presets)?; - if affected_usage_count > 0 { - emit_positions_changed( - &app, - &updated.key_positions, - &updated.stat_positions, - &updated.graph_positions, - )?; - } + publish_editor_change(state.inner(), &app, &transaction.change, false); + emit_counter_animation_changed(&app, &user_presets); + emit_positions_changed(&app, &transaction.change, affected_usage_count); Ok(CounterAnimationDeleteResponse { success: true, @@ -228,27 +241,61 @@ fn build_library_payload(user_presets: &[CounterAnimationPreset]) -> CounterAnim } } -fn emit_counter_animation_changed( - app: &AppHandle, - user_presets: &[CounterAnimationPreset], -) -> CmdResult<()> { - app.emit( +fn emit_counter_animation_changed(app: &AppHandle, user_presets: &[CounterAnimationPreset]) { + emit_best_effort( + app, "counterAnimation:changed", &build_library_payload(user_presets), - )?; - Ok(()) + ); } fn emit_positions_changed( app: &AppHandle, - key_positions: &crate::models::KeyPositions, - stat_positions: &crate::models::StatPositions, - graph_positions: &crate::models::GraphPositions, -) -> CmdResult<()> { - app.emit("positions:changed", key_positions)?; - app.emit("statPositions:changed", stat_positions)?; - app.emit("graphPositions:changed", graph_positions)?; - Ok(()) + change: &CommittedEditorChange, + affected_usage_count: u32, +) { + if affected_usage_count == 0 { + return; + } + + // 같은 애니메이션 값을 다시 저장한 경우에도 기존 refresh 이벤트 계약 유지 + if change.result.changed_fields.is_empty() { + emit_best_effort(app, "positions:changed", &change.document.key_positions); + emit_best_effort( + app, + "statPositions:changed", + &change.document.stat_positions, + ); + emit_best_effort( + app, + "graphPositions:changed", + &change.document.graph_positions, + ); + return; + } + + for field in &change.result.changed_fields { + match field { + EditorField::KeyPositions => { + emit_best_effort(app, "positions:changed", &change.document.key_positions); + } + EditorField::StatPositions => { + emit_best_effort( + app, + "statPositions:changed", + &change.document.stat_positions, + ); + } + EditorField::GraphPositions => { + emit_best_effort( + app, + "graphPositions:changed", + &change.document.graph_positions, + ); + } + _ => {} + } + } } fn apply_preset_to_bound_counters( @@ -318,3 +365,100 @@ fn update_counter_animation_if_bound( counter.normalize(); true } + +#[cfg(test)] +mod tests { + use super::apply_preset_to_bound_counters; + use crate::models::{ + AppStoreData, CounterAnimationPreset, CounterAnimationSource, EditorDocumentV1, + EditorField, GraphPosition, GraphStatType, GraphType, KeyPosition, StatPosition, StatType, + }; + + const MODE: &str = "4key"; + const TARGET_PRESET_ID: &str = "user-target"; + + fn target_preset() -> CounterAnimationPreset { + CounterAnimationPreset { + id: TARGET_PRESET_ID.to_string(), + name: "Target".to_string(), + source: CounterAnimationSource::User, + label_key: None, + bezier: [0.1, 0.2, 0.8, 0.9], + scale: 1.25, + duration_ms: 420, + } + } + + fn position(bound: bool) -> KeyPosition { + let mut position = KeyPosition::default(); + if bound { + position.counter.animation.preset_id = Some(TARGET_PRESET_ID.to_string()); + } + position + } + + fn counter_store(key_bound: bool, stat_bound: bool, graph_bound: bool) -> AppStoreData { + let mut store = AppStoreData::default(); + store + .keys + .insert(MODE.to_string(), vec!["KeyA".to_string()]); + store + .key_positions + .insert(MODE.to_string(), vec![position(key_bound)]); + store.stat_positions.insert( + MODE.to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(stat_bound), + }], + ); + store.graph_positions.insert( + MODE.to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#ffffff".to_string(), + show_avg_line: true, + position: position(graph_bound), + }], + ); + store + } + + #[test] + fn preset_update_changes_key_stat_and_graph_references() { + let mut store = counter_store(true, true, true); + let before = EditorDocumentV1::from_store(&store); + + let affected = + apply_preset_to_bound_counters(&mut store, TARGET_PRESET_ID, &target_preset()); + let after = EditorDocumentV1::from_store(&store); + + assert_eq!(affected, 3); + assert_eq!( + before.changed_fields(&after), + vec![ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + ] + ); + } + + #[test] + fn preset_update_reports_only_actually_changed_collections() { + let mut store = counter_store(false, true, false); + let before = EditorDocumentV1::from_store(&store); + + let affected = + apply_preset_to_bound_counters(&mut store, TARGET_PRESET_ID, &target_preset()); + let after = EditorDocumentV1::from_store(&store); + + assert_eq!(affected, 1); + assert_eq!( + before.changed_fields(&after), + vec![EditorField::StatPositions] + ); + } +} diff --git a/src-tauri/src/commands/media/image.rs b/src-tauri/src/commands/media/image.rs index 3e365869..09b93693 100644 --- a/src-tauri/src/commands/media/image.rs +++ b/src-tauri/src/commands/media/image.rs @@ -13,7 +13,9 @@ use webp_animation::{ }; use crate::{ + commands::editor::state::{emit_best_effort, publish_editor_change}, errors::{CmdResult, CommandError}, + models::{AppStoreData, CommittedEditorChange, EditorCommitOrigin, EditorField, KeyPosition}, state::AppState, }; @@ -116,20 +118,21 @@ fn replace_store_image_path_references( let state = app.state::(); let snapshot = state.store.snapshot(); let has_reference = snapshot.key_positions.values().any(|positions| { - positions.iter().any(|position| { - position.active_image.as_deref() == Some(from.as_str()) - || position.inactive_image.as_deref() == Some(from.as_str()) - }) + positions + .iter() + .any(|position| position_references_image(position, &from)) }) || snapshot.stat_positions.values().any(|positions| { - positions.iter().any(|stat_position| { - stat_position.position.active_image.as_deref() == Some(from.as_str()) - || stat_position.position.inactive_image.as_deref() == Some(from.as_str()) - }) + positions + .iter() + .any(|stat_position| position_references_image(&stat_position.position, &from)) }) || snapshot.graph_positions.values().any(|positions| { - positions.iter().any(|graph_position| { - graph_position.position.active_image.as_deref() == Some(from.as_str()) - || graph_position.position.inactive_image.as_deref() == Some(from.as_str()) - }) + positions + .iter() + .any(|graph_position| position_references_image(&graph_position.position, &from)) + }) || snapshot.knob_positions.values().any(|positions| { + positions + .iter() + .any(|knob_position| position_references_image(&knob_position.position, &from)) }); if !has_reference { @@ -141,50 +144,18 @@ fn replace_store_image_path_references( return Ok(()); } - let mut changed = false; - - let updated = state.store.update(|store| { - for positions in store.key_positions.values_mut() { - for position in positions.iter_mut() { - if position.active_image.as_deref() == Some(from.as_str()) { - position.active_image = Some(to.clone()); - changed = true; - } - if position.inactive_image.as_deref() == Some(from.as_str()) { - position.inactive_image = Some(to.clone()); - changed = true; - } - } - } - - for positions in store.stat_positions.values_mut() { - for stat_position in positions.iter_mut() { - if stat_position.position.active_image.as_deref() == Some(from.as_str()) { - stat_position.position.active_image = Some(to.clone()); - changed = true; - } - if stat_position.position.inactive_image.as_deref() == Some(from.as_str()) { - stat_position.position.inactive_image = Some(to.clone()); - changed = true; - } - } - } - - for positions in store.graph_positions.values_mut() { - for graph_position in positions.iter_mut() { - if graph_position.position.active_image.as_deref() == Some(from.as_str()) { - graph_position.position.active_image = Some(to.clone()); - changed = true; - } - if graph_position.position.inactive_image.as_deref() == Some(from.as_str()) { - graph_position.position.inactive_image = Some(to.clone()); - changed = true; - } - } - } - })?; - - if !changed { + let transaction = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("image_optimize".to_string()), + &[ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ], + |store| Ok(replace_image_path_references(store, &from, &to)), + )?; + + if !transaction.value { // snapshot 이후 참조가 사라진 레이스 케이스 let _ = app.emit( "image:optimized", @@ -193,9 +164,8 @@ fn replace_store_image_path_references( return Ok(()); } - app.emit("positions:changed", &updated.key_positions)?; - app.emit("statPositions:changed", &updated.stat_positions)?; - app.emit("graphPositions:changed", &updated.graph_positions)?; + publish_editor_change(state.inner(), app, &transaction.change, false); + emit_image_position_changes(app, &transaction.change); let _ = app.emit( "image:optimized", serde_json::json!({ "fromPath": from, "toPath": to }), @@ -204,6 +174,83 @@ fn replace_store_image_path_references( Ok(()) } +fn position_references_image(position: &KeyPosition, path: &str) -> bool { + position.active_image.as_deref() == Some(path) + || position.inactive_image.as_deref() == Some(path) +} + +fn replace_position_image_paths(position: &mut KeyPosition, from: &str, to: &str) -> bool { + let mut changed = false; + if position.active_image.as_deref() == Some(from) { + position.active_image = Some(to.to_string()); + changed = true; + } + if position.inactive_image.as_deref() == Some(from) { + position.inactive_image = Some(to.to_string()); + changed = true; + } + changed +} + +fn replace_image_path_references(store: &mut AppStoreData, from: &str, to: &str) -> bool { + let mut changed = false; + + for positions in store.key_positions.values_mut() { + for position in positions { + changed |= replace_position_image_paths(position, from, to); + } + } + for positions in store.stat_positions.values_mut() { + for position in positions { + changed |= replace_position_image_paths(&mut position.position, from, to); + } + } + for positions in store.graph_positions.values_mut() { + for position in positions { + changed |= replace_position_image_paths(&mut position.position, from, to); + } + } + for positions in store.knob_positions.values_mut() { + for position in positions { + changed |= replace_position_image_paths(&mut position.position, from, to); + } + } + + changed +} + +fn emit_image_position_changes(app: &tauri::AppHandle, change: &CommittedEditorChange) { + for field in &change.result.changed_fields { + match field { + EditorField::KeyPositions => { + emit_best_effort(app, "positions:changed", &change.document.key_positions); + } + EditorField::StatPositions => { + emit_best_effort( + app, + "statPositions:changed", + &change.document.stat_positions, + ); + } + EditorField::GraphPositions => { + emit_best_effort( + app, + "graphPositions:changed", + &change.document.graph_positions, + ); + } + EditorField::KnobPositions => { + emit_best_effort( + app, + "knobPositions:changed", + &change.document.knob_positions, + ); + } + _ => {} + } + } +} + fn try_convert_gif_to_cached_webp( source_path: &Path, images_dir: &Path, @@ -339,3 +386,44 @@ fn normalize_image_extension(extension: Option<&str>) -> String { _ => "png".to_string(), } } + +#[cfg(test)] +mod tests { + use super::replace_image_path_references; + use crate::models::{AppStoreData, EditorDocumentV1, EditorField, KeyPosition, KnobPosition}; + + #[test] + fn optimizer_replaces_knob_active_and_inactive_image_references() { + let from = "/images/source.gif"; + let to = "/images/optimized.webp"; + let mut position = KeyPosition { + active_image: Some(from.to_string()), + inactive_image: Some(from.to_string()), + ..Default::default() + }; + position.sound_path = Some("/sounds/unchanged.wav".to_string()); + + let mut store = AppStoreData::default(); + store.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }], + ); + let before = EditorDocumentV1::from_store(&store); + + assert!(replace_image_path_references(&mut store, from, to)); + + let knob = &store.knob_positions["4key"][0].position; + assert_eq!(knob.active_image.as_deref(), Some(to)); + assert_eq!(knob.inactive_image.as_deref(), Some(to)); + assert_eq!(knob.sound_path.as_deref(), Some("/sounds/unchanged.wav")); + assert_eq!( + before.changed_fields(&EditorDocumentV1::from_store(&store)), + vec![EditorField::KnobPositions] + ); + } +} diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index c781b37b..8fa56f0d 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -6,17 +6,21 @@ use std::{ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use rfd::FileDialog; -use tauri::{AppHandle, Emitter, Manager, State}; +use tauri::{AppHandle, Manager, State}; use uuid::Uuid; use crate::{ - commands::editor::css::TabCssResponse, + commands::editor::{ + css::TabCssResponse, + state::{emit_best_effort, publish_editor_change}, + }, defaults::{default_keys, default_positions}, errors::{CmdResult, CommandError}, models::{ - CustomCssPatch, CustomJsPatch, FontSettings, FontType, GraphPositions, KeyMappings, - KeyPositions, KnobPositions, NoteSettingsPatch, SettingsPatchInput, StatPositions, - TabCssOverrides, + AppStoreData, CommittedEditorChange, CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, + EditorCommitOrigin, EditorField, FontSettings, FontType, GraphPositions, KeyMappings, + KeyPosition, KeyPositions, KnobPositions, LayerGroups, NoteSettings, NoteSettingsPatch, + SettingsPatchInput, StatPositions, TabCssOverrides, TabNoteSettings, }, services::settings::apply_patch_to_store, state::AppState, @@ -29,6 +33,70 @@ use super::{ PRESET_LOCAL_SOUND_PREFIX, }; +fn apply_editor_runtime_best_effort( + state: &AppState, + app: &AppHandle, + change: &CommittedEditorChange, +) { + if let Err(error) = state.apply_committed_editor_key_runtime( + app, + &change.document.keys, + &change.selected_key_type, + &change.key_counters, + ) { + log::error!("[Preset] failed to publish committed key counters: {error:#}"); + } + state.obs_broadcast_counters(); + state.refresh_obs_snapshot(); +} + +fn read_preset_file(path: &Path) -> CmdResult { + let content = fs::read_to_string(path)?; + serde_json::from_str(&content).map_err(|_| CommandError::msg("invalid-preset")) +} + +struct ResolvedFullPresetSettings { + background_color: String, + note_settings: NoteSettings, + note_effect: bool, + laboratory_enabled: bool, + use_custom_css: bool, + custom_css: CustomCss, + use_custom_js: bool, + custom_js: CustomJs, +} + +/// 과거 프리셋에 없던 전역 필드는 현재 값을 보존해 신규 설정을 지우지 않음 +fn resolve_full_preset_settings( + preset: &mut PresetFile, + current: &AppStoreData, +) -> ResolvedFullPresetSettings { + ResolvedFullPresetSettings { + background_color: preset + .background_color + .take() + .unwrap_or_else(|| current.background_color.clone()), + note_settings: preset + .note_settings + .take() + .unwrap_or_else(|| current.note_settings.clone()), + note_effect: preset.note_effect.unwrap_or(current.note_effect), + laboratory_enabled: preset + .laboratory_enabled + .unwrap_or(current.laboratory_enabled), + use_custom_css: preset.use_custom_css.unwrap_or(current.use_custom_css), + custom_css: preset + .custom_css + .take() + .unwrap_or_else(|| current.custom_css.clone()), + use_custom_js: preset.use_custom_js.unwrap_or(current.use_custom_js), + custom_js: preset + .custom_js + .take() + .unwrap_or_else(|| current.custom_js.clone()), + } +} + #[tauri::command] pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult { let picked = FileDialog::new() @@ -42,11 +110,11 @@ pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult CmdResult<()> { +) { let tab_ids: BTreeSet = previous.keys().chain(current.keys()).cloned().collect(); for tab_id in tab_ids { @@ -464,16 +562,15 @@ fn sync_tab_css_runtime( } } - app.emit( + emit_best_effort( + app, "tabCss:changed", &TabCssResponse { tab_id: tab_id.clone(), css, }, - )?; + ); } - - Ok(()) } fn choose_tab_preset_source_tab( @@ -504,7 +601,68 @@ fn choose_tab_preset_source_tab( Err(CommandError::msg("tab-preset-ambiguous-source")) } +fn align_imported_key_pair(keys: &mut Vec, positions: &mut Vec) { + if keys.len() < positions.len() { + keys.resize(positions.len(), String::new()); + } else if positions.len() < keys.len() { + positions.resize(keys.len(), KeyPosition::default()); + } +} + +fn align_imported_key_collections(keys: &mut KeyMappings, positions: &mut KeyPositions) { + let modes = keys + .keys() + .chain(positions.keys()) + .cloned() + .collect::>(); + for mode in modes { + let mode_keys = keys.entry(mode.clone()).or_default(); + let mode_positions = positions.entry(mode).or_default(); + align_imported_key_pair(mode_keys, mode_positions); + } +} + +fn merge_tab_preset_key_pair( + store: &mut AppStoreData, + current_tab_id: &str, + mut keys: Vec, + imported_positions: Option>, +) { + let mut positions = imported_positions.unwrap_or_else(|| { + store + .key_positions + .get(current_tab_id) + .cloned() + .unwrap_or_default() + }); + align_imported_key_pair(&mut keys, &mut positions); + store.keys.insert(current_tab_id.to_string(), keys); + store + .key_positions + .insert(current_tab_id.to_string(), positions); +} + +#[cfg(test)] fn merge_tab_preset_fonts( + existing_font_settings: &FontSettings, + imported_font_settings: FontSettings, + restore_fonts: impl FnOnce(&mut FontSettings) -> CmdResult<()>, +) -> CmdResult> { + let Some(prepared) = prepare_tab_preset_fonts( + existing_font_settings, + imported_font_settings, + restore_fonts, + )? + else { + return Ok(None); + }; + Ok(merge_prepared_tab_preset_fonts( + existing_font_settings, + prepared, + )) +} + +fn prepare_tab_preset_fonts( existing_font_settings: &FontSettings, mut imported_font_settings: FontSettings, restore_fonts: impl FnOnce(&mut FontSettings) -> CmdResult<()>, @@ -538,11 +696,40 @@ fn merge_tab_preset_fonts( existing_ids.insert(font.id.clone()); } - let mut merged = existing_font_settings.clone(); - merged + Ok(Some(imported_font_settings)) +} + +fn merge_prepared_tab_preset_fonts( + existing_font_settings: &FontSettings, + mut prepared: FontSettings, +) -> Option { + let mut existing_names = existing_font_settings + .custom_fonts + .iter() + .map(|font| font.name.clone()) + .collect::>(); + prepared .custom_fonts - .extend(imported_font_settings.custom_fonts); - Ok(Some(merged)) + .retain(|font| existing_names.insert(font.name.clone())); + if prepared.custom_fonts.is_empty() { + return None; + } + + let mut existing_ids = existing_font_settings + .custom_fonts + .iter() + .map(|font| font.id.clone()) + .collect::>(); + for font in &mut prepared.custom_fonts { + if existing_ids.contains(&font.id) { + font.id = Uuid::new_v4().to_string(); + } + existing_ids.insert(font.id.clone()); + } + + let mut merged = existing_font_settings.clone(); + merged.custom_fonts.extend(prepared.custom_fonts); + Some(merged) } fn restore_preset_local_fonts( @@ -1053,14 +1240,282 @@ fn choose_selected_key_type( "4key".to_string() } +fn apply_tab_note_override( + store: &mut AppStoreData, + tab_id: &str, + field_present: bool, + imported: Option, +) { + if !field_present { + return; + } + if let Some(settings) = imported { + store + .tab_note_overrides + .insert(tab_id.to_string(), settings); + } else { + store.tab_note_overrides.remove(tab_id); + } +} + +fn resolve_full_preset_layer_groups( + imported: Option, + keys: &KeyMappings, +) -> LayerGroups { + imported.unwrap_or_else(|| { + keys.keys() + .cloned() + .map(|mode| (mode, Vec::new())) + .collect() + }) +} + #[cfg(test)] mod tests { use super::*; use crate::{ - defaults::default_positions, - models::{CustomFont, KnobPosition}, + defaults::{default_keys, default_positions}, + models::{CustomFont, JsPlugin, KnobPosition}, }; + #[test] + fn preset_source_bytes_remain_unchanged_on_success_and_parse_failure() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-read-only-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + + let valid_source = br#"{ + "keys": { "custom": ["Q"] }, + "keyPositions": { + "custom": [{ "dx": 12.5, "dy": -4.0, "width": 61.0, "count": 3 }] + }, + "customJS": { + "path": null, + "content": "globalThis.oldPreset = true", + "plugins": [{ + "id": "plugin-source", + "name": "Source plugin", + "path": null, + "content": "void 0", + "enabled": true + }] + }, + "embeddedLocalImages": [{ + "imageId": "image-source", + "extension": "png", + "dataBase64": "AA==" + }] +}"#; + std::fs::write(&source_path, valid_source).unwrap(); + let parsed = read_preset_file(&source_path).unwrap(); + assert_eq!(parsed.keys.as_ref().unwrap()["custom"], ["Q"]); + assert_eq!(parsed.key_positions.as_ref().unwrap()["custom"][0].dx, 12.5); + assert_eq!( + parsed.custom_js.as_ref().unwrap().plugins[0].id, + "plugin-source" + ); + assert_eq!( + parsed.embedded_local_images.as_ref().unwrap()[0].image_id, + "image-source" + ); + assert_eq!(std::fs::read(&source_path).unwrap(), valid_source); + + let invalid_source = b"{ invalid preset"; + std::fs::write(&source_path, invalid_source).unwrap(); + assert!(read_preset_file(&source_path).is_err()); + assert_eq!(std::fs::read(&source_path).unwrap(), invalid_source); + + let _ = std::fs::remove_dir_all(temp_dir); + } + + #[test] + fn missing_legacy_global_fields_preserve_current_plugin_and_style_settings() { + let current = AppStoreData { + background_color: "#123456".to_string(), + note_settings: NoteSettings { + speed: 777, + ..NoteSettings::default() + }, + note_effect: true, + laboratory_enabled: true, + use_custom_css: true, + custom_css: CustomCss { + path: Some("/current/style.css".to_string()), + content: ".current {}".to_string(), + }, + use_custom_js: true, + custom_js: CustomJs { + path: None, + content: "globalThis.current = true".to_string(), + plugins: vec![JsPlugin { + id: "current-plugin".to_string(), + name: "Current plugin".to_string(), + path: None, + content: "void 0".to_string(), + enabled: true, + }], + }, + ..AppStoreData::default() + }; + let mut legacy = PresetFile::default(); + + let resolved = resolve_full_preset_settings(&mut legacy, ¤t); + + assert_eq!(resolved.background_color, current.background_color); + assert_eq!(resolved.note_settings, current.note_settings); + assert_eq!(resolved.note_effect, current.note_effect); + assert_eq!(resolved.laboratory_enabled, current.laboratory_enabled); + assert_eq!(resolved.use_custom_css, current.use_custom_css); + assert_eq!(resolved.custom_css, current.custom_css); + assert_eq!(resolved.use_custom_js, current.use_custom_js); + assert_eq!(resolved.custom_js, current.custom_js); + } + + #[test] + fn tauri_130_literal_without_js_fields_preserves_installed_plugins() { + let current = AppStoreData { + use_custom_js: true, + custom_js: CustomJs { + path: None, + content: "globalThis.current = true".to_string(), + plugins: vec![JsPlugin { + id: "installed-plugin".to_string(), + name: "Installed plugin".to_string(), + path: None, + content: "globalThis.installed = true".to_string(), + enabled: true, + }], + }, + ..AppStoreData::default() + }; + let mut preset: PresetFile = serde_json::from_value(serde_json::json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { "4key": [{ "dx": 1, "dy": 2, "width": 60, "count": 0 }] }, + "backgroundColor": "transparent" + })) + .unwrap(); + + let resolved = resolve_full_preset_settings(&mut preset, ¤t); + + assert!(resolved.use_custom_js); + assert_eq!(resolved.custom_js, current.custom_js); + } + + #[test] + fn tauri_161_literal_imports_its_plugin_list_exactly() { + let mut current = AppStoreData::default(); + current.custom_js.plugins.push(JsPlugin { + id: "current-plugin".to_string(), + name: "Current plugin".to_string(), + path: None, + content: "void 0".to_string(), + enabled: true, + }); + let mut preset: PresetFile = serde_json::from_value(serde_json::json!({ + "useCustomJS": true, + "customJS": { + "path": null, + "content": "globalThis.legacy = true", + "plugins": [{ + "id": "plugin-161", + "name": "Legacy plugin", + "path": null, + "content": "globalThis.plugin161 = true", + "enabled": true + }] + } + })) + .unwrap(); + + let resolved = resolve_full_preset_settings(&mut preset, ¤t); + + assert!(resolved.use_custom_js); + assert_eq!(resolved.custom_js.plugins.len(), 1); + assert_eq!(resolved.custom_js.plugins[0].id, "plugin-161"); + assert!(!resolved + .custom_js + .plugins + .iter() + .any(|plugin| plugin.id == "current-plugin")); + } + + #[test] + fn explicit_empty_plugin_settings_still_clear_current_plugins() { + let current = AppStoreData { + use_custom_js: true, + custom_js: CustomJs { + plugins: vec![JsPlugin { + id: "current-plugin".to_string(), + name: "Current plugin".to_string(), + path: None, + content: "void 0".to_string(), + enabled: true, + }], + ..CustomJs::default() + }, + ..AppStoreData::default() + }; + let mut preset = PresetFile { + use_custom_js: Some(false), + custom_js: Some(CustomJs::default()), + ..PresetFile::default() + }; + + let resolved = resolve_full_preset_settings(&mut preset, ¤t); + + assert!(!resolved.use_custom_js); + assert_eq!(resolved.custom_js, CustomJs::default()); + } + + #[test] + fn legacy_tab_preset_without_note_override_preserves_current_override() { + let mut store = AppStoreData::default(); + let current = TabNoteSettings { + speed: Some(654), + ..TabNoteSettings::default() + }; + store + .tab_note_overrides + .insert("4key".to_string(), current.clone()); + + apply_tab_note_override(&mut store, "4key", false, None); + + assert_eq!(store.tab_note_overrides["4key"], current); + } + + #[test] + fn explicit_empty_tab_note_override_removes_current_override() { + let mut store = AppStoreData::default(); + store.tab_note_overrides.insert( + "4key".to_string(), + TabNoteSettings { + speed: Some(654), + ..TabNoteSettings::default() + }, + ); + + apply_tab_note_override(&mut store, "4key", true, None); + + assert!(!store.tab_note_overrides.contains_key("4key")); + } + + #[test] + fn historical_full_preset_without_groups_starts_each_imported_mode_ungrouped() { + let keys = KeyMappings::from([ + ("4key".to_string(), vec!["A".to_string()]), + ("custom-old".to_string(), vec!["B".to_string()]), + ]); + + let groups = resolve_full_preset_layer_groups(None, &keys); + + assert_eq!(groups.len(), 2); + assert!(groups["4key"].is_empty()); + assert!(groups["custom-old"].is_empty()); + } + #[test] fn tab_preset_duplicate_font_does_not_create_embedded_file() { let temp_dir = std::env::temp_dir().join(format!( @@ -1115,6 +1570,94 @@ mod tests { let _ = std::fs::remove_dir_all(temp_dir); } + #[test] + fn tab_preset_key_pair_merge_preserves_other_modes_and_latest_target_positions() { + let mut store = AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }; + store.key_positions.get_mut("4key").unwrap()[0].dx = 777.0; + let untouched_keys = store.keys["5key"].clone(); + let untouched_positions = store.key_positions["5key"].clone(); + + merge_tab_preset_key_pair(&mut store, "4key", vec!["Imported".to_string()], None); + + assert_eq!(store.keys["5key"], untouched_keys); + assert_eq!(store.key_positions["5key"], untouched_positions); + assert_eq!(store.key_positions["4key"][0].dx, 777.0); + assert_eq!(store.keys["4key"][0], "Imported"); + assert_eq!(store.keys["4key"].len(), store.key_positions["4key"].len()); + } + + #[test] + fn preset_import_alignment_repairs_each_mode_without_dropping_values() { + let mut keys = KeyMappings::from([ + ("keys-only".to_string(), vec!["A".to_string()]), + ("positions-long".to_string(), vec!["B".to_string()]), + ]); + let mut positions = KeyPositions::from([ + ( + "positions-only".to_string(), + vec![KeyPosition { + dx: 123.0, + ..KeyPosition::default() + }], + ), + ( + "positions-long".to_string(), + vec![KeyPosition::default(), KeyPosition::default()], + ), + ]); + + align_imported_key_collections(&mut keys, &mut positions); + + assert_eq!(keys["keys-only"], vec!["A".to_string()]); + assert_eq!(positions["keys-only"], vec![KeyPosition::default()]); + assert_eq!(keys["positions-only"], vec![String::new()]); + assert_eq!(positions["positions-only"][0].dx, 123.0); + assert_eq!(keys["positions-long"], vec!["B".to_string(), String::new()]); + assert_eq!( + keys["positions-long"].len(), + positions["positions-long"].len() + ); + } + + #[test] + fn tab_preset_font_restore_failure_keeps_existing_settings_unchanged() { + let existing = FontSettings { + custom_fonts: vec![CustomFont { + id: "existing-id".to_string(), + font_type: FontType::Local, + name: "ExistingFont".to_string(), + display_name: "Existing Font".to_string(), + enabled: true, + local_path: Some("/existing/font.ttf".to_string()), + css_content: None, + }], + }; + let before = existing.clone(); + let imported = FontSettings { + custom_fonts: vec![CustomFont { + id: "imported-id".to_string(), + font_type: FontType::Local, + name: "ImportedFont".to_string(), + display_name: "Imported Font".to_string(), + enabled: true, + local_path: None, + css_content: None, + }], + }; + + let result = prepare_tab_preset_fonts(&existing, imported, |fonts| { + fonts.custom_fonts[0].local_path = Some("/staged/font.ttf".to_string()); + Err(CommandError::msg("restore-failed")) + }); + + assert!(result.is_err()); + assert_eq!(existing, before); + } + #[test] fn legacy_percent_encoded_file_url_is_copied_on_import() { let temp_dir = std::env::temp_dir().join(format!( @@ -1140,6 +1683,7 @@ mod tests { let restored_path = Path::new(image_ref.as_deref().unwrap()); assert!(restored_path.starts_with(&images_dir)); assert_eq!(std::fs::read(restored_path).unwrap(), b"legacy-image"); + assert_eq!(std::fs::read(&source_path).unwrap(), b"legacy-image"); let _ = std::fs::remove_dir_all(temp_dir); } diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index c189a66e..f5368ca1 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -270,6 +270,238 @@ mod tests { assert!(preset.tab_css_overrides.is_none()); } + #[test] + fn tauri_era_preset_schema_transitions_remain_readable() { + let tauri_130: PresetFile = serde_json::from_value(json!({ + "keys": { "custom-130": ["Q"] }, + "keyPositions": { + "custom-130": [{ + "dx": 12.5, + "dy": -3.0, + "width": 64.0, + "count": 7, + "displayText": "Tauri 1.3" + }] + }, + "backgroundColor": "#112233", + "noteSettings": { "speed": 321, "trackHeight": 222 }, + "noteEffect": false, + "laboratoryEnabled": true, + "customTabs": [{ "id": "custom-130", "name": "Old tab" }], + "selectedKeyType": "custom-130", + "useCustomCSS": true, + "customCSS": { "path": "/tmp/old.css", "content": ".old {}" } + })) + .expect("1.3.0 preset must deserialize"); + + assert_eq!(tauri_130.keys.as_ref().unwrap()["custom-130"], ["Q"]); + let old_position = &tauri_130.key_positions.as_ref().unwrap()["custom-130"][0]; + assert_eq!(old_position.dx, 12.5); + assert_eq!(old_position.dy, -3.0); + assert_eq!(old_position.width, 64.0); + assert_eq!(old_position.count, 7); + assert_eq!(old_position.display_text.as_deref(), Some("Tauri 1.3")); + assert_eq!(tauri_130.background_color.as_deref(), Some("#112233")); + assert_eq!(tauri_130.note_settings.as_ref().unwrap().speed, 321); + assert_eq!(tauri_130.note_settings.as_ref().unwrap().track_height, 222); + assert_eq!(tauri_130.note_effect, Some(false)); + assert_eq!(tauri_130.laboratory_enabled, Some(true)); + assert_eq!(tauri_130.custom_tabs.as_ref().unwrap()[0].id, "custom-130"); + assert_eq!(tauri_130.selected_key_type.as_deref(), Some("custom-130")); + assert_eq!(tauri_130.use_custom_css, Some(true)); + assert_eq!(tauri_130.custom_css.as_ref().unwrap().content, ".old {}"); + + let transition_fixtures = [ + ( + "1.4.0", + json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": {}, + "useCustomJS": true, + "customJS": { "path": null, "content": "void 0", "plugins": [] } + }), + ), + ( + "1.5.1", + json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": {}, + "statPositions": {}, + "fontSettings": { "customFonts": [] }, + "embeddedLocalFonts": [], + "embeddedLocalImages": [] + }), + ), + ( + "1.6.0", + json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": {}, + "statPositions": {}, + "graphPositions": {}, + "tabNoteOverrides": {}, + "embeddedLocalSounds": [] + }), + ), + ]; + + for (version, fixture) in transition_fixtures { + let preset: PresetFile = serde_json::from_value(fixture) + .unwrap_or_else(|error| panic!("{version} preset must deserialize: {error}")); + assert_eq!(preset.keys.as_ref().unwrap()["4key"], vec!["Q"]); + } + + let tauri_161: PresetFile = serde_json::from_value(json!({ + "keys": { "custom-161": ["W"] }, + "keyPositions": { + "custom-161": [{ + "dx": 10.0, + "dy": 20.0, + "width": 70.0, + "height": 80.0, + "count": 9, + "groupId": "historic-group", + "activeImage": "dmnote-local-image://image-1", + "soundPath": "dmnote-local-sound://sound-1" + }] + }, + "statPositions": { + "custom-161": [{ + "statType": "total", + "dx": 1.0, + "dy": 2.0, + "width": 100.0, + "count": 0 + }] + }, + "graphPositions": { + "custom-161": [{ + "statType": "kpsAvg", + "graphType": "line", + "graphSpeed": 4, + "graphColor": "#abcdef", + "dx": 3.0, + "dy": 4.0, + "width": 120.0, + "count": 0 + }] + }, + "knobPositions": { + "custom-161": [{ + "axisId": "HIDA:1:2:3:4", + "sensitivity": 1.5, + "reverse": true, + "dx": 5.0, + "dy": 6.0, + "width": 90.0, + "count": 0 + }] + }, + "useCustomJS": true, + "customJS": { + "path": null, + "content": "globalThis.legacy = true", + "plugins": [{ + "id": "plugin-161", + "name": "Legacy plugin", + "path": null, + "content": "globalThis.plugin161 = true", + "enabled": true + }] + }, + "fontSettings": { + "customFonts": [{ + "id": "font-1", + "type": "local", + "name": "LegacyFont", + "displayName": "Legacy Font", + "enabled": true, + "localPath": null, + "cssContent": null + }] + }, + "tabNoteOverrides": { + "custom-161": { "speed": 444, "reverse": true } + }, + "embeddedLocalFonts": [{ + "fontId": "font-1", + "extension": "ttf", + "dataBase64": "AA==" + }], + "embeddedLocalImages": [{ + "imageId": "image-1", + "extension": "png", + "dataBase64": "AA==" + }], + "embeddedLocalSounds": [{ + "soundId": "sound-1", + "extension": "wav", + "dataBase64": "AA==" + }] + })) + .expect("1.6.1 preset must deserialize"); + + assert_eq!(tauri_161.keys.as_ref().unwrap()["custom-161"], ["W"]); + let position_161 = &tauri_161.key_positions.as_ref().unwrap()["custom-161"][0]; + assert_eq!(position_161.group_id.as_deref(), Some("historic-group")); + assert_eq!( + position_161.active_image.as_deref(), + Some("dmnote-local-image://image-1") + ); + assert_eq!( + position_161.sound_path.as_deref(), + Some("dmnote-local-sound://sound-1") + ); + assert_eq!( + tauri_161.stat_positions.as_ref().unwrap()["custom-161"].len(), + 1 + ); + assert_eq!( + tauri_161.graph_positions.as_ref().unwrap()["custom-161"][0].graph_speed, + 4 + ); + assert_eq!( + tauri_161.knob_positions.as_ref().unwrap()["custom-161"][0].axis_id, + "HIDA:1:2:3:4" + ); + assert_eq!( + tauri_161.custom_js.as_ref().unwrap().plugins[0].id, + "plugin-161" + ); + assert_eq!( + tauri_161.font_settings.as_ref().unwrap().custom_fonts[0].id, + "font-1" + ); + assert_eq!( + tauri_161.tab_note_overrides.as_ref().unwrap()["custom-161"].speed, + Some(444) + ); + assert_eq!( + tauri_161.embedded_local_fonts.as_ref().unwrap()[0].font_id, + "font-1" + ); + assert_eq!( + tauri_161.embedded_local_images.as_ref().unwrap()[0].image_id, + "image-1" + ); + assert_eq!( + tauri_161.embedded_local_sounds.as_ref().unwrap()[0].sound_id, + "sound-1" + ); + } + + #[test] + fn internal_editor_revision_never_becomes_part_of_a_preset() { + let preset: PresetFile = serde_json::from_value(json!({ + "keys": { "4key": ["Q"] }, + "editorRevision": 42 + })) + .unwrap(); + let serialized = serde_json::to_value(preset).unwrap(); + + assert!(serialized.get("editorRevision").is_none()); + } + #[cfg(not(target_os = "windows"))] #[test] fn file_url_image_source_preserves_its_absolute_path() { diff --git a/src-tauri/src/commands/preset/save.rs b/src-tauri/src/commands/preset/save.rs index 6e274be8..0c07294a 100644 --- a/src-tauri/src/commands/preset/save.rs +++ b/src-tauri/src/commands/preset/save.rs @@ -79,14 +79,8 @@ pub fn preset_save(state: State<'_, AppState>) -> CmdResult) -> CmdResult, + #[serde(skip_serializing_if = "Option::is_none")] + pub validation_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub field: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorCommitError { + pub error_code: EditorCommitErrorCode, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, + pub retryable: bool, +} + +impl EditorCommitError { + pub fn revision_conflict(current_revision: u64) -> Self { + Self { + error_code: EditorCommitErrorCode::RevisionConflict, + message: "editor revision conflict".to_string(), + details: Some(EditorCommitErrorDetails { + current_revision: Some(current_revision), + ..EditorCommitErrorDetails::default() + }), + retryable: true, + } + } + + pub fn validation(validation_code: impl Into, message: impl Into) -> Self { + Self { + error_code: EditorCommitErrorCode::ValidationFailed, + message: message.into(), + details: Some(EditorCommitErrorDetails { + validation_code: Some(validation_code.into()), + ..EditorCommitErrorDetails::default() + }), + retryable: false, + } + } + + pub fn paired_update_required(field: impl Into) -> Self { + let field = field.into(); + Self { + error_code: EditorCommitErrorCode::PairedUpdateRequired, + message: format!("{field} must be updated with its paired field"), + details: Some(EditorCommitErrorDetails { + field: Some(field), + ..EditorCommitErrorDetails::default() + }), + retryable: false, + } + } + + pub fn mutation_id_reused() -> Self { + Self { + error_code: EditorCommitErrorCode::MutationIdReused, + message: "mutationId was reused with a different request".to_string(), + details: None, + retryable: false, + } + } + + pub fn io(message: impl Into) -> Self { + Self { + error_code: EditorCommitErrorCode::IoError, + message: message.into(), + details: None, + retryable: true, + } + } +} + +impl std::fmt::Display for EditorCommitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}", self.message) + } +} + +impl std::error::Error for EditorCommitError {} + /// Tauri 커맨드 통합 에러 타입 #[derive(Debug, thiserror::Error)] pub enum CommandError { @@ -17,6 +113,9 @@ pub enum CommandError { #[error(transparent)] Tauri(#[from] tauri::Error), + + #[error(transparent)] + Editor(#[from] EditorCommitError), } impl CommandError { @@ -33,7 +132,10 @@ impl Serialize for CommandError { where S: serde::Serializer, { - serializer.serialize_str(&self.to_string()) + match self { + Self::Editor(error) => error.serialize(serializer), + _ => serializer.serialize_str(&self.to_string()), + } } } diff --git a/src-tauri/src/keyboard/manager.rs b/src-tauri/src/keyboard/manager.rs index d65d518c..c0d6593a 100644 --- a/src-tauri/src/keyboard/manager.rs +++ b/src-tauri/src/keyboard/manager.rs @@ -26,18 +26,6 @@ impl KeyboardManager { manager } - pub fn update_mappings(&self, mappings: KeyMappings) { - let mut mappings_guard = self.mappings.write(); - let current_mode = self.current_mode.read(); - let next_valid_keys = Self::valid_keys_for_mode(&mappings, ¤t_mode); - let mut valid_keys = self.valid_keys.write(); - let mut active_keys = self.active_keys.write(); - - Self::retain_active_keys(&mut active_keys, ¤t_mode, &next_valid_keys); - *mappings_guard = mappings; - *valid_keys = next_valid_keys; - } - pub fn set_mode(&self, mode: impl Into) -> bool { let mode = mode.into(); let mappings = self.mappings.read(); @@ -265,14 +253,17 @@ mod tests { ); assert!(manager.register_key_down("custom", "KeyD")); - manager.update_mappings(HashMap::from([("custom".to_string(), Vec::new())])); + manager.update_mappings_and_set_mode( + HashMap::from([("custom".to_string(), Vec::new())]), + "custom".to_string(), + ); assert!(manager.pressed_keys().is_empty()); assert!(!manager.register_key_up("custom", "KeyD")); } #[test] - fn update_mappings_prunes_removed_active_keys() { + fn update_mappings_and_mode_prunes_removed_active_keys() { let manager = KeyboardManager::new( HashMap::from([( "4key".to_string(), @@ -283,10 +274,13 @@ mod tests { assert!(manager.register_key_down("4key", "KeyD")); assert!(manager.register_key_down("4key", "KeyF")); - manager.update_mappings(HashMap::from([( + manager.update_mappings_and_set_mode( + HashMap::from([( + "4key".to_string(), + vec!["KeyF".to_string(), "KeyJ".to_string()], + )]), "4key".to_string(), - vec!["KeyF".to_string(), "KeyJ".to_string()], - )])); + ); assert_eq!(manager.pressed_keys(), vec!["KeyF"]); assert!(!manager.register_key_up("4key", "KeyD")); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 88d4d679..07c404ce 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -162,6 +162,8 @@ fn main() { commands::app::system::app_open_external, commands::app::system::app_restart, commands::app::system::app_quit, + commands::app::system::app_quit_after_editor_flush, + commands::app::system::app_cancel_editor_flush, commands::app::system::window_open_devtools_all, commands::app::system::get_cursor_settings, // OBS 모드 @@ -195,6 +197,8 @@ fn main() { commands::editor::note_tab::note_tab_get, commands::editor::note_tab::note_tab_set, commands::editor::note_tab::note_tab_clear, + commands::editor::state::editor_get, + commands::editor::state::editor_commit, // 키 입력/설정 commands::keys::keys::keys_get, commands::keys::keys::keys_get_counters, @@ -216,6 +220,7 @@ fn main() { commands::keys::keys::custom_tabs_delete, commands::keys::keys::custom_tabs_select, commands::keys::keys::custom_tabs_restore, + commands::keys::keys::editor_history_restore, commands::keys::keys::layer_groups_get, commands::keys::keys::layer_groups_update, commands::keys::key_sound::key_sound_get_status, @@ -277,6 +282,18 @@ fn main() { .expect("error while building tauri application"); app.run(|app_handle, event| match event { + tauri::RunEvent::ExitRequested { code, api, .. } if code.is_none() => { + api.prevent_exit(); + if let Some(state) = app_handle.try_state::() { + if let Some(main) = app_handle.get_webview_window("main") { + let _ = main.hide(); + } + if let Some(overlay) = app_handle.get_webview_window("overlay") { + let _ = overlay.hide(); + } + state.request_frontend_shutdown(app_handle.clone()); + } + } tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => { if let Some(state) = app_handle.try_state::() { state.arm_shutdown_watchdog("RunEvent state shutdown"); diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs new file mode 100644 index 00000000..66136a23 --- /dev/null +++ b/src-tauri/src/models/editor.rs @@ -0,0 +1,265 @@ +use serde::{Deserialize, Serialize}; + +use super::{ + AppStoreData, CustomTab, GraphPositions, KeyCounters, KeyMappings, KeyPositions, KnobPositions, + LayerGroups, SettingsPatchInput, StatPositions, TabNoteOverrides, +}; + +pub const EDITOR_SCHEMA_VERSION: u16 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorHistoryRestoreRequest { + pub base_revision: u64, + pub document: EditorDocumentV1, + pub custom_tabs: Vec, + pub selected_key_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_counters: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings_patch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_note_overrides: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditorField { + Keys, + KeyPositions, + StatPositions, + GraphPositions, + KnobPositions, + LayerGroups, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorDocumentV1 { + pub schema_version: u16, + pub keys: KeyMappings, + pub key_positions: KeyPositions, + pub stat_positions: StatPositions, + pub graph_positions: GraphPositions, + pub knob_positions: KnobPositions, + pub layer_groups: LayerGroups, +} + +impl EditorDocumentV1 { + pub fn from_store(store: &AppStoreData) -> Self { + Self { + schema_version: EDITOR_SCHEMA_VERSION, + keys: store.keys.clone(), + key_positions: store.key_positions.clone(), + stat_positions: store.stat_positions.clone(), + graph_positions: store.graph_positions.clone(), + knob_positions: store.knob_positions.clone(), + layer_groups: store.layer_groups.clone(), + } + } + + pub fn apply_to_store(&self, store: &mut AppStoreData) { + store.keys = self.keys.clone(); + store.key_positions = self.key_positions.clone(); + store.stat_positions = self.stat_positions.clone(); + store.graph_positions = self.graph_positions.clone(); + store.knob_positions = self.knob_positions.clone(); + store.layer_groups = self.layer_groups.clone(); + } + + pub fn apply_patch(&mut self, patch: &EditorPatchV1) { + if let Some(value) = patch.keys.as_ref() { + self.keys = value.clone(); + } + if let Some(value) = patch.key_positions.as_ref() { + self.key_positions = value.clone(); + } + if let Some(value) = patch.stat_positions.as_ref() { + self.stat_positions = value.clone(); + } + if let Some(value) = patch.graph_positions.as_ref() { + self.graph_positions = value.clone(); + } + if let Some(value) = patch.knob_positions.as_ref() { + self.knob_positions = value.clone(); + } + if let Some(value) = patch.layer_groups.as_ref() { + self.layer_groups = value.clone(); + } + } + + pub fn changed_fields(&self, next: &Self) -> Vec { + let mut fields = Vec::new(); + if self.keys != next.keys { + fields.push(EditorField::Keys); + } + if self.key_positions != next.key_positions { + fields.push(EditorField::KeyPositions); + } + if self.stat_positions != next.stat_positions { + fields.push(EditorField::StatPositions); + } + if self.graph_positions != next.graph_positions { + fields.push(EditorField::GraphPositions); + } + if self.knob_positions != next.knob_positions { + fields.push(EditorField::KnobPositions); + } + if self.layer_groups != next.layer_groups { + fields.push(EditorField::LayerGroups); + } + fields + } + + pub fn patch_for_fields(&self, fields: &[EditorField]) -> EditorPatchV1 { + let mut patch = EditorPatchV1::default(); + for field in fields { + match field { + EditorField::Keys => patch.keys = Some(self.keys.clone()), + EditorField::KeyPositions => { + patch.key_positions = Some(self.key_positions.clone()); + } + EditorField::StatPositions => { + patch.stat_positions = Some(self.stat_positions.clone()); + } + EditorField::GraphPositions => { + patch.graph_positions = Some(self.graph_positions.clone()); + } + EditorField::KnobPositions => { + patch.knob_positions = Some(self.knob_positions.clone()); + } + EditorField::LayerGroups => { + patch.layer_groups = Some(self.layer_groups.clone()); + } + } + } + patch + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorPatchV1 { + pub schema_version: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keys: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_positions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stat_positions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph_positions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub knob_positions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_groups: Option, +} + +impl Default for EditorPatchV1 { + fn default() -> Self { + Self { + schema_version: EDITOR_SCHEMA_VERSION, + keys: None, + key_positions: None, + stat_positions: None, + graph_positions: None, + knob_positions: None, + layer_groups: None, + } + } +} + +impl EditorPatchV1 { + pub fn includes(&self, field: EditorField) -> bool { + match field { + EditorField::Keys => self.keys.is_some(), + EditorField::KeyPositions => self.key_positions.is_some(), + EditorField::StatPositions => self.stat_positions.is_some(), + EditorField::GraphPositions => self.graph_positions.is_some(), + EditorField::KnobPositions => self.knob_positions.is_some(), + EditorField::LayerGroups => self.layer_groups.is_some(), + } + } + + pub fn included_fields(&self) -> Vec { + [ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ] + .into_iter() + .filter(|field| self.includes(*field)) + .collect() + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorCommitRequest { + pub base_revision: u64, + pub mutation_id: String, + pub changes: EditorPatchV1, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorCommitResult { + pub revision: u64, + pub changed_fields: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorGetResult { + pub revision: u64, + pub document: EditorDocumentV1, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorCommittedV1 { + pub schema_version: u16, + pub revision: u64, + pub mutation_id: String, + pub origin: String, + pub changed_fields: Vec, + pub patch: EditorPatchV1, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EditorCommitOrigin { + StrictEditorCommit, + LegacyAdapter(String), + // 이벤트 없는 부팅 복구용 origin 계약 + #[allow(dead_code)] + LoadRecovery, +} + +impl EditorCommitOrigin { + pub fn event_name(&self) -> Option { + match self { + Self::StrictEditorCommit => Some("editorCommit".to_string()), + Self::LegacyAdapter(command) => Some(format!("legacy:{command}")), + Self::LoadRecovery => None, + } + } +} + +#[derive(Debug, Clone)] +pub struct CommittedEditorChange { + pub result: EditorCommitResult, + pub event: Option, + pub replayed: bool, + pub document: EditorDocumentV1, + pub selected_key_type: String, + pub key_counters: KeyCounters, +} + +#[derive(Debug, Clone)] +pub struct EditorTransactionResult { + pub value: T, + pub change: CommittedEditorChange, +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index e93592a4..1a650de6 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,5 +1,8 @@ +pub mod editor; pub mod obs; +pub use editor::*; + use serde::de::Error as DeError; use serde::ser::SerializeMap; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -1352,6 +1355,8 @@ pub struct AppStoreData { #[serde(default)] pub main_window_hidden: bool, #[serde(default)] + pub editor_revision: u64, + #[serde(default)] pub keys: KeyMappings, #[serde(default)] pub key_positions: KeyPositions, @@ -1440,6 +1445,7 @@ impl Default for AppStoreData { tray_enabled: false, auto_update_enabled: default_auto_update_enabled(), main_window_hidden: false, + editor_revision: 0, keys: KeyMappings::new(), key_positions: KeyPositions::new(), stat_positions: StatPositions::new(), @@ -1693,6 +1699,7 @@ pub struct BootstrapPayload { pub layer_groups: LayerGroups, pub tab_note_overrides: TabNoteOverrides, pub tab_css_overrides: TabCssOverrides, + pub editor_revision: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 3f87adbf..2c928245 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -37,6 +37,7 @@ const MAX_HTTP_HEADER_SIZE: usize = 16 * 1024; const ALLOWED_WS_COMMANDS: &[&str] = &[ "app_bootstrap", "settings_get", + "editor_get", "layer_groups_get", "note_tab_get_all", "note_tab_get", @@ -367,6 +368,7 @@ impl ObsBridgeService { // OBS 오버레이가 수신하는 이벤트 목록 let forwarded_events = [ "settings:changed", + "editor:committed", "keys:state", "keys:changed", "keys:counters", @@ -1371,9 +1373,11 @@ mod tests { #[test] fn websocket_allowlist_uses_exact_matching() { - assert_eq!(ALLOWED_WS_COMMANDS.len(), 30); + assert_eq!(ALLOWED_WS_COMMANDS.len(), 31); assert!(is_allowed_command("app_bootstrap")); + assert!(is_allowed_command("editor_get")); assert!(is_allowed_command("plugin_storage_clear_by_prefix")); + assert!(!is_allowed_command("editor_commit")); assert!(!is_allowed_command("settings_update")); assert!(!is_allowed_command("keys_update")); assert!(!is_allowed_command("keys_update_with_positions")); diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 24ddf2ed..b8434a61 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -1,5 +1,6 @@ use std::time::Instant; use std::{ + collections::HashSet, io::{BufRead, BufReader}, process::{Child, ChildStdin, Command, Stdio}, sync::{ @@ -13,6 +14,7 @@ use std::{ use anyhow::{anyhow, Context, Result}; use log::{error, warn}; use parking_lot::{Mutex, RwLock}; +use serde::Serialize; use serde_json::json; use tauri::{ menu::{Menu, MenuItem}, @@ -32,10 +34,9 @@ use crate::{ }, keyboard::KeyboardManager, models::{ - overlay_resize_anchor_from_str, AppStoreData, BootstrapOverlayState, BootstrapPayload, - DefaultsPayload, KeyCounterSettings, KeyCounters, KeyMappings, - KeySoundOutputBackendPersist, OverlayBounds, OverlayResizeAnchor, SettingsDiff, - SettingsState, + overlay_resize_anchor_from_str, BootstrapOverlayState, BootstrapPayload, DefaultsPayload, + KeyCounterSettings, KeyCounters, KeyMappings, KeySoundOutputBackendPersist, OverlayBounds, + OverlayResizeAnchor, SettingsDiff, SettingsState, }, services::{css_watcher::CssWatcher, obs_bridge::ObsBridgeService, settings::SettingsService}, }; @@ -49,6 +50,7 @@ const DEFAULT_OVERLAY_HEIGHT: f64 = 320.0; const OVERLAY_MARGIN: f64 = 40.0; const OVERLAY_BOUNDS_DEBOUNCE_MS: u64 = 400; const OVERLAY_CREATION_LOCK_TIMEOUT: Duration = Duration::from_secs(10); +const EDITOR_FLUSH_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(5); const SHUTDOWN_WATCHDOG_EXIT_CODE: i32 = 1; @@ -57,6 +59,26 @@ struct ShutdownWatchdogState { stage: &'static str, } +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum FrontendLifecycleAction { + Quit, + Restart, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct EditorFlushRequest { + handshake_id: String, + action: FrontendLifecycleAction, +} + +struct EditorFlushHandshake { + id: String, + action: FrontendLifecycleAction, + pending_windows: HashSet, +} + /// 카운터 write lock 내부 전용 이벤트 송신 경계 /// 동기 Rust listener에서 AppState 카운터 API 재진입 금지 pub(crate) trait KeyCounterEventEmitter { @@ -117,6 +139,7 @@ pub struct AppState { obs_previous_overlay_visible: Arc>>, shutdown_started: AtomicBool, shutdown_watchdog: Arc>, + editor_flush_handshake: Arc>>, } impl AppState { @@ -165,6 +188,7 @@ impl AppState { armed: false, stage: "shutdown initialization", })), + editor_flush_handshake: Arc::new(Mutex::new(None)), }) } @@ -257,8 +281,6 @@ impl AppState { )?; let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT_ID, quit_label, true, None::<&str>)?; let menu = Menu::with_items(app, &[&settings_item, &quit_item])?; - let overlay_force_close = self.overlay_force_close.clone(); - let mut tray_builder = TrayIconBuilder::with_id(TRAY_ICON_ID) .menu(&menu) .show_menu_on_left_click(false); @@ -290,11 +312,9 @@ impl AppState { } if event.id() == TRAY_MENU_QUIT_ID { - let app_for_shutdown = app_handle.clone(); - let overlay_force_close_for_shutdown = overlay_force_close.clone(); - thread::spawn(move || { - shutdown_application(app_for_shutdown, overlay_force_close_for_shutdown); - }); + app_handle + .state::() + .request_frontend_shutdown(app_handle.clone()); } }) .build(app)?; @@ -367,6 +387,7 @@ impl AppState { layer_groups: state.layer_groups.clone(), tab_note_overrides: state.tab_note_overrides.clone(), tab_css_overrides: state.tab_css_overrides.clone(), + editor_revision: state.editor_revision, } } @@ -606,9 +627,6 @@ impl AppState { if !visible { flush_deferred_overlay_bounds(&self.store, &self.overlay_bounds_generation)?; } - self.store.update(|state| { - state.overlay_visible = visible; - })?; if visible { // 오버레이를 열 때: 창이 없으면 생성하고 표시 @@ -629,6 +647,33 @@ impl AppState { } } + // 창 조작 성공 후에만 영속. 저장 실패면 창 조작을 보상해 전 계층을 이전 상태로 복원 + if let Err(persist_err) = self.store.update(|state| { + state.overlay_visible = visible; + }) { + let compensation = if visible { + app.get_webview_window(OVERLAY_LABEL) + .map_or(Ok(()), |window| hide_overlay_window(&window)) + } else { + match app.get_webview_window(OVERLAY_LABEL) { + Some(window) => { + let snapshot = self.store.snapshot(); + show_overlay_window(&window, snapshot.always_on_top) + } + None => Ok(()), + } + }; + if let Err(comp_err) = compensation { + // 보상 실패 — 실제 창 상태를 권위로 runtime과 이벤트를 동기화 + log::error!( + "[Overlay] 저장 실패 후 보상도 실패({comp_err}) — 창 상태({visible})를 권위로 동기화" + ); + *self.overlay_visible.write() = visible; + let _ = app.emit("overlay:visibility", &json!({ "visible": visible })); + } + return Err(persist_err); + } + *self.overlay_visible.write() = visible; app.emit("overlay:visibility", &json!({ "visible": visible }))?; Ok(()) @@ -704,10 +749,143 @@ impl AppState { self.shutdown_watchdog.lock().stage = stage; } - pub fn request_shutdown(&self, app_handle: AppHandle) { + pub fn request_frontend_shutdown(&self, app_handle: AppHandle) { + self.request_frontend_lifecycle(app_handle, FrontendLifecycleAction::Quit); + } + + pub fn request_frontend_restart(&self, app_handle: AppHandle) { + self.request_frontend_lifecycle(app_handle, FrontendLifecycleAction::Restart); + } + + pub fn acknowledge_frontend_lifecycle( + &self, + app_handle: AppHandle, + handshake_id: &str, + window_label: &str, + ) { + let action = { + let mut handshake = self.editor_flush_handshake.lock(); + let Some(active) = handshake.as_mut() else { + return; + }; + if active.id != handshake_id || !active.pending_windows.remove(window_label) { + return; + } + if active.pending_windows.is_empty() { + handshake.take().map(|completed| completed.action) + } else { + None + } + }; + + if let Some(action) = action { + execute_frontend_lifecycle(app_handle, action, self.overlay_force_close.clone()); + } + } + + pub fn cancel_frontend_lifecycle(&self, handshake_id: &str) { + let mut handshake = self.editor_flush_handshake.lock(); + if handshake + .as_ref() + .is_some_and(|active| active.id == handshake_id) + { + handshake.take(); + } + } + + fn request_frontend_lifecycle(&self, app_handle: AppHandle, action: FrontendLifecycleAction) { + let targets = ["main", OVERLAY_LABEL] + .into_iter() + .filter_map(|label| { + app_handle + .get_webview_window(label) + .map(|window| (label.to_string(), window)) + }) + .collect::>(); + + if targets.is_empty() { + execute_frontend_lifecycle(app_handle, action, self.overlay_force_close.clone()); + return; + } + + let handshake_id = uuid::Uuid::new_v4().to_string(); + { + let mut handshake = self.editor_flush_handshake.lock(); + if handshake.is_some() { + return; + } + *handshake = Some(EditorFlushHandshake { + id: handshake_id.clone(), + action, + pending_windows: targets.iter().map(|(label, _)| label.clone()).collect(), + }); + } + + let request = EditorFlushRequest { + handshake_id: handshake_id.clone(), + action, + }; + let mut failed_windows = Vec::new(); + for (label, window) in &targets { + if let Err(error) = window.emit("app:close-requested", &request) { + log::warn!("failed to request editor flush from {label}: {error}"); + failed_windows.push(label.clone()); + } + } + + if !failed_windows.is_empty() { + let mut handshake = self.editor_flush_handshake.lock(); + let Some(active) = handshake.as_ref() else { + return; + }; + if active.id != handshake_id { + return; + } + handshake.take(); + drop(handshake); + log::warn!( + "editor flush request failed for {:?}; lifecycle action canceled", + failed_windows + ); + if let Err(error) = self.show_main_window(&app_handle) { + log::warn!("failed to restore main window after canceled lifecycle: {error}"); + } + if *self.overlay_visible.read() { + if let Err(error) = self.set_overlay_visibility(&app_handle, true) { + log::warn!("failed to restore overlay after canceled lifecycle: {error}"); + } + } + return; + } + + let handshake = self.editor_flush_handshake.clone(); let overlay_force_close = self.overlay_force_close.clone(); thread::spawn(move || { - shutdown_application(app_handle, overlay_force_close); + thread::sleep(EDITOR_FLUSH_HANDSHAKE_TIMEOUT); + let main_is_visible = app_handle + .get_webview_window("main") + .and_then(|window| window.is_visible().ok()) + .unwrap_or(false); + let timed_out_action = { + let mut active = handshake.lock(); + if active + .as_ref() + .is_some_and(|pending| pending.id == handshake_id) + { + active.take().map(|pending| pending.action) + } else { + None + } + }; + + if let Some(action) = timed_out_action { + if main_is_visible { + log::warn!("editor flush handshake timed out; lifecycle action canceled"); + } else { + log::warn!("editor flush handshake timed out while windows were hidden"); + execute_frontend_lifecycle(app_handle, action, overlay_force_close); + } + } }); } @@ -1472,14 +1650,23 @@ impl AppState { log::warn!("failed to flush overlay bounds on close: {err}"); return; } + // 숨김 먼저, 저장은 성공 후 — set_overlay_visibility와 같은 전환 계약 + if let Err(err) = overlay_window.hide() { + log::error!("failed to hide overlay window on close: {err}"); + return; + } if let Err(err) = store.update(|state| { state.overlay_visible = false; }) { log::warn!("failed to persist overlay visibility on close: {err}"); - return; - } - if let Err(err) = overlay_window.hide() { - log::error!("failed to hide overlay window on close: {err}"); + // 보상: 숨김을 되돌려 전 계층을 이전 상태로 일치 + if let Err(show_err) = overlay_window.show() { + // 보상 실패 — 실제 창 상태(숨김)를 권위로 runtime과 이벤트만 동기화 + log::error!("failed to compensate overlay hide: {show_err}"); + *overlay_visible.write() = false; + let _ = app_handle + .emit("overlay:visibility", &json!({ "visible": false })); + } return; } *overlay_visible.write() = false; @@ -1760,41 +1947,23 @@ impl AppState { self.keyboard.clear_active_keys(); } - /// 카운터 스냅샷·store 커밋·runtime mirror·전체 emit의 단일 write lock 경계 - pub(crate) fn update_store_with_key_counter_mirror_and_emit( - &self, - emitter: &dyn KeyCounterEventEmitter, - updater: impl FnOnce(&mut AppStoreData), - ) -> Result { - self.with_key_counters_write_and_emit(emitter, |guard| { - let runtime_counters = guard.clone(); - let updated = self.store.update(move |store| { - store.key_counters = runtime_counters; - updater(store); - })?; - *guard = updated.key_counters.clone(); - Ok(updated) - }) - } - pub fn persist_key_counters(&self) -> Result { let snapshot = self.key_counters.read().clone(); self.store.set_key_counters(snapshot.clone())?; Ok(snapshot) } - pub fn sync_counters_with_keys(&self, keys: &KeyMappings) { - let mut guard = self.key_counters.write(); - Self::sync_counters_with_keys_impl(&mut guard, keys); - } - - pub(crate) fn sync_counters_with_keys_and_emit( + pub(crate) fn apply_committed_editor_key_runtime( &self, emitter: &dyn KeyCounterEventEmitter, keys: &KeyMappings, + selected_key_type: &str, + counters: &KeyCounters, ) -> Result<()> { + self.keyboard + .update_mappings_and_set_mode(keys.clone(), selected_key_type.to_string()); self.with_key_counters_write_and_emit(emitter, |guard| { - Self::sync_counters_with_keys_impl(guard, keys); + *guard = counters.clone(); Ok(()) }) } @@ -2017,8 +2186,6 @@ fn attach_main_window_close_handler( } let main_window = window.clone(); - let shutdown_started = Arc::new(AtomicBool::new(false)); - window.on_window_event(move |event| { if let WindowEvent::CloseRequested { api, .. } = event { if overlay_force_close.load(Ordering::SeqCst) { @@ -2040,12 +2207,6 @@ fn attach_main_window_close_handler( return; } - // 중복 종료 요청 방지 - if shutdown_started.swap(true, Ordering::SeqCst) { - api.prevent_close(); - return; - } - // 즉시 종료 효과를 위해 윈도우 먼저 숨김 // 실제 정리/프로세스 종료는 백그라운드 스레드에서 수행 api.prevent_close(); @@ -2058,15 +2219,29 @@ fn attach_main_window_close_handler( } } - let app_for_shutdown = app_handle.clone(); - let overlay_force_close_for_shutdown = overlay_force_close.clone(); - thread::spawn(move || { - shutdown_application(app_for_shutdown, overlay_force_close_for_shutdown); - }); + state.request_frontend_shutdown(app_handle.clone()); } }); } +fn execute_frontend_lifecycle( + app_handle: AppHandle, + action: FrontendLifecycleAction, + overlay_force_close: Arc, +) { + match action { + FrontendLifecycleAction::Quit => { + thread::spawn(move || shutdown_application(app_handle, overlay_force_close)); + } + FrontendLifecycleAction::Restart => { + let state = app_handle.state::(); + state.arm_shutdown_watchdog("app-restart"); + state.shutdown(); + app_handle.request_restart(); + } + } +} + fn tray_menu_labels(_language: &str) -> (&'static str, &'static str) { ("Settings", "Quit") } diff --git a/src-tauri/src/state/atomic_file.rs b/src-tauri/src/state/atomic_file.rs index b26b6dd8..3dec68f3 100644 --- a/src-tauri/src/state/atomic_file.rs +++ b/src-tauri/src/state/atomic_file.rs @@ -1,5 +1,5 @@ use std::{ - fs::{self, OpenOptions}, + fs::{self, File, OpenOptions}, io::Write, path::{Path, PathBuf}, }; @@ -14,10 +14,30 @@ pub(crate) struct PreparedAtomicReplace { } impl PreparedAtomicReplace { - pub(crate) fn commit(mut self) -> Result<()> { - fs::rename(&self.temp_path, &self.target_path) + pub(crate) fn commit(self) -> Result<()> { + self.commit_with(replace_file, sync_parent_directory) + } + + fn commit_with( + mut self, + replace: Replace, + sync_parent: SyncParent, + ) -> Result<()> + where + Replace: FnOnce(&Path, &Path) -> Result<()>, + SyncParent: FnOnce(&Path) -> Result<()>, + { + replace(&self.temp_path, &self.target_path) .with_context(|| format!("failed to replace file at {}", self.target_path.display()))?; self.committed = true; + + // rename 이후 실패를 API 실패로 돌리면 디스크와 메모리의 성공 판정이 갈림 + if let Err(error) = sync_parent(&self.target_path) { + log::warn!( + "failed to sync parent directory after replacing '{}': {error:#}", + self.target_path.display() + ); + } Ok(()) } } @@ -52,7 +72,7 @@ pub(crate) fn prepare_atomic_replace( .with_context(|| format!("failed to create temp file at {}", temp_path.display()))?; temp.write_all(content) .with_context(|| format!("failed to write temp file at {}", temp_path.display()))?; - temp.sync_all() + sync_file_contents(&temp) .with_context(|| format!("failed to sync temp file at {}", temp_path.display()))?; drop(temp); @@ -73,6 +93,139 @@ pub(crate) fn atomic_replace(path: &Path, content: &[u8], label: &str) -> Result prepare_atomic_replace(path, content, label)?.commit() } +#[cfg(not(target_os = "windows"))] +fn replace_file(source: &Path, target: &Path) -> Result<()> { + fs::rename(source, target).map_err(Into::into) +} + +#[cfg(target_os = "windows")] +fn replace_file(source: &Path, target: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::{ + core::PCWSTR, + Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }, + }; + + let source = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let target = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + unsafe { + MoveFileExW( + PCWSTR(source.as_ptr()), + PCWSTR(target.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } + .map_err(Into::into) +} + +#[cfg(target_os = "macos")] +fn sync_file_contents(file: &File) -> Result<()> { + use std::os::fd::AsRawFd; + + let result = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_FULLFSYNC) }; + if result == -1 { + let error = std::io::Error::last_os_error(); + if error + .raw_os_error() + .is_some_and(|code| code == libc::EINVAL || code == libc::ENOTSUP) + { + return file + .sync_all() + .context("F_FULLFSYNC is unsupported and fsync fallback failed"); + } + return Err(error).context("F_FULLFSYNC failed"); + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn sync_file_contents(file: &File) -> Result<()> { + file.sync_all().map_err(Into::into) +} + +#[cfg(unix)] +fn sync_parent_directory(target: &Path) -> Result<()> { + let parent = target + .parent() + .context("failed to resolve target parent directory")?; + File::open(parent) + .with_context(|| format!("failed to open parent directory at {}", parent.display()))? + .sync_all() + .with_context(|| format!("failed to sync parent directory at {}", parent.display())) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_target: &Path) -> Result<()> { + // Windows 교체는 MOVEFILE_WRITE_THROUGH에서 메타데이터 flush까지 대기 + Ok(()) +} + +#[cfg(test)] +mod commit_tests { + use super::prepare_atomic_replace; + + fn test_directory(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("dmnote-{label}-{}", uuid::Uuid::new_v4())) + } + + #[test] + fn replace_failure_keeps_old_target_and_removes_temp() { + let directory = test_directory("atomic-replace-failure"); + std::fs::create_dir_all(&directory).unwrap(); + let target = directory.join("store.json"); + std::fs::write(&target, b"old").unwrap(); + let prepared = prepare_atomic_replace(&target, b"new", "failure").unwrap(); + + let result = prepared.commit_with( + |_source, _target| anyhow::bail!("injected replace failure"), + |_target| panic!("parent sync must not run after replace failure"), + ); + + assert!(result.is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"old"); + assert!(!std::fs::read_dir(&directory).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".tmp"))); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn parent_sync_failure_keeps_committed_result_successful() { + let directory = test_directory("atomic-parent-sync-failure"); + std::fs::create_dir_all(&directory).unwrap(); + let target = directory.join("store.json"); + std::fs::write(&target, b"old").unwrap(); + let prepared = prepare_atomic_replace(&target, b"new", "sync-failure").unwrap(); + + let result = prepared.commit_with( + |source, target| std::fs::rename(source, target).map_err(Into::into), + |_target| anyhow::bail!("injected parent sync failure"), + ); + + assert!(result.is_ok()); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert!(!std::fs::read_dir(&directory).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".tmp"))); + std::fs::remove_dir_all(directory).unwrap(); + } +} + #[cfg(all(test, unix))] pub(crate) mod test_support { pub(crate) struct FileSizeLimit { @@ -115,3 +268,108 @@ pub(crate) mod test_support { } } } + +#[cfg(all(test, unix))] +mod tests { + use std::{ + path::Path, + process::{Child, Command}, + thread, + time::Duration, + }; + + use super::prepare_atomic_replace; + + const CHILD_BOUNDARY_ENV: &str = "DMNOTE_ATOMIC_KILL_BOUNDARY"; + const CHILD_DIRECTORY_ENV: &str = "DMNOTE_ATOMIC_KILL_DIRECTORY"; + const CHILD_TEST_NAME: &str = "state::atomic_file::tests::atomic_replace_process_kill_child"; + + fn wait_until_ready(child: &mut Child, marker: &Path) { + for _ in 0..500 { + if marker.exists() { + return; + } + if let Some(status) = child.try_wait().unwrap() { + panic!("atomic replace child exited before boundary: {status}"); + } + thread::sleep(Duration::from_millis(10)); + } + let _ = child.kill(); + let _ = child.wait(); + panic!("timed out waiting for atomic replace child boundary"); + } + + fn run_kill_boundary(boundary: &str, expected_revision: u64, expected_key: &str) { + let directory = std::env::temp_dir().join(format!( + "dmnote-atomic-process-kill-{boundary}-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&directory).unwrap(); + let target = directory.join("store.json"); + let marker = directory.join("ready"); + std::fs::write(&target, br#"{"editorRevision":4,"keys":{"4key":["old"]}}"#).unwrap(); + + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", CHILD_TEST_NAME, "--nocapture"]) + .env(CHILD_BOUNDARY_ENV, boundary) + .env(CHILD_DIRECTORY_ENV, &directory) + .spawn() + .unwrap(); + wait_until_ready(&mut child, &marker); + child.kill().unwrap(); + child.wait().unwrap(); + + let target_value: serde_json::Value = + serde_json::from_slice(&std::fs::read(&target).unwrap()).unwrap(); + assert_eq!(target_value["editorRevision"], expected_revision); + assert_eq!(target_value["keys"]["4key"][0], expected_key); + + if boundary == "before" { + let orphan_temps = std::fs::read_dir(&directory) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp")) + .count(); + assert_eq!(orphan_temps, 1); + } + + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn process_kill_leaves_complete_old_or_new_target_at_replace_boundary() { + run_kill_boundary("before", 4, "old"); + run_kill_boundary("after", 5, "new"); + } + + #[test] + fn atomic_replace_process_kill_child() { + let Ok(boundary) = std::env::var(CHILD_BOUNDARY_ENV) else { + return; + }; + let directory = std::path::PathBuf::from( + std::env::var_os(CHILD_DIRECTORY_ENV).expect("child directory"), + ); + let target = directory.join("store.json"); + let marker = directory.join("ready"); + let next = br#"{"editorRevision":5,"keys":{"4key":["new"]}}"#; + let prepared = prepare_atomic_replace(&target, next, boundary.as_str()).unwrap(); + + match boundary.as_str() { + "before" => { + std::fs::write(marker, b"prepared").unwrap(); + loop { + thread::sleep(Duration::from_secs(1)); + } + } + "after" => { + prepared.commit().unwrap(); + std::fs::write(marker, b"committed").unwrap(); + loop { + thread::sleep(Duration::from_secs(1)); + } + } + other => panic!("unknown child boundary {other}"), + } + } +} diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs new file mode 100644 index 00000000..c636eac2 --- /dev/null +++ b/src-tauri/src/state/editor.rs @@ -0,0 +1,1593 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; + +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{ + defaults::default_keys, + errors::EditorCommitError, + models::{ + AppStoreData, CustomTab, EditorCommitRequest, EditorDocumentV1, EditorField, + EditorHistoryRestoreRequest, KeyCounters, KeyMappings, KeyPosition, EDITOR_SCHEMA_VERSION, + }, +}; + +pub(crate) const MAX_SAFE_EDITOR_REVISION: u64 = 9_007_199_254_740_991; +pub(crate) const MUTATION_ACK_CAPACITY: usize = 32; +pub(crate) const MAX_CUSTOM_TABS: usize = 30; + +const MAX_MUTATION_ID_BYTES: usize = 64; +const MAX_MODE_ID_BYTES: usize = 128; +const MAX_MODES: usize = 64; +const MAX_ITEMS_PER_MODE: usize = 512; +const MAX_RENDER_ITEMS: usize = 4_096; +const MAX_LAYER_GROUPS: usize = 4_096; +const MAX_KEY_LABEL_BYTES: usize = 1_024; +const MAX_GROUP_ID_BYTES: usize = 256; +const MAX_GROUP_NAME_BYTES: usize = 1_024; +const MAX_ABS_COORDINATE: f64 = 32_768.0; +const MAX_DIMENSION: f64 = 32_768.0; +const REQUEST_WARNING_BYTES: usize = 1_024 * 1_024; +const MAX_REQUEST_BYTES: usize = 8 * 1_024 * 1_024; + +pub(crate) type RequestFingerprint = [u8; 32]; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FingerprintPayload<'a> { + base_revision: u64, + changes: &'a crate::models::EditorPatchV1, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct ValidationViolation { + identity: String, + code: &'static str, + message: String, +} + +impl ValidationViolation { + fn new(identity: impl Into, code: &'static str, message: impl Into) -> Self { + Self { + identity: identity.into(), + code, + message: message.into(), + } + } +} + +pub(crate) fn validate_request_envelope( + request: &EditorCommitRequest, +) -> Result<(), EditorCommitError> { + validate_revision(request.base_revision)?; + + if request.changes.schema_version != EDITOR_SCHEMA_VERSION { + return Err(EditorCommitError::validation( + "UNSUPPORTED_SCHEMA_VERSION", + format!( + "unsupported editor schema version {}", + request.changes.schema_version + ), + )); + } + + if request.mutation_id.len() > MAX_MUTATION_ID_BYTES + || Uuid::parse_str(&request.mutation_id).is_err() + { + return Err(EditorCommitError::validation( + "INVALID_MUTATION_ID", + "mutationId must be a UUID no longer than 64 bytes", + )); + } + + Ok(()) +} + +pub(crate) fn validate_revision(revision: u64) -> Result<(), EditorCommitError> { + if revision > MAX_SAFE_EDITOR_REVISION { + return Err(EditorCommitError::validation( + "REVISION_OUT_OF_RANGE", + "editor revision exceeds JavaScript's safe integer range", + )); + } + Ok(()) +} + +pub(crate) fn validate_history_restore_envelope( + request: &EditorHistoryRestoreRequest, +) -> Result<(), EditorCommitError> { + validate_revision(request.base_revision)?; + if request.document.schema_version != EDITOR_SCHEMA_VERSION { + return Err(EditorCommitError::validation( + "UNSUPPORTED_SCHEMA_VERSION", + format!( + "unsupported editor schema version {}", + request.document.schema_version + ), + )); + } + let compact_size = serde_json::to_vec(request) + .map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("failed to serialize history restore request: {error}"), + ) + })? + .len(); + if compact_size > MAX_REQUEST_BYTES { + return Err(EditorCommitError::validation( + "REQUEST_TOO_LARGE", + format!("history restore request exceeds the {MAX_REQUEST_BYTES} byte limit"), + )); + } + if compact_size >= REQUEST_WARNING_BYTES { + log::warn!("[Editor] Large history restore request: {compact_size} compact bytes"); + } + Ok(()) +} + +pub(crate) fn next_revision(current: u64) -> Result { + validate_revision(current)?; + current + .checked_add(1) + .filter(|revision| *revision <= MAX_SAFE_EDITOR_REVISION) + .ok_or_else(|| { + EditorCommitError::validation( + "REVISION_OUT_OF_RANGE", + "editor revision cannot be incremented safely", + ) + }) +} + +pub(crate) fn validate_history_restore_metadata( + document: &EditorDocumentV1, + custom_tabs: &[CustomTab], + selected_key_type: &str, +) -> Result<(), EditorCommitError> { + if custom_tabs.len() > MAX_CUSTOM_TABS { + return Err(EditorCommitError::validation( + "TOO_MANY_CUSTOM_TABS", + format!("custom tab count exceeds {MAX_CUSTOM_TABS}"), + )); + } + + let built_in_modes = default_keys(); + let mut ids = HashSet::with_capacity(custom_tabs.len()); + let mut names = HashSet::with_capacity(custom_tabs.len()); + for tab in custom_tabs { + let id = tab.id.trim(); + let name = tab.name.trim(); + if id.is_empty() || id.len() > MAX_MODE_ID_BYTES { + return Err(EditorCommitError::validation( + "INVALID_CUSTOM_TAB_ID", + "custom tab id is empty or too long", + )); + } + if name.is_empty() || name.len() > MAX_GROUP_NAME_BYTES { + return Err(EditorCommitError::validation( + "INVALID_CUSTOM_TAB_NAME", + "custom tab name is empty or too long", + )); + } + if built_in_modes.contains_key(id) || !ids.insert(id.to_string()) { + return Err(EditorCommitError::validation( + "DUPLICATE_CUSTOM_TAB_ID", + format!("custom tab id '{id}' is duplicated or reserved"), + )); + } + if !names.insert(name.to_string()) { + return Err(EditorCommitError::validation( + "DUPLICATE_CUSTOM_TAB_NAME", + format!("custom tab name '{name}' is duplicated"), + )); + } + if !document.keys.contains_key(id) || !document.key_positions.contains_key(id) { + return Err(EditorCommitError::validation( + "CUSTOM_TAB_DOCUMENT_MISSING", + format!("custom tab '{id}' has no paired editor collections"), + )); + } + } + + for mode in document.keys.keys() { + if !built_in_modes.contains_key(mode) && !ids.contains(mode) { + return Err(EditorCommitError::validation( + "CUSTOM_TAB_METADATA_MISSING", + format!("editor mode '{mode}' has no custom tab metadata"), + )); + } + } + + let selected_is_valid = + built_in_modes.contains_key(selected_key_type) || ids.contains(selected_key_type); + if !selected_is_valid || !document.keys.contains_key(selected_key_type) { + return Err(EditorCommitError::validation( + "INVALID_SELECTED_MODE", + format!("selected mode '{selected_key_type}' is not restorable"), + )); + } + + Ok(()) +} + +pub(crate) fn request_fingerprint( + request: &EditorCommitRequest, +) -> Result { + let value = serde_json::to_value(FingerprintPayload { + base_revision: request.base_revision, + changes: &request.changes, + }) + .map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("failed to serialize editor request: {error}"), + ) + })?; + + let mut canonical = Vec::new(); + write_canonical_json(&value, &mut canonical).map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("failed to canonicalize editor request: {error}"), + ) + })?; + + Ok(Sha256::digest(canonical).into()) +} + +pub(crate) fn request_payload_size( + request: &EditorCommitRequest, +) -> Result { + let compact_size = serde_json::to_vec(request) + .map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("failed to serialize editor request: {error}"), + ) + })? + .len(); + if compact_size > MAX_REQUEST_BYTES { + return Err(EditorCommitError::validation( + "REQUEST_TOO_LARGE", + format!("editor request exceeds the {MAX_REQUEST_BYTES} byte limit"), + )); + } + if compact_size >= REQUEST_WARNING_BYTES { + log::warn!("[Editor] Large editor request: {compact_size} compact bytes"); + } + Ok(compact_size) +} + +fn write_canonical_json(value: &Value, output: &mut Vec) -> serde_json::Result<()> { + match value { + Value::Null => output.extend_from_slice(b"null"), + Value::Bool(value) => output.extend_from_slice(if *value { b"true" } else { b"false" }), + Value::Number(value) => output.extend_from_slice(value.to_string().as_bytes()), + Value::String(value) => output.extend_from_slice(serde_json::to_string(value)?.as_bytes()), + Value::Array(values) => { + output.push(b'['); + for (index, value) in values.iter().enumerate() { + if index > 0 { + output.push(b','); + } + write_canonical_json(value, output)?; + } + output.push(b']'); + } + Value::Object(values) => { + output.push(b'{'); + let mut entries = values.iter().collect::>(); + entries.sort_unstable_by_key(|(key, _)| *key); + for (index, (key, value)) in entries.into_iter().enumerate() { + if index > 0 { + output.push(b','); + } + output.extend_from_slice(serde_json::to_string(key)?.as_bytes()); + output.push(b':'); + write_canonical_json(value, output)?; + } + output.push(b'}'); + } + } + Ok(()) +} + +pub(crate) fn validate_paired_update( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, + keys_touched: bool, + key_positions_touched: bool, +) -> Result<(), EditorCommitError> { + if keys_touched + && !key_positions_touched + && collection_shape(¤t.keys) != collection_shape(&candidate.keys) + { + return Err(EditorCommitError::paired_update_required("keyPositions")); + } + + if key_positions_touched + && !keys_touched + && collection_shape(¤t.key_positions) != collection_shape(&candidate.key_positions) + { + return Err(EditorCommitError::paired_update_required("keys")); + } + + Ok(()) +} + +fn collection_shape(collection: &HashMap>) -> Vec<(String, usize)> { + let mut shape = collection + .iter() + .map(|(mode, values)| (mode.clone(), values.len())) + .collect::>(); + shape.sort_unstable(); + shape +} + +/// 기존 store에 있던 손실 없는 비정상 데이터는 유지하되 새 비정상 상태는 만들지 않음 +pub(crate) fn validate_document_transition( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, + current_store: &AppStoreData, + candidate_store: &AppStoreData, +) -> Result<(), EditorCommitError> { + if candidate.schema_version != EDITOR_SCHEMA_VERSION { + return Err(EditorCommitError::validation( + "UNSUPPORTED_SCHEMA_VERSION", + format!( + "unsupported editor schema version {}", + candidate.schema_version + ), + )); + } + + let current_violations = collect_violations(current, &allowed_modes(current_store)); + let candidate_violations = collect_violations(candidate, &allowed_modes(candidate_store)); + validate_metric_limits(current, candidate)?; + + if let Some(violation) = candidate_violations.iter().find(|violation| { + is_unconditional_structural_violation(violation.code) + || !current_violations.contains(*violation) + }) { + return Err(EditorCommitError::validation( + violation.code, + violation.message.clone(), + )); + } + + Ok(()) +} + +fn is_unconditional_structural_violation(code: &str) -> bool { + matches!( + code, + "KEY_POSITION_MODE_MISMATCH" + | "KEY_POSITION_LENGTH_MISMATCH" + | "DUPLICATE_GROUP_ID" + | "UNKNOWN_GROUP_ID" + ) +} + +fn allowed_modes(store: &AppStoreData) -> HashSet { + default_keys() + .keys() + .cloned() + .chain(store.custom_tabs.iter().map(|tab| tab.id.clone())) + .collect() +} + +fn collect_violations( + document: &EditorDocumentV1, + allowed_modes: &HashSet, +) -> BTreeSet { + let mut violations = BTreeSet::new(); + let all_modes = document + .keys + .keys() + .chain(document.key_positions.keys()) + .chain(document.stat_positions.keys()) + .chain(document.graph_positions.keys()) + .chain(document.knob_positions.keys()) + .chain(document.layer_groups.keys()) + .cloned() + .collect::>(); + + for mode in &all_modes { + if mode.is_empty() { + violations.insert(ValidationViolation::new( + format!("invalid-mode-id:{mode:?}"), + "INVALID_MODE_ID", + "mode id is empty", + )); + } + } + + collect_collection_violations("keys", &document.keys, allowed_modes, &mut violations); + collect_collection_violations( + "keyPositions", + &document.key_positions, + allowed_modes, + &mut violations, + ); + collect_collection_violations( + "statPositions", + &document.stat_positions, + allowed_modes, + &mut violations, + ); + collect_collection_violations( + "graphPositions", + &document.graph_positions, + allowed_modes, + &mut violations, + ); + collect_collection_violations( + "knobPositions", + &document.knob_positions, + allowed_modes, + &mut violations, + ); + collect_collection_violations( + "layerGroups", + &document.layer_groups, + allowed_modes, + &mut violations, + ); + + for mode in document.keys.keys().chain(document.key_positions.keys()) { + let keys = document.keys.get(mode); + let positions = document.key_positions.get(mode); + if keys.is_some() != positions.is_some() { + violations.insert(ValidationViolation::new( + format!( + "paired-mode:{mode}:{}:{}", + keys.is_some(), + positions.is_some() + ), + "KEY_POSITION_MODE_MISMATCH", + format!("keys and keyPositions must contain the same mode '{mode}'"), + )); + } + + let key_count = keys.map_or(0, Vec::len); + let position_count = positions.map_or(0, Vec::len); + if key_count != position_count { + violations.insert(ValidationViolation::new( + format!("paired-length:{mode}:{key_count}:{position_count}"), + "KEY_POSITION_LENGTH_MISMATCH", + format!("keys and keyPositions for mode '{mode}' have different lengths"), + )); + } + } + + for (mode, positions) in &document.knob_positions { + for (index, position) in positions.iter().enumerate() { + if !position.sensitivity.is_finite() { + violations.insert(ValidationViolation::new( + format!( + "knob-sensitivity:{mode}:{index}:{}", + position.sensitivity.to_bits() + ), + "INVALID_NUMBER", + format!("knob sensitivity at {mode}[{index}] is invalid"), + )); + } + } + } + + let group_ids = collect_group_violations(document, &mut violations); + collect_group_reference_violations(document, &group_ids, &mut violations); + violations +} + +fn collect_collection_violations( + field: &'static str, + collection: &HashMap>, + allowed_modes: &HashSet, + violations: &mut BTreeSet, +) { + for (mode, values) in collection { + if !allowed_modes.contains(mode) { + violations.insert(ValidationViolation::new( + format!("unknown-mode:{field}:{mode}"), + "UNKNOWN_MODE", + format!("{field} contains unknown mode '{mode}'"), + )); + } + let _ = values; + } +} + +fn validate_metric_limits( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, +) -> Result<(), EditorCommitError> { + validate_count_limit( + "TOO_MANY_MODES", + "editor mode count", + editor_modes(current).len(), + editor_modes(candidate).len(), + MAX_MODES, + )?; + + validate_collection_limits("keys", ¤t.keys, &candidate.keys)?; + validate_collection_limits( + "keyPositions", + ¤t.key_positions, + &candidate.key_positions, + )?; + validate_collection_limits( + "statPositions", + ¤t.stat_positions, + &candidate.stat_positions, + )?; + validate_collection_limits( + "graphPositions", + ¤t.graph_positions, + &candidate.graph_positions, + )?; + validate_collection_limits( + "knobPositions", + ¤t.knob_positions, + &candidate.knob_positions, + )?; + validate_collection_limits( + "layerGroups", + ¤t.layer_groups, + &candidate.layer_groups, + )?; + + validate_count_limit( + "TOO_MANY_RENDER_ITEMS", + "render item count", + render_item_count(current), + render_item_count(candidate), + MAX_RENDER_ITEMS, + )?; + validate_count_limit( + "TOO_MANY_LAYER_GROUPS", + "layer group count", + current.layer_groups.values().map(Vec::len).sum(), + candidate.layer_groups.values().map(Vec::len).sum(), + MAX_LAYER_GROUPS, + )?; + + for mode in editor_modes(candidate) { + let current_len = editor_modes(current) + .get(&mode) + .map_or(0, |current_mode| current_mode.len()); + validate_count_limit( + "MODE_ID_TOO_LONG", + &format!("mode id '{mode}' byte length"), + current_len, + mode.len(), + MAX_MODE_ID_BYTES, + )?; + } + + for (mode, keys) in &candidate.keys { + for (index, key) in keys.iter().enumerate() { + let current_len = current + .keys + .get(mode) + .and_then(|values| values.get(index)) + .map_or(0, String::len); + validate_count_limit( + "KEY_LABEL_TOO_LONG", + &format!("key label {mode}[{index}] byte length"), + current_len, + key.len(), + MAX_KEY_LABEL_BYTES, + )?; + } + } + + for (mode, groups) in &candidate.layer_groups { + for (index, group) in groups.iter().enumerate() { + let current_group = current + .layer_groups + .get(mode) + .and_then(|values| values.get(index)); + validate_count_limit( + "GROUP_ID_TOO_LONG", + &format!("layer group id {mode}[{index}] byte length"), + current_group.map_or(0, |group| group.id.len()), + group.id.len(), + MAX_GROUP_ID_BYTES, + )?; + validate_count_limit( + "GROUP_NAME_TOO_LONG", + &format!("layer group name {mode}[{index}] byte length"), + current_group.map_or(0, |group| group.name.len()), + group.name.len(), + MAX_GROUP_NAME_BYTES, + )?; + } + } + + for (mode, positions) in &candidate.key_positions { + for (index, position) in positions.iter().enumerate() { + validate_position_metrics( + "keyPositions", + mode, + index, + current + .key_positions + .get(mode) + .and_then(|values| values.get(index)), + position, + )?; + } + } + for (mode, positions) in &candidate.stat_positions { + for (index, position) in positions.iter().enumerate() { + validate_position_metrics( + "statPositions", + mode, + index, + current + .stat_positions + .get(mode) + .and_then(|values| values.get(index)) + .map(|position| &position.position), + &position.position, + )?; + } + } + for (mode, positions) in &candidate.graph_positions { + for (index, position) in positions.iter().enumerate() { + validate_position_metrics( + "graphPositions", + mode, + index, + current + .graph_positions + .get(mode) + .and_then(|values| values.get(index)) + .map(|position| &position.position), + &position.position, + )?; + } + } + for (mode, positions) in &candidate.knob_positions { + for (index, position) in positions.iter().enumerate() { + validate_position_metrics( + "knobPositions", + mode, + index, + current + .knob_positions + .get(mode) + .and_then(|values| values.get(index)) + .map(|position| &position.position), + &position.position, + )?; + } + } + + Ok(()) +} + +fn editor_modes(document: &EditorDocumentV1) -> BTreeSet { + document + .keys + .keys() + .chain(document.key_positions.keys()) + .chain(document.stat_positions.keys()) + .chain(document.graph_positions.keys()) + .chain(document.knob_positions.keys()) + .chain(document.layer_groups.keys()) + .cloned() + .collect() +} + +fn render_item_count(document: &EditorDocumentV1) -> usize { + document.key_positions.values().map(Vec::len).sum::() + + document + .stat_positions + .values() + .map(Vec::len) + .sum::() + + document + .graph_positions + .values() + .map(Vec::len) + .sum::() + + document + .knob_positions + .values() + .map(Vec::len) + .sum::() +} + +fn validate_collection_limits( + field: &str, + current: &HashMap>, + candidate: &HashMap>, +) -> Result<(), EditorCommitError> { + for (mode, values) in candidate { + validate_count_limit( + "COLLECTION_TOO_LARGE", + &format!("{field} mode '{mode}' item count"), + current.get(mode).map_or(0, Vec::len), + values.len(), + MAX_ITEMS_PER_MODE, + )?; + } + Ok(()) +} + +fn validate_count_limit( + code: &'static str, + label: &str, + current: usize, + candidate: usize, + limit: usize, +) -> Result<(), EditorCommitError> { + if candidate <= limit || (current > limit && candidate <= current) { + return Ok(()); + } + Err(EditorCommitError::validation( + code, + format!("{label} exceeds {limit} and increases the stored metric"), + )) +} + +fn validate_position_metrics( + field: &str, + mode: &str, + index: usize, + current: Option<&KeyPosition>, + candidate: &KeyPosition, +) -> Result<(), EditorCommitError> { + for (name, current, candidate) in [ + ("dx", current.map(|position| position.dx), candidate.dx), + ("dy", current.map(|position| position.dy), candidate.dy), + ] { + if coordinate_within_limit(candidate) + || current.is_some_and(|value| { + !coordinate_within_limit(value) + && numeric_metric_non_increasing(value, candidate, false) + }) + { + continue; + } + return Err(EditorCommitError::validation( + "COORDINATE_OUT_OF_RANGE", + format!("{field} {mode}[{index}].{name} exceeds ±{MAX_ABS_COORDINATE}"), + )); + } + + for (name, current, candidate) in [ + ( + "width", + current.map(|position| position.width), + candidate.width, + ), + ( + "height", + current.map(|position| position.height), + candidate.height, + ), + ] { + if dimension_within_limit(candidate) + || current.is_some_and(|value| { + !dimension_within_limit(value) + && numeric_metric_non_increasing(value, candidate, true) + }) + { + continue; + } + return Err(EditorCommitError::validation( + "DIMENSION_OUT_OF_RANGE", + format!("{field} {mode}[{index}].{name} must satisfy 0 < value <= {MAX_DIMENSION}"), + )); + } + Ok(()) +} + +fn coordinate_within_limit(value: f64) -> bool { + value.is_finite() && value.abs() <= MAX_ABS_COORDINATE +} + +fn dimension_within_limit(value: f64) -> bool { + value.is_finite() && value > 0.0 && value <= MAX_DIMENSION +} + +fn numeric_metric_non_increasing(current: f64, candidate: f64, dimension: bool) -> bool { + if !current.is_finite() || !candidate.is_finite() { + return current.to_bits() == candidate.to_bits(); + } + if dimension && (current <= 0.0 || candidate <= 0.0) { + return current.to_bits() == candidate.to_bits(); + } + if dimension { + candidate <= current + } else { + candidate.abs() <= current.abs() + } +} + +fn collect_group_violations( + document: &EditorDocumentV1, + violations: &mut BTreeSet, +) -> HashMap> { + let mut result = HashMap::new(); + for (mode, groups) in &document.layer_groups { + let mut ids = HashSet::new(); + for (index, group) in groups.iter().enumerate() { + if group.id.is_empty() { + violations.insert(ValidationViolation::new( + format!("group-id:{mode}:{index}:{:?}", group.id), + "INVALID_GROUP_ID", + format!("layer group id at {mode}[{index}] is empty"), + )); + } + if !ids.insert(group.id.clone()) { + violations.insert(ValidationViolation::new( + format!("duplicate-group:{mode}:{}", group.id), + "DUPLICATE_GROUP_ID", + format!( + "layer group id '{}' is duplicated in mode '{mode}'", + group.id + ), + )); + } + } + result.insert(mode.clone(), ids); + } + result +} + +fn collect_group_reference_violations( + document: &EditorDocumentV1, + group_ids: &HashMap>, + violations: &mut BTreeSet, +) { + for (field, mode, index, position) in document + .key_positions + .iter() + .flat_map(|(mode, positions)| { + positions + .iter() + .enumerate() + .map(move |(index, position)| ("keyPositions", mode, index, position)) + }) + .chain( + document + .stat_positions + .iter() + .flat_map(|(mode, positions)| { + positions.iter().enumerate().map(move |(index, position)| { + ("statPositions", mode, index, &position.position) + }) + }), + ) + .chain( + document + .graph_positions + .iter() + .flat_map(|(mode, positions)| { + positions.iter().enumerate().map(move |(index, position)| { + ("graphPositions", mode, index, &position.position) + }) + }), + ) + .chain( + document + .knob_positions + .iter() + .flat_map(|(mode, positions)| { + positions.iter().enumerate().map(move |(index, position)| { + ("knobPositions", mode, index, &position.position) + }) + }), + ) + { + let Some(group_id) = position.group_id.as_deref() else { + continue; + }; + let exists = group_ids + .get(mode) + .is_some_and(|ids| ids.contains(group_id)); + if !exists { + violations.insert(ValidationViolation::new( + format!("group-ref:{field}:{mode}:{index}:{group_id}"), + "UNKNOWN_GROUP_ID", + format!("{field} {mode}[{index}] references unknown group '{group_id}'"), + )); + } + } +} + +pub(crate) fn sync_key_counters(counters: &mut KeyCounters, keys: &KeyMappings) { + for (mode, key_list) in keys { + let entry = counters.entry(mode.clone()).or_default(); + entry.retain(|key, _| key_list.contains(key)); + for key in key_list { + entry.entry(key.clone()).or_insert(0); + } + } + + counters.retain(|mode, _| keys.contains_key(mode)); +} + +pub(crate) fn repair_selected_mode(store: &mut AppStoreData) { + let selected_is_valid = default_keys().contains_key(&store.selected_key_type) + || (store.keys.contains_key(&store.selected_key_type) + && store + .custom_tabs + .iter() + .any(|tab| tab.id == store.selected_key_type)); + if !selected_is_valid { + store.selected_key_type = "4key".to_string(); + } +} + +pub(crate) fn touched_pair(fields: &[EditorField]) -> (bool, bool) { + ( + fields.contains(&EditorField::Keys), + fields.contains(&EditorField::KeyPositions), + ) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::models::{ + CustomTab, EditorCommitRequest, EditorDocumentV1, EditorPatchV1, KeyPosition, + LayerGroupDef, StatPosition, StatType, + }; + + use super::*; + + fn request(keys: KeyMappings) -> EditorCommitRequest { + EditorCommitRequest { + base_revision: 0, + mutation_id: Uuid::new_v4().to_string(), + changes: EditorPatchV1 { + keys: Some(keys), + ..EditorPatchV1::default() + }, + } + } + + fn default_editor_store() -> AppStoreData { + AppStoreData { + keys: crate::defaults::default_keys().clone(), + key_positions: crate::defaults::default_positions().clone(), + ..AppStoreData::default() + } + } + + fn store_with_custom_modes(count: usize) -> AppStoreData { + let mut store = AppStoreData::default(); + for index in 0..count { + store.custom_tabs.push(CustomTab { + id: format!("custom-{index}"), + name: format!("Custom {index}"), + }); + } + store + } + + #[test] + fn canonical_fingerprint_ignores_hash_map_insertion_order() { + let mut left = HashMap::new(); + left.insert("4key".to_string(), vec!["A".to_string()]); + left.insert("5key".to_string(), vec!["B".to_string()]); + + let mut right = HashMap::new(); + right.insert("5key".to_string(), vec!["B".to_string()]); + right.insert("4key".to_string(), vec!["A".to_string()]); + + assert_eq!( + request_fingerprint(&request(left)).unwrap(), + request_fingerprint(&request(right)).unwrap() + ); + } + + #[test] + fn structural_single_field_update_requires_pair() { + let store = AppStoreData::default(); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate + .keys + .entry("4key".to_string()) + .or_default() + .push("A".to_string()); + + let error = validate_paired_update(¤t, &candidate, true, false).unwrap_err(); + assert_eq!( + error.error_code, + crate::errors::EditorCommitErrorCode::PairedUpdateRequired + ); + } + + #[test] + fn unchanged_ghost_mode_is_grandfathered() { + let mut store = AppStoreData::default(); + store + .keys + .insert("ghost".to_string(), vec!["A".to_string()]); + store + .key_positions + .insert("ghost".to_string(), vec![KeyPosition::default()]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate.keys.get_mut("ghost").unwrap()[0] = "B".to_string(); + + validate_document_transition(¤t, &candidate, &store, &store).unwrap(); + } + + #[test] + fn new_ghost_mode_is_rejected() { + let store = AppStoreData::default(); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate + .keys + .insert("ghost".to_string(), vec!["A".to_string()]); + candidate + .key_positions + .insert("ghost".to_string(), vec![KeyPosition::default()]); + + let error = validate_document_transition(¤t, &candidate, &store, &store).unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("UNKNOWN_MODE") + ); + } + + #[test] + fn metadata_can_introduce_matching_custom_mode_in_same_transition() { + let store = AppStoreData::default(); + let current = EditorDocumentV1::from_store(&store); + let mut candidate_store = store.clone(); + candidate_store.custom_tabs.push(CustomTab { + id: "custom".to_string(), + name: "Custom".to_string(), + }); + candidate_store + .keys + .insert("custom".to_string(), vec!["A".to_string()]); + candidate_store + .key_positions + .insert("custom".to_string(), vec![KeyPosition::default()]); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); + } + + #[test] + fn history_restore_requires_metadata_for_every_custom_editor_mode() { + let mut store = AppStoreData::default(); + store + .keys + .insert("custom".to_string(), vec!["A".to_string()]); + store + .key_positions + .insert("custom".to_string(), vec![KeyPosition::default()]); + let document = EditorDocumentV1::from_store(&store); + + let error = validate_history_restore_metadata(&document, &[], "4key").unwrap_err(); + + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("CUSTOM_TAB_METADATA_MISSING") + ); + } + + #[test] + fn history_restore_rejects_metadata_without_paired_editor_collections() { + let document = EditorDocumentV1::from_store(&AppStoreData::default()); + let tabs = vec![CustomTab { + id: "custom".to_string(), + name: "Custom".to_string(), + }]; + + let error = validate_history_restore_metadata(&document, &tabs, "custom").unwrap_err(); + + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("CUSTOM_TAB_DOCUMENT_MISSING") + ); + } + + #[test] + fn coordinate_and_dimension_limits_accept_boundary_and_reject_one_past_it() { + let store = default_editor_store(); + let current = EditorDocumentV1::from_store(&store); + let mut boundary = current.clone(); + let position = &mut boundary.key_positions.get_mut("4key").unwrap()[0]; + position.dx = MAX_ABS_COORDINATE; + position.dy = -MAX_ABS_COORDINATE; + position.width = MAX_DIMENSION; + position.height = MAX_DIMENSION; + let mut boundary_store = store.clone(); + boundary.apply_to_store(&mut boundary_store); + validate_document_transition(¤t, &boundary, &store, &boundary_store).unwrap(); + + for (name, value) in [ + ("dx", MAX_ABS_COORDINATE + 1.0), + ("dy", -MAX_ABS_COORDINATE - 1.0), + ("width", MAX_DIMENSION + 1.0), + ("height", 0.0), + ] { + let mut invalid = current.clone(); + let position = &mut invalid.key_positions.get_mut("4key").unwrap()[0]; + match name { + "dx" => position.dx = value, + "dy" => position.dy = value, + "width" => position.width = value, + "height" => position.height = value, + _ => unreachable!(), + } + let mut invalid_store = store.clone(); + invalid.apply_to_store(&mut invalid_store); + assert!( + validate_document_transition(¤t, &invalid, &store, &invalid_store).is_err(), + "{name}={value} should be rejected" + ); + } + } + + #[test] + fn oversized_coordinate_is_grandfathered_only_when_unchanged_or_decreased() { + let mut store = default_editor_store(); + store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 2.0; + let current = EditorDocumentV1::from_store(&store); + + validate_document_transition(¤t, ¤t, &store, &store).unwrap(); + + let mut decreased = current.clone(); + decreased.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; + let mut decreased_store = store.clone(); + decreased.apply_to_store(&mut decreased_store); + validate_document_transition(¤t, &decreased, &store, &decreased_store).unwrap(); + + let mut increased = current.clone(); + increased.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 3.0; + let mut increased_store = store.clone(); + increased.apply_to_store(&mut increased_store); + assert!( + validate_document_transition(¤t, &increased, &store, &increased_store).is_err() + ); + } + + #[test] + fn oversized_per_mode_collection_is_grandfathered_only_when_non_increasing() { + let mut store = store_with_custom_modes(1); + store.keys.insert( + "custom-0".to_string(), + (0..514).map(|index| format!("Key{index}")).collect(), + ); + store + .key_positions + .insert("custom-0".to_string(), vec![KeyPosition::default(); 514]); + let current = EditorDocumentV1::from_store(&store); + validate_document_transition(¤t, ¤t, &store, &store).unwrap(); + + let mut decreased = current.clone(); + decreased.keys.get_mut("custom-0").unwrap().pop(); + decreased.key_positions.get_mut("custom-0").unwrap().pop(); + let mut decreased_store = store.clone(); + decreased.apply_to_store(&mut decreased_store); + validate_document_transition(¤t, &decreased, &store, &decreased_store).unwrap(); + + let mut increased = current.clone(); + increased + .keys + .get_mut("custom-0") + .unwrap() + .push("Extra".to_string()); + increased + .key_positions + .get_mut("custom-0") + .unwrap() + .push(KeyPosition::default()); + let mut increased_store = store.clone(); + increased.apply_to_store(&mut increased_store); + assert!( + validate_document_transition(¤t, &increased, &store, &increased_store).is_err() + ); + } + + #[test] + fn oversized_mode_count_is_grandfathered_only_when_non_increasing() { + let mut store = store_with_custom_modes(65); + for index in 0..65 { + store.keys.insert(format!("custom-{index}"), Vec::new()); + store + .key_positions + .insert(format!("custom-{index}"), Vec::new()); + } + let current = EditorDocumentV1::from_store(&store); + validate_document_transition(¤t, ¤t, &store, &store).unwrap(); + + let mut decreased = current.clone(); + decreased.keys.remove("custom-64"); + decreased.key_positions.remove("custom-64"); + let mut decreased_store = store.clone(); + decreased.apply_to_store(&mut decreased_store); + validate_document_transition(¤t, &decreased, &store, &decreased_store).unwrap(); + + let mut increased_store = store.clone(); + increased_store.custom_tabs.push(CustomTab { + id: "custom-65".to_string(), + name: "Custom 65".to_string(), + }); + let mut increased = current.clone(); + increased.keys.insert("custom-65".to_string(), Vec::new()); + increased + .key_positions + .insert("custom-65".to_string(), Vec::new()); + increased.apply_to_store(&mut increased_store); + assert!( + validate_document_transition(¤t, &increased, &store, &increased_store).is_err() + ); + } + + #[test] + fn count_limits_accept_exact_boundaries_and_reject_boundary_plus_one() { + let mode_store = store_with_custom_modes(65); + let empty = EditorDocumentV1::from_store(&mode_store); + let mut modes_at_limit = empty.clone(); + for index in 0..64 { + modes_at_limit + .keys + .insert(format!("custom-{index}"), Vec::new()); + modes_at_limit + .key_positions + .insert(format!("custom-{index}"), Vec::new()); + } + let mut modes_at_limit_store = mode_store.clone(); + modes_at_limit.apply_to_store(&mut modes_at_limit_store); + validate_document_transition(&empty, &modes_at_limit, &mode_store, &modes_at_limit_store) + .unwrap(); + let mut too_many_modes = modes_at_limit.clone(); + too_many_modes + .keys + .insert("custom-64".to_string(), Vec::new()); + too_many_modes + .key_positions + .insert("custom-64".to_string(), Vec::new()); + let mut too_many_modes_store = mode_store.clone(); + too_many_modes.apply_to_store(&mut too_many_modes_store); + assert!(validate_document_transition( + &empty, + &too_many_modes, + &mode_store, + &too_many_modes_store, + ) + .is_err()); + + let collection_store = store_with_custom_modes(1); + let collection_empty = EditorDocumentV1::from_store(&collection_store); + let mut collection_at_limit = collection_empty.clone(); + collection_at_limit.keys.insert( + "custom-0".to_string(), + (0..512).map(|index| format!("Key{index}")).collect(), + ); + collection_at_limit + .key_positions + .insert("custom-0".to_string(), vec![KeyPosition::default(); 512]); + let mut collection_at_limit_store = collection_store.clone(); + collection_at_limit.apply_to_store(&mut collection_at_limit_store); + validate_document_transition( + &collection_empty, + &collection_at_limit, + &collection_store, + &collection_at_limit_store, + ) + .unwrap(); + let mut collection_too_large = collection_at_limit.clone(); + collection_too_large + .keys + .get_mut("custom-0") + .unwrap() + .push("Extra".to_string()); + collection_too_large + .key_positions + .get_mut("custom-0") + .unwrap() + .push(KeyPosition::default()); + let mut collection_too_large_store = collection_store.clone(); + collection_too_large.apply_to_store(&mut collection_too_large_store); + assert!(validate_document_transition( + &collection_empty, + &collection_too_large, + &collection_store, + &collection_too_large_store, + ) + .is_err()); + + let render_store = store_with_custom_modes(8); + let render_empty = EditorDocumentV1::from_store(&render_store); + let mut render_at_limit = render_empty.clone(); + for index in 0..8 { + let mode = format!("custom-{index}"); + render_at_limit + .keys + .insert(mode.clone(), vec![String::new(); 512]); + render_at_limit + .key_positions + .insert(mode, vec![KeyPosition::default(); 512]); + } + let mut render_at_limit_store = render_store.clone(); + render_at_limit.apply_to_store(&mut render_at_limit_store); + validate_document_transition( + &render_empty, + &render_at_limit, + &render_store, + &render_at_limit_store, + ) + .unwrap(); + let mut too_many_render_items = render_at_limit.clone(); + too_many_render_items.stat_positions.insert( + "custom-0".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }], + ); + let mut too_many_render_items_store = render_store.clone(); + too_many_render_items.apply_to_store(&mut too_many_render_items_store); + assert!(validate_document_transition( + &render_empty, + &too_many_render_items, + &render_store, + &too_many_render_items_store, + ) + .is_err()); + + let group_store = store_with_custom_modes(9); + let group_empty = EditorDocumentV1::from_store(&group_store); + let mut groups_at_limit = group_empty.clone(); + for index in 0..8 { + groups_at_limit.layer_groups.insert( + format!("custom-{index}"), + (0..512) + .map(|group| LayerGroupDef { + id: format!("g-{index}-{group}"), + name: "Group".to_string(), + }) + .collect(), + ); + } + let mut groups_at_limit_store = group_store.clone(); + groups_at_limit.apply_to_store(&mut groups_at_limit_store); + validate_document_transition( + &group_empty, + &groups_at_limit, + &group_store, + &groups_at_limit_store, + ) + .unwrap(); + let mut too_many_groups = groups_at_limit.clone(); + too_many_groups.layer_groups.insert( + "custom-8".to_string(), + vec![LayerGroupDef { + id: "extra-group".to_string(), + name: "Group".to_string(), + }], + ); + let mut too_many_groups_store = group_store.clone(); + too_many_groups.apply_to_store(&mut too_many_groups_store); + assert!(validate_document_transition( + &group_empty, + &too_many_groups, + &group_store, + &too_many_groups_store, + ) + .is_err()); + } + + #[test] + fn revision_and_mutation_id_wire_limits_are_enforced() { + assert!(validate_revision(MAX_SAFE_EDITOR_REVISION).is_ok()); + assert!(validate_revision(MAX_SAFE_EDITOR_REVISION + 1).is_err()); + assert!(next_revision(MAX_SAFE_EDITOR_REVISION).is_err()); + + let invalid = EditorCommitRequest { + base_revision: 0, + mutation_id: "not-a-uuid".to_string(), + changes: EditorPatchV1::default(), + }; + assert!(validate_request_envelope(&invalid).is_err()); + } + + #[test] + fn oversized_render_and_group_totals_only_allow_non_increasing_changes() { + let mut render_store = store_with_custom_modes(8); + for index in 0..8 { + let mode = format!("custom-{index}"); + render_store + .keys + .insert(mode.clone(), vec![String::new(); 512]); + render_store + .key_positions + .insert(mode, vec![KeyPosition::default(); 512]); + } + render_store.stat_positions.insert( + "custom-0".to_string(), + vec![ + StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }; + 2 + ], + ); + let current_render = EditorDocumentV1::from_store(&render_store); + validate_document_transition( + ¤t_render, + ¤t_render, + &render_store, + &render_store, + ) + .unwrap(); + let mut less_render = current_render.clone(); + less_render + .stat_positions + .get_mut("custom-0") + .unwrap() + .pop(); + let mut less_render_store = render_store.clone(); + less_render.apply_to_store(&mut less_render_store); + validate_document_transition( + ¤t_render, + &less_render, + &render_store, + &less_render_store, + ) + .unwrap(); + let mut more_render = current_render.clone(); + more_render + .stat_positions + .get_mut("custom-0") + .unwrap() + .push(StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }); + let mut more_render_store = render_store.clone(); + more_render.apply_to_store(&mut more_render_store); + assert!(validate_document_transition( + ¤t_render, + &more_render, + &render_store, + &more_render_store, + ) + .is_err()); + + let mut group_store = store_with_custom_modes(9); + for index in 0..8 { + group_store.layer_groups.insert( + format!("custom-{index}"), + (0..512) + .map(|group| LayerGroupDef { + id: format!("g-{index}-{group}"), + name: "Group".to_string(), + }) + .collect(), + ); + } + group_store.layer_groups.insert( + "custom-8".to_string(), + vec![ + LayerGroupDef { + id: "g-8-0".to_string(), + name: "Group".to_string(), + }, + LayerGroupDef { + id: "g-8-1".to_string(), + name: "Group".to_string(), + }, + ], + ); + let current_groups = EditorDocumentV1::from_store(&group_store); + validate_document_transition(¤t_groups, ¤t_groups, &group_store, &group_store) + .unwrap(); + let mut less_groups = current_groups.clone(); + less_groups.layer_groups.get_mut("custom-8").unwrap().pop(); + let mut less_group_store = group_store.clone(); + less_groups.apply_to_store(&mut less_group_store); + validate_document_transition( + ¤t_groups, + &less_groups, + &group_store, + &less_group_store, + ) + .unwrap(); + let mut more_groups = current_groups.clone(); + more_groups + .layer_groups + .get_mut("custom-8") + .unwrap() + .push(LayerGroupDef { + id: "g-8-2".to_string(), + name: "Group".to_string(), + }); + let mut more_group_store = group_store.clone(); + more_groups.apply_to_store(&mut more_group_store); + assert!(validate_document_transition( + ¤t_groups, + &more_groups, + &group_store, + &more_group_store, + ) + .is_err()); + } + + #[test] + fn existing_pair_and_group_reference_violations_are_not_grandfathered() { + let mut pair_store = default_editor_store(); + pair_store.keys.get_mut("4key").unwrap().pop(); + let pair_document = EditorDocumentV1::from_store(&pair_store); + assert!(validate_document_transition( + &pair_document, + &pair_document, + &pair_store, + &pair_store, + ) + .is_err()); + + let mut reference_store = default_editor_store(); + reference_store.key_positions.get_mut("4key").unwrap()[0].group_id = + Some("missing".to_string()); + let reference_document = EditorDocumentV1::from_store(&reference_store); + assert!(validate_document_transition( + &reference_document, + &reference_document, + &reference_store, + &reference_store, + ) + .is_err()); + } + + #[test] + fn compact_request_size_boundaries_are_exact() { + fn sized_request(target_bytes: usize) -> EditorCommitRequest { + let mut empty_keys = KeyMappings::new(); + empty_keys.insert("4key".to_string(), vec![String::new()]); + let empty = request(empty_keys); + let overhead = serde_json::to_vec(&empty).unwrap().len(); + assert!(target_bytes >= overhead); + + let mut keys = KeyMappings::new(); + keys.insert( + "4key".to_string(), + vec!["x".repeat(target_bytes - overhead)], + ); + let request = EditorCommitRequest { + mutation_id: empty.mutation_id, + ..request(keys) + }; + assert_eq!(serde_json::to_vec(&request).unwrap().len(), target_bytes); + request + } + + assert_eq!( + request_payload_size(&sized_request(REQUEST_WARNING_BYTES - 1)).unwrap(), + REQUEST_WARNING_BYTES - 1 + ); + assert_eq!( + request_payload_size(&sized_request(REQUEST_WARNING_BYTES)).unwrap(), + REQUEST_WARNING_BYTES + ); + assert_eq!( + request_payload_size(&sized_request(MAX_REQUEST_BYTES)).unwrap(), + MAX_REQUEST_BYTES + ); + let error = request_payload_size(&sized_request(MAX_REQUEST_BYTES + 1)).unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("REQUEST_TOO_LARGE") + ); + } +} diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index ff134dc9..c74d2582 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -57,6 +57,8 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { if migrate_legacy_knob_sensitivity(&mut data) { needs_persist = true; } + let editor_revision_repaired = repair_editor_revision(&mut data); + needs_persist |= editor_revision_repaired; let semantic_repaired = repair_semantic_identities(&mut data); needs_persist |= semantic_repaired; let layout_repaired = repair_custom_tab_key_layout_pairs( @@ -70,7 +72,7 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { ( normalize_state(data), needs_persist, - layout_repaired || semantic_repaired, + layout_repaired || semantic_repaired || editor_revision_repaired, ) } Err(err) => { @@ -490,6 +492,8 @@ fn has_convertible_note_border_color(data: &AppStoreData) -> bool { /// store 데이터 정규화 및 레거시 마이그레이션 적용 pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { + repair_editor_revision(&mut data); + if data.keys.is_empty() { data.keys = default_keys().clone(); } else { @@ -569,6 +573,19 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { data } +fn repair_editor_revision(data: &mut AppStoreData) -> bool { + if data.editor_revision <= super::editor::MAX_SAFE_EDITOR_REVISION { + return false; + } + + log::warn!( + "[Store] Resetting unsafe editorRevision {} to 0 during load recovery", + data.editor_revision + ); + data.editor_revision = 0; + true +} + fn has_valid_selected_key_type(data: &AppStoreData) -> bool { default_keys().contains_key(&data.selected_key_type) || (data.keys.contains_key(&data.selected_key_type) @@ -1461,6 +1478,281 @@ mod tests { }, }; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + use serde::{Deserialize, Serialize}; + + fn tauri_store_fixture_base() -> serde_json::Value { + serde_json::json!({ + "hardwareAcceleration": true, + "alwaysOnTop": false, + "overlayLocked": false, + "noteEffect": true, + "selectedKeyType": "fixture-tab", + "customTabs": [{ "id": "fixture-tab", "name": "Fixture" }], + "angleMode": "d3d11", + "language": "ko", + "laboratoryEnabled": false, + "keys": { "fixture-tab": ["F13"] }, + "keyPositions": { + "fixture-tab": [{ + "dx": 13.0, + "dy": 14.0, + "width": 60.0, + "height": 60.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }, + "keyCounters": { "fixture-tab": { "F13": 17 } }, + "backgroundColor": "#131415", + "useCustomCss": false, + "customCss": { "path": null, "content": "" }, + "overlayResizeAnchor": "top-left", + "overlayBounds": null, + "overlayLastContentTopOffset": null, + "overlayBoundsAreLogical": false, + "keyCounterEnabled": true + }) + } + + fn load_literal_fixture(version: &str, fixture: &serde_json::Value) -> AppStoreData { + let path = std::env::temp_dir().join(format!( + "dmnote-tauri-{version}-store-fixture-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, serde_json::to_vec_pretty(fixture).unwrap()).unwrap(); + let loaded = load_store_from_path(&path) + .unwrap_or_else(|error| panic!("Tauri {version} fixture must load: {error:#}")); + let _ = std::fs::remove_file(path); + loaded.data + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct HistoricalTauri13Store { + #[serde(default)] + keys: crate::models::KeyMappings, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct HistoricalTauri14PlusStore { + #[serde(default)] + keys: crate::models::KeyMappings, + #[serde(default, flatten)] + plugin_data: std::collections::HashMap, + } + + #[test] + fn tauri_era_store_schema_transitions_preserve_editor_data() { + let v1_3 = tauri_store_fixture_base(); + + let mut v1_4 = tauri_store_fixture_base(); + v1_4.as_object_mut().unwrap().extend([ + ("developerModeEnabled".to_string(), serde_json::json!(true)), + ("tabCssOverrides".to_string(), serde_json::json!({})), + ("useCustomJs".to_string(), serde_json::json!(true)), + ( + "customJs".to_string(), + serde_json::json!({ "path": null, "content": "void 0" }), + ), + ( + "plugin:fixture".to_string(), + serde_json::json!({ "kept": true }), + ), + ]); + + let mut v1_5 = v1_4.clone(); + v1_5.as_object_mut().unwrap().extend([ + ( + "statPositions".to_string(), + serde_json::json!({ + "fixture-tab": [{ + "statType": "kps", + "dx": 80.0, + "dy": 14.0, + "width": 80.0, + "height": 40.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }), + ), + ( + "fontSettings".to_string(), + serde_json::json!({ "customFonts": [] }), + ), + ("gridSettings".to_string(), serde_json::json!({})), + ("shortcuts".to_string(), serde_json::json!({})), + ]); + + let mut v1_6 = v1_5.clone(); + v1_6["keyPositions"]["fixture-tab"][0]["groupId"] = serde_json::json!("fixture-group"); + v1_6.as_object_mut().unwrap().extend([ + ( + "graphPositions".to_string(), + serde_json::json!({ + "fixture-tab": [{ + "statType": "kpsAvg", + "graphType": "line", + "graphSpeed": 1000, + "graphColor": "#24BBB4", + "showAvgLine": true, + "dx": 170.0, + "dy": 14.0, + "width": 160.0, + "height": 80.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }), + ), + ( + "layerGroups".to_string(), + serde_json::json!({ + "fixture-tab": [{ "id": "fixture-group", "name": "Fixture group" }] + }), + ), + ("counterAnimationPresets".to_string(), serde_json::json!([])), + ("tabNoteOverrides".to_string(), serde_json::json!({})), + ("soundLibrary".to_string(), serde_json::json!({})), + ("obsModeEnabled".to_string(), serde_json::json!(false)), + ("obsPort".to_string(), serde_json::json!(34891)), + ("obsToken".to_string(), serde_json::Value::Null), + ]); + + let mut v1_6_1 = v1_6.clone(); + v1_6_1.as_object_mut().unwrap().extend([ + ( + "knobPositions".to_string(), + serde_json::json!({ + "fixture-tab": [{ + "axisId": "axis-x", + "sensitivity": 1.0, + "reverse": false, + "dx": 340.0, + "dy": 14.0, + "width": 80.0, + "height": 80.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }), + ), + ("keySoundOutputBackend".to_string(), serde_json::Value::Null), + ]); + + for (version, fixture) in [ + ("1.3.0", v1_3), + ("1.4.0", v1_4), + ("1.5.1", v1_5), + ("1.6.0", v1_6), + ("1.6.1", v1_6_1), + ] { + let loaded = load_literal_fixture(version, &fixture); + assert_eq!(loaded.editor_revision, 0, "{version}"); + assert_eq!(loaded.keys["fixture-tab"], vec!["F13"], "{version}"); + assert_eq!(loaded.key_positions["fixture-tab"][0].dx, 13.0, "{version}"); + assert_eq!(loaded.key_counters["fixture-tab"]["F13"], 17, "{version}"); + + if version >= "1.4.0" { + assert_eq!(loaded.plugin_data["plugin:fixture"]["kept"], true); + } + if version >= "1.5.1" { + assert_eq!(loaded.stat_positions["fixture-tab"].len(), 1); + } + if version >= "1.6.0" { + assert_eq!(loaded.graph_positions["fixture-tab"].len(), 1); + assert_eq!(loaded.layer_groups["fixture-tab"][0].id, "fixture-group"); + } + if version >= "1.6.1" { + assert_eq!(loaded.knob_positions["fixture-tab"].len(), 1); + } + } + } + + #[test] + fn reverse_downgrade_models_document_unknown_revision_behavior() { + let fixture = serde_json::json!({ + "keys": { "4key": ["Q"] }, + "editorRevision": 23 + }); + + let v1_3: HistoricalTauri13Store = serde_json::from_value(fixture.clone()).unwrap(); + let v1_3_resaved = serde_json::to_value(v1_3).unwrap(); + assert!(v1_3_resaved.get("editorRevision").is_none()); + + let mut v1_4_plus: HistoricalTauri14PlusStore = serde_json::from_value(fixture).unwrap(); + assert_eq!(v1_4_plus.plugin_data["editorRevision"], 23); + let preserved = serde_json::to_value(&v1_4_plus).unwrap(); + assert_eq!(preserved["editorRevision"], 23); + + v1_4_plus.plugin_data.clear(); + let cleared = serde_json::to_value(v1_4_plus).unwrap(); + assert!(cleared.get("editorRevision").is_none()); + } + + #[test] + fn load_repairs_unsafe_editor_revision_without_touching_editor_data() { + let path = std::env::temp_dir().join(format!( + "dmnote-unsafe-editor-revision-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + data.editor_revision = crate::state::editor::MAX_SAFE_EDITOR_REVISION + 1; + data.key_positions.get_mut("4key").unwrap()[0].dx = 12_345.0; + let expected_keys = data.keys.clone(); + let expected_positions = data.key_positions.clone(); + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + + assert_eq!(loaded.data.editor_revision, 0); + assert_eq!(loaded.data.keys, expected_keys); + assert_eq!(loaded.data.key_positions, expected_positions); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + let _ = std::fs::remove_file(path); + } + + #[test] + fn load_preserves_max_safe_editor_revision() { + let path = std::env::temp_dir().join(format!( + "dmnote-max-safe-editor-revision-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + data.editor_revision = crate::state::editor::MAX_SAFE_EDITOR_REVISION; + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + + assert_eq!( + loaded.data.editor_revision, + crate::state::editor::MAX_SAFE_EDITOR_REVISION + ); + assert!(!loaded.repaired); + let _ = std::fs::remove_file(path); + } #[test] fn clear_dangling_group_ids_removes_ghosts_and_keeps_valid_ones() { diff --git a/src-tauri/src/state/mod.rs b/src-tauri/src/state/mod.rs index 183983f5..8a33faa9 100644 --- a/src-tauri/src/state/mod.rs +++ b/src-tauri/src/state/mod.rs @@ -1,6 +1,7 @@ pub mod app_state; pub(crate) mod atomic_file; pub(crate) mod builtin_sounds; +pub(crate) mod editor; pub(crate) mod local_asset_path; pub(crate) mod migration; pub mod store; diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 6ef847b4..33a766b1 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -1,15 +1,18 @@ use std::{ - collections::HashSet, + collections::{HashSet, VecDeque}, fs, path::{Component, Path, PathBuf}, sync::mpsc, thread::{self, JoinHandle}, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; +use crate::errors::{EditorCommitError, EditorCommitErrorCode}; use crate::models::{ - AppStoreData, FontType, GraphPositions, KeyCounters, KeyMappings, KeyPosition, KeyPositions, - KnobPositions, LayerGroups, SettingsState, StatPositions, + AppStoreData, CommittedEditorChange, EditorCommitOrigin, EditorCommitRequest, + EditorCommitResult, EditorCommittedV1, EditorDocumentV1, EditorField, EditorGetResult, + EditorHistoryRestoreRequest, EditorTransactionResult, FontType, KeyCounters, KeyPosition, + SettingsDiff, SettingsState, EDITOR_SCHEMA_VERSION, }; use anyhow::{anyhow, Context, Result}; use parking_lot::{Mutex, RwLock, RwLockWriteGuard}; @@ -19,6 +22,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, + validate_history_restore_envelope, validate_history_restore_metadata, validate_paired_update, + validate_request_envelope, RequestFingerprint, MUTATION_ACK_CAPACITY, +}; use super::local_asset_path::{file_url_to_path, path_identity_key, FileUrlPath}; use super::migration::{ find_legacy_store_file, load_store_from_path, migrate_key_images_to_app_data, @@ -61,6 +70,14 @@ struct VersionedStoreState { revision: u64, dirty: bool, accepting_writes: bool, + mutation_acks: VecDeque, +} + +#[derive(Clone)] +struct MutationAck { + id: String, + fingerprint: RequestFingerprint, + result: EditorCommitResult, } struct PersistTicket { @@ -143,16 +160,16 @@ impl AppStore { warn_unresolved_asset_references(&state); if needs_persist && had_existing_default_store && !skip_asset_sweep { - match preserve_pre_migration_store(&path) { - Ok(Some(backup_path)) => log::info!( + if let Some(backup_path) = preserve_pre_migration_store(&path).with_context(|| { + format!( + "failed to preserve store before migration at {}", + path.display() + ) + })? { + log::info!( "[Store] Preserved pre-migration store at {}", backup_path.display() - ), - Ok(None) => {} - Err(err) => log::warn!( - "[Store] Failed to preserve pre-migration store at {}: {err:#}", - path.display() - ), + ); } } @@ -186,6 +203,7 @@ impl AppStore { revision: 0, dirty: false, accepting_writes: true, + mutation_acks: VecDeque::with_capacity(MUTATION_ACK_CAPACITY), }), }) } @@ -222,9 +240,10 @@ impl AppStore { reader: impl FnOnce(&AppStoreData) -> T, ) -> Result { let mut guard = self.lock_for_update()?; + let before = guard.data.clone(); let mut scratch = guard.data.clone(); updater(&mut scratch); - scratch = normalize_state(scratch); + ensure_generic_editor_unchanged(&before, &scratch)?; let result = reader(&scratch); self.commit_locked(&mut guard, scratch, result) } @@ -239,7 +258,10 @@ impl AppStore { /// 다른 데이터에는 일반 update를 사용해 성공한 저장만 committed로 공개해야 함 pub(crate) fn update_deferred(&self, updater: impl FnOnce(&mut AppStoreData)) -> Result<()> { let mut guard = self.lock_for_update()?; - updater(&mut guard.data); + let mut scratch = guard.data.clone(); + updater(&mut scratch); + ensure_generic_editor_unchanged(&guard.data, &scratch)?; + guard.data = scratch; guard.dirty = true; Ok(()) } @@ -292,66 +314,329 @@ impl AppStore { self.update_committed(updater, AppStoreData::clone) } - pub fn update_keys(&self, mappings: KeyMappings) -> Result<(KeyMappings, String)> { - self.update_committed( - move |state| state.keys = mappings, - |state| (state.keys.clone(), state.selected_key_type.clone()), - ) + pub fn editor_get(&self) -> EditorGetResult { + let guard = self.state.read(); + EditorGetResult { + revision: guard.data.editor_revision, + document: EditorDocumentV1::from_store(&guard.data), + } } - pub fn update_keys_with_positions( + pub(crate) fn restore_editor_history( &self, - mappings: KeyMappings, - positions: KeyPositions, - ) -> Result<(KeyMappings, KeyPositions, String)> { - self.update_committed( - move |state| { - state.keys = mappings; - state.key_positions = positions; - }, - |state| { - ( - state.keys.clone(), - state.key_positions.clone(), - state.selected_key_type.clone(), - ) + request: EditorHistoryRestoreRequest, + ) -> std::result::Result>, EditorCommitError> { + validate_history_restore_envelope(&request)?; + let EditorHistoryRestoreRequest { + base_revision, + document, + custom_tabs, + selected_key_type, + key_counters, + settings_patch, + tab_note_overrides, + } = request; + + self.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("editor_history_restore".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + move |store| { + if store.editor_revision != base_revision { + return Err(EditorCommitError::revision_conflict(store.editor_revision)); + } + validate_history_restore_metadata(&document, &custom_tabs, &selected_key_type)?; + + store.custom_tabs = custom_tabs; + store.selected_key_type = selected_key_type; + document.apply_to_store(store); + if let Some(counters) = key_counters { + store.key_counters = counters; + } + if let Some(overrides) = tab_note_overrides { + store.tab_note_overrides = overrides; + } + let settings_diff = settings_patch + .as_ref() + .map(|patch| crate::services::settings::apply_patch_to_store(store, patch)); + Ok(settings_diff) }, ) } - pub fn update_positions(&self, positions: KeyPositions) -> Result { - self.update_committed( - move |state| state.key_positions = positions, - |state| state.key_positions.clone(), - ) + pub fn commit_editor_document( + &self, + request: EditorCommitRequest, + ) -> std::result::Result { + let started = Instant::now(); + let base_revision = request.base_revision; + let mutation_id = uuid::Uuid::parse_str(&request.mutation_id) + .map(|id| id.hyphenated().to_string()) + .unwrap_or_else(|_| "".to_string()); + let payload_size = request_payload_size(&request); + let payload_bytes = payload_size.as_ref().copied().unwrap_or(0); + let result = match payload_size { + Ok(_) => self.commit_editor_document_inner(request), + Err(error) => Err(error), + }; + let current_revision = result + .as_ref() + .err() + .and_then(|error| error.details.as_ref()) + .and_then(|details| details.current_revision) + .unwrap_or_else(|| self.state.read().data.editor_revision); + let (outcome, changed_fields) = match &result { + Ok(change) if change.event.is_some() => { + ("committed", change.result.changed_fields.as_slice()) + } + Ok(change) if change.result.changed_fields.is_empty() => { + ("no_op", change.result.changed_fields.as_slice()) + } + Ok(change) => ("replay", change.result.changed_fields.as_slice()), + Err(error) => (editor_error_outcome(error.error_code), &[][..]), + }; + // 문서·patch 원문 없이 경계 메타데이터만 기록 + log::info!( + target: "editor_commit", + "command=editor_commit mutationId={mutation_id} baseRevision={base_revision} currentRevision={current_revision} outcome={outcome} changedFields={changed_fields:?} durationMs={} payloadBytes={payload_bytes}", + started.elapsed().as_millis() + ); + result } - pub fn update_layer_groups(&self, groups: LayerGroups) -> Result { - self.update_committed( - move |state| state.layer_groups = groups, - |state| state.layer_groups.clone(), - ) - } + fn commit_editor_document_inner( + &self, + request: EditorCommitRequest, + ) -> std::result::Result { + validate_request_envelope(&request)?; + let fingerprint = request_fingerprint(&request)?; + let mut guard = self + .lock_for_update() + .map_err(|error| EditorCommitError::io(error.to_string()))?; + + if let Some(ack) = guard + .mutation_acks + .iter() + .find(|ack| ack.id == request.mutation_id) + { + if ack.fingerprint != fingerprint { + return Err(EditorCommitError::mutation_id_reused()); + } + return Ok(CommittedEditorChange { + result: ack.result.clone(), + event: None, + replayed: true, + document: EditorDocumentV1::from_store(&guard.data), + selected_key_type: guard.data.selected_key_type.clone(), + key_counters: guard.data.key_counters.clone(), + }); + } - pub fn update_stat_positions(&self, positions: StatPositions) -> Result { - self.update_committed( - move |state| state.stat_positions = positions, - |state| state.stat_positions.clone(), - ) + if request.base_revision != guard.data.editor_revision { + return Err(EditorCommitError::revision_conflict( + guard.data.editor_revision, + )); + } + + let current_store = guard.data.clone(); + let current = EditorDocumentV1::from_store(¤t_store); + let mut candidate = current.clone(); + candidate.apply_patch(&request.changes); + + validate_paired_update( + ¤t, + &candidate, + request.changes.includes(EditorField::Keys), + request.changes.includes(EditorField::KeyPositions), + )?; + + let mut scratch = current_store.clone(); + candidate.apply_to_store(&mut scratch); + scratch.editor_revision = current_store.editor_revision; + validate_document_transition(¤t, &candidate, ¤t_store, &scratch)?; + + let changed_fields = current.changed_fields(&candidate); + if changed_fields.is_empty() { + let result = EditorCommitResult { + revision: current_store.editor_revision, + changed_fields, + }; + insert_mutation_ack( + &mut guard.mutation_acks, + request.mutation_id, + fingerprint, + result.clone(), + ); + return Ok(CommittedEditorChange { + result, + event: None, + replayed: false, + document: current, + selected_key_type: current_store.selected_key_type, + key_counters: current_store.key_counters, + }); + } + + let revision = next_revision(current_store.editor_revision)?; + if changed_fields.contains(&EditorField::Keys) { + sync_key_counters(&mut scratch.key_counters, &candidate.keys); + repair_selected_mode(&mut scratch); + } + scratch.editor_revision = revision; + 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()))?; + + let result = EditorCommitResult { + revision, + changed_fields: changed_fields.clone(), + }; + insert_mutation_ack( + &mut guard.mutation_acks, + request.mutation_id.clone(), + fingerprint, + result.clone(), + ); + let event = EditorCommittedV1 { + schema_version: EDITOR_SCHEMA_VERSION, + revision, + mutation_id: request.mutation_id, + origin: EditorCommitOrigin::StrictEditorCommit + .event_name() + .expect("strict editor commit always has an event origin"), + changed_fields: changed_fields.clone(), + patch: candidate.patch_for_fields(&changed_fields), + }; + + Ok(CommittedEditorChange { + result, + event: Some(event), + replayed: false, + document: candidate, + selected_key_type, + key_counters, + }) } - pub fn update_graph_positions(&self, positions: GraphPositions) -> Result { - self.update_committed( - move |state| state.graph_positions = positions, - |state| state.graph_positions.clone(), - ) + pub(crate) fn commit_legacy_editor_transaction( + &self, + origin: EditorCommitOrigin, + touched_fields: &[EditorField], + updater: impl FnOnce(&mut AppStoreData) -> std::result::Result, + ) -> std::result::Result, EditorCommitError> { + let started = Instant::now(); + let origin_name = origin + .event_name() + .unwrap_or_else(|| "loadRecovery".to_string()); + let result = self.commit_legacy_editor_transaction_inner(origin, touched_fields, updater); + let (outcome, changed_fields) = match &result { + Ok(transaction) if transaction.change.result.changed_fields.is_empty() => ( + "no_editor_change", + transaction.change.result.changed_fields.as_slice(), + ), + Ok(transaction) => ( + "committed", + transaction.change.result.changed_fields.as_slice(), + ), + Err(error) => (editor_error_outcome(error.error_code), &[][..]), + }; + // adapter 입력 원문 없이 origin과 결과만 기록 + log::info!( + target: "editor_commit", + "origin={origin_name} outcome={outcome} changedFields={changed_fields:?} durationMs={}", + started.elapsed().as_millis() + ); + result } - pub fn update_knob_positions(&self, positions: KnobPositions) -> Result { - self.update_committed( - move |state| state.knob_positions = positions, - |state| state.knob_positions.clone(), - ) + fn commit_legacy_editor_transaction_inner( + &self, + origin: EditorCommitOrigin, + touched_fields: &[EditorField], + updater: impl FnOnce(&mut AppStoreData) -> std::result::Result, + ) -> std::result::Result, EditorCommitError> { + let mut guard = self + .lock_for_update() + .map_err(|error| EditorCommitError::io(error.to_string()))?; + let current_store = guard.data.clone(); + let current = EditorDocumentV1::from_store(¤t_store); + let mut scratch = current_store.clone(); + let value = updater(&mut scratch)?; + + // editorRevision은 이 트랜잭션만 관리 + scratch.editor_revision = current_store.editor_revision; + let candidate = EditorDocumentV1::from_store(&scratch); + let changed_fields = current.changed_fields(&candidate); + if let Some(field) = changed_fields + .iter() + .find(|field| !touched_fields.contains(field)) + { + return Err(EditorCommitError::validation( + "UNDECLARED_EDITOR_FIELD", + format!("editor field {field:?} changed outside the declared transaction scope"), + )); + } + if changed_fields.contains(&EditorField::Keys) { + repair_selected_mode(&mut scratch); + } + + let (keys_touched, key_positions_touched) = touched_pair(touched_fields); + validate_paired_update(¤t, &candidate, keys_touched, key_positions_touched)?; + validate_document_transition(¤t, &candidate, ¤t_store, &scratch)?; + + if changed_fields.contains(&EditorField::Keys) { + sync_key_counters(&mut scratch.key_counters, &candidate.keys); + } + + let revision = if changed_fields.is_empty() { + current_store.editor_revision + } else { + let revision = next_revision(current_store.editor_revision)?; + scratch.editor_revision = revision; + revision + }; + + let has_store_changes = scratch != current_store; + let selected_key_type = scratch.selected_key_type.clone(); + let key_counters = scratch.key_counters.clone(); + if has_store_changes { + self.commit_locked(&mut guard, scratch, ()) + .map_err(|error| EditorCommitError::io(error.to_string()))?; + } + + let event = if changed_fields.is_empty() { + None + } else { + origin.event_name().map(|origin| EditorCommittedV1 { + schema_version: EDITOR_SCHEMA_VERSION, + revision, + mutation_id: uuid::Uuid::new_v4().to_string(), + origin, + changed_fields: changed_fields.clone(), + patch: candidate.patch_for_fields(&changed_fields), + }) + }; + let change = CommittedEditorChange { + result: EditorCommitResult { + revision, + changed_fields, + }, + event, + replayed: false, + document: candidate, + selected_key_type, + key_counters, + }; + + Ok(EditorTransactionResult { value, change }) } pub fn set_key_counters(&self, counters: KeyCounters) -> Result { @@ -827,7 +1112,39 @@ fn preserve_pre_migration_store(path: &Path) -> Result> { let backup_path = pre_migration_backup_path(path)?; match fs::hard_link(path, &backup_path) { Ok(()) => Ok(Some(backup_path)), - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(None), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + let metadata = fs::symlink_metadata(&backup_path).with_context(|| { + format!( + "failed to inspect existing pre-migration backup at {}", + backup_path.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(anyhow!( + "pre-migration backup path is not a regular file: {}", + backup_path.display() + )); + } + let backup = fs::read(&backup_path).with_context(|| { + format!( + "failed to read existing pre-migration backup at {}", + backup_path.display() + ) + })?; + let value: Value = serde_json::from_slice(&backup).with_context(|| { + format!( + "existing pre-migration backup is not valid JSON at {}", + backup_path.display() + ) + })?; + if !value.is_object() { + return Err(anyhow!( + "existing pre-migration backup is not a JSON object: {}", + backup_path.display() + )); + } + Ok(None) + } Err(err) => Err(err).with_context(|| { format!( "failed to create pre-migration backup at {}", @@ -848,6 +1165,43 @@ fn initialize_default_state() -> AppStoreData { normalize_state(data) } +fn ensure_generic_editor_unchanged(before: &AppStoreData, after: &AppStoreData) -> Result<()> { + if before.editor_revision != after.editor_revision + || EditorDocumentV1::from_store(before) != EditorDocumentV1::from_store(after) + { + return Err(anyhow!( + "editor fields must be changed through an editor transaction" + )); + } + Ok(()) +} + +fn editor_error_outcome(code: EditorCommitErrorCode) -> &'static str { + match code { + EditorCommitErrorCode::RevisionConflict => "revision_conflict", + EditorCommitErrorCode::ValidationFailed => "validation_failed", + EditorCommitErrorCode::PairedUpdateRequired => "paired_update_required", + EditorCommitErrorCode::MutationIdReused => "mutation_id_reused", + EditorCommitErrorCode::IoError => "io_error", + } +} + +fn insert_mutation_ack( + acks: &mut VecDeque, + id: String, + fingerprint: RequestFingerprint, + result: EditorCommitResult, +) { + if acks.len() == MUTATION_ACK_CAPACITY { + acks.pop_front(); + } + acks.push_back(MutationAck { + id, + fingerprint, + result, + }); +} + struct AssetReferencePaths { keys: HashSet, complete: bool, @@ -1833,20 +2187,27 @@ mod tests { }; use crate::{ defaults::default_positions, + errors::{EditorCommitError, EditorCommitErrorCode}, keyboard::KeyboardManager, models::{ - AppStoreData, CustomFont, CustomTab, FontType, GraphPosition, GraphStatType, GraphType, - KeyCounters, KeyPosition, KnobPosition, OverlayBounds, PendingProcessedWavReplacement, + AppStoreData, CommittedEditorChange, CustomFont, CustomTab, EditorCommitOrigin, + EditorCommitRequest, EditorDocumentV1, EditorField, EditorHistoryRestoreRequest, + EditorPatchV1, FontType, GraphPosition, GraphStatType, GraphType, KeyCounters, + KeyPosition, KnobPosition, OverlayBounds, PendingProcessedWavReplacement, SettingsPatchInput, SoundLibraryEntry, SoundSource, StatPosition, StatType, + TabNoteSettings, }, services::settings::apply_patch_to_store, state::{app_state::KeyCounterEventEmitter, local_asset_path::path_identity_key, AppState}, }; use serde_json::{json, Value}; use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, path::Path, - sync::{mpsc, Arc, Barrier, Mutex}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, Barrier, Mutex, + }, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -1912,162 +2273,643 @@ mod tests { } } + fn editor_request( + base_revision: u64, + mutation_id: impl Into, + changes: EditorPatchV1, + ) -> EditorCommitRequest { + EditorCommitRequest { + base_revision, + mutation_id: mutation_id.into(), + changes, + } + } + + fn position_patch(store: &AppStore, dx: f64) -> EditorPatchV1 { + let mut positions = store.editor_get().document.key_positions; + positions.get_mut("4key").unwrap()[0].dx = dx; + EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + } + } + + 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()), + fields, + |data| { + updater(data); + Ok(()) + }, + ) + .map(|transaction| transaction.change) + } + #[test] - fn keys_update_returns_the_normalized_mode_for_runtime_sync() { - let dir = test_directory("keys-update-mode-sync-test"); + fn strict_editor_commit_persists_once_and_synchronizes_key_counters() { + let dir = test_directory("strict-editor-success-test"); std::fs::create_dir_all(&dir).unwrap(); let store = AppStore::initialize_in_dir(&dir).unwrap(); - let custom_mode = "custom-mode".to_string(); - let initialized = store - .update(|data| { - data.custom_tabs.push(CustomTab { - id: custom_mode.clone(), - name: "Custom".to_string(), - }); - data.keys - .insert(custom_mode.clone(), vec!["KeyA".to_string()]); - data.selected_key_type = custom_mode.clone(); - }) - .unwrap(); - let keyboard = KeyboardManager::new( - initialized.keys.clone(), - initialized.selected_key_type.clone(), + let persist_count = store.writer.persist_count(); + let before = store.snapshot(); + let old_key = before.keys["4key"][0].clone(); + let mut keys = before.keys.clone(); + keys.get_mut("4key").unwrap()[0] = "StrictKey".to_string(); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(keys), + ..EditorPatchV1::default() + }, ); - let mut mappings = initialized.keys; - mappings.remove(&custom_mode); - let (updated, selected_key_type) = store.update_keys(mappings).unwrap(); - keyboard.update_mappings_and_set_mode(updated, selected_key_type.clone()); + let change = store.commit_editor_document(request).unwrap(); - assert_eq!(selected_key_type, "4key"); - assert_eq!(store.snapshot().selected_key_type, "4key"); - assert_eq!(keyboard.current_mode(), "4key"); + assert_eq!(change.result.revision, 1); + assert_eq!(change.result.changed_fields, vec![EditorField::Keys]); + assert_eq!(change.event.as_ref().unwrap().origin, "editorCommit"); + assert_eq!(store.writer.persist_count(), persist_count + 1); + let snapshot = store.snapshot(); + assert_eq!(snapshot.editor_revision, 1); + assert_eq!(snapshot.keys["4key"][0], "StrictKey"); + assert_eq!(snapshot.key_counters["4key"]["StrictKey"], 0); + assert!(!snapshot.key_counters["4key"].contains_key(&old_key)); store.flush_and_shutdown().unwrap(); drop(store); + let reloaded = crate::state::migration::load_store_from_path(&dir.join("store.json")) + .unwrap() + .data; + assert_eq!(reloaded.editor_revision, 1); + assert_eq!(reloaded.keys["4key"][0], "StrictKey"); let _ = std::fs::remove_dir_all(dir); } #[test] - fn keys_with_positions_commit_is_atomic_and_pads_without_deletion() { - let dir = test_directory("keys-positions-atomic-commit-test"); + fn strict_editor_noop_is_acknowledged_without_persist_or_event() { + let dir = test_directory("strict-editor-noop-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let persist_count = store.writer.persist_count(); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1::default(), + ); + + let change = store.commit_editor_document(request).unwrap(); + + assert_eq!(change.result.revision, 0); + assert!(change.result.changed_fields.is_empty()); + assert!(change.event.is_none()); + assert!(!change.replayed); + assert_eq!(store.writer.persist_count(), persist_count); + assert_eq!(store.snapshot().editor_revision, 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn strict_editor_conflict_does_not_persist() { + let dir = test_directory("strict-editor-conflict-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let persist_count = store.writer.persist_count(); + let error = store + .commit_editor_document(editor_request( + 1, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 123.0), + )) + .unwrap_err(); + + assert_eq!(error.error_code, EditorCommitErrorCode::RevisionConflict); + assert_eq!(error.details.unwrap().current_revision, Some(0)); + assert_eq!(store.writer.persist_count(), persist_count); + assert_eq!(store.snapshot().editor_revision, 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn strict_editor_io_failure_is_not_committed_or_acknowledged() { + let dir = test_directory("strict-editor-io-failure-test"); std::fs::create_dir_all(&dir).unwrap(); let store = AppStore::initialize_in_dir(&dir).unwrap(); let before = store.snapshot(); let disk_before = std::fs::read(dir.join("store.json")).unwrap(); - let mut mappings = before.keys.clone(); - mappings.get_mut("4key").unwrap().push("F5".to_string()); - let mut positions = before.key_positions.clone(); - positions - .get_mut("5key") - .unwrap() - .push(KeyPosition::default()); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 456.0), + ); store.writer.fail_next_persist(); - assert!(store - .update_keys_with_positions(mappings.clone(), positions.clone()) - .is_err()); + let error = store.commit_editor_document(request.clone()).unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::IoError); assert_eq!(store.snapshot(), before); assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), disk_before); - let persist_count = store.writer.persist_count(); - let (keys, positions, _) = store - .update_keys_with_positions(mappings, positions) - .unwrap(); - assert_eq!(store.writer.persist_count(), persist_count + 1); - assert_eq!(keys["4key"].last().unwrap(), "F5"); - assert_eq!(positions["4key"].last().unwrap(), &KeyPosition::default()); - assert!(keys["5key"].last().unwrap().is_empty()); - assert_eq!(keys["4key"].len(), positions["4key"].len()); - assert_eq!(keys["5key"].len(), positions["5key"].len()); - + let retried = store.commit_editor_document(request).unwrap(); + assert_eq!(retried.result.revision, 1); + assert_eq!(store.snapshot().key_positions["4key"][0].dx, 456.0); store.flush_and_shutdown().unwrap(); drop(store); let _ = std::fs::remove_dir_all(dir); } #[test] - fn legacy_single_collection_commits_preserve_key_position_lengths() { - let dir = test_directory("legacy-key-position-commit-length-test"); + fn strict_editor_structural_single_field_update_requires_pair() { + let dir = test_directory("strict-editor-paired-test"); std::fs::create_dir_all(&dir).unwrap(); let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let mut keys = before.keys.clone(); + keys.get_mut("4key").unwrap().push("F5".to_string()); + let error = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(keys), + ..EditorPatchV1::default() + }, + )) + .unwrap_err(); - let mut mappings = store.snapshot().keys; - mappings.get_mut("4key").unwrap().push("F5".to_string()); - store.update_keys(mappings).unwrap(); - let after_keys = store.snapshot(); assert_eq!( - after_keys.keys["4key"].len(), - after_keys.key_positions["4key"].len() - ); - assert_eq!( - after_keys.key_positions["4key"].last(), - Some(&KeyPosition::default()) + error.error_code, + EditorCommitErrorCode::PairedUpdateRequired ); + assert_eq!(store.snapshot(), before); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } - let mut positions = after_keys.key_positions; - positions - .get_mut("5key") - .unwrap() - .push(KeyPosition::default()); - store.update_positions(positions).unwrap(); - let after_positions = store.snapshot(); - assert_eq!( - after_positions.keys["5key"].len(), - after_positions.key_positions["5key"].len() + #[test] + fn strict_editor_retry_returns_original_ack_before_base_check() { + let dir = test_directory("strict-editor-idempotency-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 789.0), ); - assert!(after_positions.keys["5key"].last().unwrap().is_empty()); + let first = store.commit_editor_document(request.clone()).unwrap(); + let persist_count = store.writer.persist_count(); + let retry = store.commit_editor_document(request).unwrap(); + + assert_eq!(retry.result, first.result); + assert!(retry.event.is_none()); + assert!(retry.replayed); + assert_eq!(store.writer.persist_count(), persist_count); store.flush_and_shutdown().unwrap(); drop(store); - let reloaded = crate::state::migration::load_store_from_path(&dir.join("store.json")) - .unwrap() - .data; - for mode in reloaded.keys.keys().chain(reloaded.key_positions.keys()) { - assert_eq!( - reloaded.keys.get(mode).map_or(0, Vec::len), - reloaded.key_positions.get(mode).map_or(0, Vec::len) - ); - } - let _ = std::fs::remove_dir_all(dir); } #[test] - fn asset_cleanup_waits_for_an_active_processed_wav_transaction() { - let dir = test_directory("processed-wav-cleanup-lock-test"); + fn strict_editor_rejects_reused_mutation_id_with_different_request() { + let dir = test_directory("strict-editor-mutation-reuse-test"); std::fs::create_dir_all(&dir).unwrap(); - let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); - let transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); - let (started_tx, started_rx) = mpsc::channel(); - let (done_tx, done_rx) = mpsc::channel(); - let cleanup_store = store.clone(); - let cleanup_thread = std::thread::spawn(move || { - started_tx.send(()).unwrap(); - let result = cleanup_store.cleanup_orphan_assets_now(); - done_tx.send(result).unwrap(); - }); - - started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(done_rx.recv_timeout(Duration::from_millis(50)).is_err()); - drop(transaction_guard); - done_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap() + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + store + .commit_editor_document(editor_request( + 0, + mutation_id.clone(), + EditorPatchV1::default(), + )) .unwrap(); - cleanup_thread.join().unwrap(); + let error = store + .commit_editor_document(editor_request( + 0, + mutation_id, + position_patch(&store, 321.0), + )) + .unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::MutationIdReused); store.flush_and_shutdown().unwrap(); drop(store); let _ = std::fs::remove_dir_all(dir); } #[test] - fn shutdown_clears_a_pending_wav_journal_before_stopping_the_writer() { - let dir = test_directory("processed-wav-shutdown-order-test"); + fn mutation_ack_ring_retains_only_the_latest_32_results() { + let dir = test_directory("strict-editor-ring-test"); std::fs::create_dir_all(&dir).unwrap(); let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target_path = dir.join("sounds").join("sound.wav"); + let ids = (0..33) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>(); + for id in &ids { + store + .commit_editor_document(editor_request(0, id.clone(), EditorPatchV1::default())) + .unwrap(); + } + store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 654.0), + )) + .unwrap(); + + let evicted = store + .commit_editor_document(editor_request(0, ids[0].clone(), EditorPatchV1::default())) + .unwrap_err(); + assert_eq!(evicted.error_code, EditorCommitErrorCode::RevisionConflict); + + let retained = store + .commit_editor_document(editor_request(0, ids[32].clone(), EditorPatchV1::default())) + .unwrap(); + assert_eq!(retained.result.revision, 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn canonical_request_hash_makes_map_order_idempotent() { + let dir = test_directory("strict-editor-canonical-hash-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let current = store.snapshot().keys; + let mut entries = current.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let forward = entries.iter().cloned().collect::>(); + let reverse = entries.into_iter().rev().collect::>(); + + let first = store + .commit_editor_document(editor_request( + 0, + mutation_id.clone(), + EditorPatchV1 { + keys: Some(forward), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let retry = store + .commit_editor_document(editor_request( + 0, + mutation_id, + EditorPatchV1 { + keys: Some(reverse), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + assert_eq!(retry.result, first.result); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn editor_get_never_observes_a_revision_document_mismatch() { + let dir = test_directory("editor-get-atomic-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let done = Arc::new(AtomicBool::new(false)); + let reader_store = Arc::clone(&store); + let reader_done = Arc::clone(&done); + let reader = std::thread::spawn(move || { + while !reader_done.load(Ordering::Acquire) { + let snapshot = reader_store.editor_get(); + if snapshot.revision > 0 { + assert_eq!( + snapshot.document.key_positions["4key"][0].dx, + 10_000.0 + snapshot.revision as f64 + ); + } + } + }); + + for expected_revision in 1..=20 { + let base = store.editor_get().revision; + store + .commit_editor_document(editor_request( + base, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 10_000.0 + expected_revision as f64), + )) + .unwrap(); + } + done.store(true, Ordering::Release); + reader.join().unwrap(); + let final_snapshot = store.editor_get(); + assert_eq!(final_snapshot.revision, 20); + assert_eq!( + final_snapshot.document.key_positions["4key"][0].dx, + 10_020.0 + ); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn metadata_only_legacy_transaction_persists_without_editor_revision_or_event() { + let dir = test_directory("editor-metadata-only-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let persist_count = store.writer.persist_count(); + + let transaction = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("metadata_test".to_string()), + &[], + |data| { + data.language = "en".to_string(); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(store.writer.persist_count(), persist_count + 1); + assert_eq!(transaction.change.result.revision, 0); + assert!(transaction.change.result.changed_fields.is_empty()); + assert!(transaction.change.event.is_none()); + assert_eq!(store.snapshot().language, "en"); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn strict_editor_commit_preserves_existing_ghost_modes_losslessly() { + let dir = test_directory("editor-grandfather-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = super::initialize_default_state(); + data.keys + .insert("ghost".to_string(), vec!["GhostKey".to_string()]); + data.key_positions + .insert("ghost".to_string(), vec![KeyPosition::default()]); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + + store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 222.0), + )) + .unwrap(); + + let snapshot = store.snapshot(); + assert_eq!(snapshot.keys["ghost"], vec!["GhostKey".to_string()]); + assert_eq!( + snapshot.key_positions["ghost"], + vec![KeyPosition::default()] + ); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn editor_get_exposes_recovered_safe_revision_after_loading_unsafe_wire_value() { + let dir = test_directory("editor-revision-load-recovery-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = super::initialize_default_state(); + data.editor_revision = crate::state::editor::MAX_SAFE_EDITOR_REVISION + 1; + data.key_positions.get_mut("4key").unwrap()[0].dx = 12_345.0; + std::fs::write( + dir.join("store.json"), + serde_json::to_vec_pretty(&data).unwrap(), + ) + .unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let editor = store.editor_get(); + + assert_eq!(editor.revision, 0); + assert_eq!(editor.document.key_positions["4key"][0].dx, 12_345.0); + let on_disk: AppStoreData = + serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); + assert_eq!(on_disk.editor_revision, 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn generic_update_cannot_mutate_editor_fields_or_revision() { + let dir = test_directory("editor-generic-escape-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + + assert!(store + .update(|data| data.key_positions.get_mut("4key").unwrap()[0].dx = 999.0) + .is_err()); + assert!(store.update(|data| data.editor_revision += 1).is_err()); + assert_eq!(store.snapshot(), before); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn paired_keys_update_returns_the_normalized_mode_for_runtime_sync() { + let dir = test_directory("keys-update-mode-sync-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let custom_mode = "custom-mode".to_string(); + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("test_setup".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.custom_tabs.push(CustomTab { + id: custom_mode.clone(), + name: "Custom".to_string(), + }); + data.keys + .insert(custom_mode.clone(), vec!["KeyA".to_string()]); + data.key_positions + .insert(custom_mode.clone(), vec![KeyPosition::default()]); + data.selected_key_type = custom_mode.clone(); + Ok(()) + }, + ) + .unwrap(); + let initialized = store.snapshot(); + let keyboard = KeyboardManager::new( + initialized.keys.clone(), + initialized.selected_key_type.clone(), + ); + + let mut mappings = initialized.keys; + mappings.remove(&custom_mode); + let mut positions = initialized.key_positions; + positions.remove(&custom_mode); + let change = legacy_editor_commit( + &store, + &[EditorField::Keys, EditorField::KeyPositions], + move |data| { + data.keys = mappings; + data.key_positions = positions; + }, + ) + .unwrap(); + let updated = change.document.keys; + let selected_key_type = change.selected_key_type; + keyboard.update_mappings_and_set_mode(updated, selected_key_type.clone()); + + assert_eq!(selected_key_type, "4key"); + assert_eq!(store.snapshot().selected_key_type, "4key"); + assert_eq!(keyboard.current_mode(), "4key"); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn keys_with_positions_commit_is_atomic_without_implicit_padding() { + let dir = test_directory("keys-positions-atomic-commit-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let disk_before = std::fs::read(dir.join("store.json")).unwrap(); + let mut mappings = before.keys.clone(); + mappings.get_mut("4key").unwrap().push("F5".to_string()); + mappings.get_mut("5key").unwrap().push(String::new()); + let mut positions = before.key_positions.clone(); + positions + .get_mut("4key") + .unwrap() + .push(KeyPosition::default()); + positions + .get_mut("5key") + .unwrap() + .push(KeyPosition::default()); + + store.writer.fail_next_persist(); + let failed_mappings = mappings.clone(); + let failed_positions = positions.clone(); + assert!(legacy_editor_commit( + &store, + &[EditorField::Keys, EditorField::KeyPositions], + move |data| { + data.keys = failed_mappings; + data.key_positions = failed_positions; + }, + ) + .is_err()); + assert_eq!(store.snapshot(), before); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), disk_before); + + let persist_count = store.writer.persist_count(); + let change = legacy_editor_commit( + &store, + &[EditorField::Keys, EditorField::KeyPositions], + move |data| { + data.keys = mappings; + data.key_positions = positions; + }, + ) + .unwrap(); + let keys = change.document.keys; + let positions = change.document.key_positions; + assert_eq!(store.writer.persist_count(), persist_count + 1); + assert_eq!(keys["4key"].last().unwrap(), "F5"); + assert_eq!(positions["4key"].last().unwrap(), &KeyPosition::default()); + assert!(keys["5key"].last().unwrap().is_empty()); + assert_eq!(keys["4key"].len(), positions["4key"].len()); + assert_eq!(keys["5key"].len(), positions["5key"].len()); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn legacy_structural_single_collection_commits_are_rejected() { + let dir = test_directory("legacy-key-position-commit-length-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + let before = store.snapshot(); + let mut mappings = before.keys.clone(); + mappings.get_mut("4key").unwrap().push("F5".to_string()); + assert!( + legacy_editor_commit(&store, &[EditorField::Keys], move |data| { + data.keys = mappings; + }) + .is_err() + ); + assert_eq!(store.snapshot(), before); + + let mut positions = before.key_positions.clone(); + positions + .get_mut("5key") + .unwrap() + .push(KeyPosition::default()); + assert!( + legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { + data.key_positions = positions; + },) + .is_err() + ); + assert_eq!(store.snapshot(), before); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn asset_cleanup_waits_for_an_active_processed_wav_transaction() { + let dir = test_directory("processed-wav-cleanup-lock-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + let cleanup_store = store.clone(); + let cleanup_thread = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = cleanup_store.cleanup_orphan_assets_now(); + done_tx.send(result).unwrap(); + }); + + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(done_rx.recv_timeout(Duration::from_millis(50)).is_err()); + drop(transaction_guard); + done_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(); + cleanup_thread.join().unwrap(); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn shutdown_clears_a_pending_wav_journal_before_stopping_the_writer() { + let dir = test_directory("processed-wav-shutdown-order-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_path = dir.join("sounds").join("sound.wav"); std::fs::write(&target_path, b"old-wav").unwrap(); store .update(|data| { @@ -2095,55 +2937,218 @@ mod tests { let dir = test_directory("preset-single-transaction-test"); std::fs::create_dir_all(&dir).unwrap(); let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .set_plugin_data("plugin_data_fixture", json!({ "kept": true })) + .unwrap(); let initial_persist_count = store.writer.persist_count(); - let updated = store - .update(|data| { - data.key_positions.get_mut("4key").unwrap()[0].dx = 321.0; + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("preset_test".to_string()), + &[EditorField::KeyPositions], + |data| { + data.key_positions.get_mut("4key").unwrap()[0].dx = 321.0; + apply_patch_to_store( + data, + &SettingsPatchInput { + background_color: Some("#123456".to_string()), + ..SettingsPatchInput::default() + }, + ); + Ok(()) + }, + ) + .unwrap(); + let updated = store.snapshot(); + + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + assert_eq!(updated.key_positions["4key"][0].dx, 321.0); + assert_eq!(updated.background_color, "#123456"); + assert_eq!(updated.plugin_data["plugin_data_fixture"]["kept"], true); + + let before_failure = store.snapshot(); + let disk_before_failure = std::fs::read(dir.join("store.json")).unwrap(); + store.writer.fail_next_persist(); + let result = store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("preset_test".to_string()), + &[EditorField::KeyPositions], + |data| { + data.key_positions.get_mut("4key").unwrap()[0].dx = 999.0; apply_patch_to_store( data, &SettingsPatchInput { - background_color: Some("#123456".to_string()), + background_color: Some("#FFFFFF".to_string()), ..SettingsPatchInput::default() }, ); + Ok(()) + }, + ); + + assert!(result.is_err()); + assert_eq!(store.snapshot(), before_failure); + assert_eq!( + store.snapshot().plugin_data["plugin_data_fixture"]["kept"], + true + ); + assert_eq!( + std::fs::read(dir.join("store.json")).unwrap(), + disk_before_failure + ); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn history_restore_commits_all_core_state_once_and_fails_closed() { + let dir = test_directory("history-restore-transaction-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .set_plugin_data("history_fixture", json!({ "kept": true })) + .unwrap(); + let before = store.snapshot(); + let persist_count = store.writer.persist_count(); + let mut document = EditorDocumentV1::from_store(&before); + document.key_positions.get_mut("4key").unwrap()[0].dx = 456.0; + let mut counters = before.key_counters.clone(); + let first_key = before.keys["4key"][0].clone(); + counters + .get_mut("4key") + .unwrap() + .insert(first_key.clone(), 77); + let mut tab_note_overrides = HashMap::new(); + tab_note_overrides.insert( + "4key".to_string(), + TabNoteSettings { + speed: Some(777), + ..TabNoteSettings::default() + }, + ); + + let transaction = store + .restore_editor_history(EditorHistoryRestoreRequest { + base_revision: before.editor_revision, + document, + custom_tabs: before.custom_tabs.clone(), + selected_key_type: before.selected_key_type.clone(), + key_counters: Some(counters), + settings_patch: Some(SettingsPatchInput { + background_color: Some("#123456".to_string()), + ..SettingsPatchInput::default() + }), + tab_note_overrides: Some(tab_note_overrides), }) .unwrap(); - assert_eq!(store.writer.persist_count(), initial_persist_count + 1); - assert_eq!(updated.key_positions["4key"][0].dx, 321.0); - assert_eq!(updated.background_color, "#123456"); + assert_eq!(store.writer.persist_count(), persist_count + 1); + assert_eq!( + transaction.change.result.revision, + before.editor_revision + 1 + ); + assert_eq!( + transaction.change.result.changed_fields, + vec![EditorField::KeyPositions] + ); + assert!(transaction.value.is_some()); + let committed = store.snapshot(); + assert_eq!(committed.key_positions["4key"][0].dx, 456.0); + assert_eq!(committed.key_counters["4key"][&first_key], 77); + assert_eq!(committed.background_color, "#123456"); + assert_eq!(committed.tab_note_overrides["4key"].speed, Some(777)); + assert_eq!(committed.plugin_data["history_fixture"]["kept"], true); let before_failure = store.snapshot(); let disk_before_failure = std::fs::read(dir.join("store.json")).unwrap(); + let mut failed_document = EditorDocumentV1::from_store(&before_failure); + failed_document.key_positions.get_mut("4key").unwrap()[0].dx = 999.0; store.writer.fail_next_persist(); - let result = store.update(|data| { - data.key_positions.get_mut("4key").unwrap()[0].dx = 999.0; - apply_patch_to_store( - data, - &SettingsPatchInput { - background_color: Some("#FFFFFF".to_string()), - ..SettingsPatchInput::default() - }, - ); + let failed = store.restore_editor_history(EditorHistoryRestoreRequest { + base_revision: before_failure.editor_revision, + document: failed_document.clone(), + custom_tabs: before_failure.custom_tabs.clone(), + selected_key_type: before_failure.selected_key_type.clone(), + key_counters: None, + settings_patch: Some(SettingsPatchInput { + background_color: Some("#FFFFFF".to_string()), + ..SettingsPatchInput::default() + }), + tab_note_overrides: None, }); - assert!(result.is_err()); + assert_eq!( + failed.unwrap_err().error_code, + EditorCommitErrorCode::IoError + ); assert_eq!(store.snapshot(), before_failure); assert_eq!( std::fs::read(dir.join("store.json")).unwrap(), disk_before_failure ); + let persist_count = store.writer.persist_count(); + let stale = store.restore_editor_history(EditorHistoryRestoreRequest { + base_revision: before_failure.editor_revision - 1, + document: failed_document, + custom_tabs: before_failure.custom_tabs.clone(), + selected_key_type: before_failure.selected_key_type.clone(), + key_counters: None, + settings_patch: None, + tab_note_overrides: None, + }); + assert_eq!( + stale.unwrap_err().error_code, + EditorCommitErrorCode::RevisionConflict + ); + assert_eq!(store.writer.persist_count(), persist_count); + assert_eq!(store.snapshot(), before_failure); + store.flush_and_shutdown().unwrap(); drop(store); let _ = std::fs::remove_dir_all(dir); } - // 프론트 저장 순서 재현: positions(groupId 포함) 먼저 → layerGroups 정의 나중 - // 부분 저장 중간 정규화가 신규 그룹 참조를 지우면 그룹 생성이 통째로 깨진다 #[test] - fn group_ids_survive_positions_saved_before_group_definitions() { + fn failed_preset_style_commit_keeps_revision_and_key_runtime_unchanged() { + let dir = test_directory("preset-runtime-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let state = AppState::initialize(store).unwrap(); + let before_store = state.store.snapshot(); + let before_disk = std::fs::read(dir.join("store.json")).unwrap(); + let before_mode = state.keyboard.current_mode(); + let before_counters = state.snapshot_key_counters(); + let mut keys = before_store.keys.clone(); + keys.get_mut("4key").unwrap()[0] = "PresetKey".to_string(); + + state.store.writer.fail_next_persist(); + let result = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("preset_failure_test".to_string()), + &[EditorField::Keys], + move |data| { + data.keys = keys; + data.selected_key_type = "5key".to_string(); + data.background_color = "#123456".to_string(); + Ok(()) + }, + ); + + assert!(result.is_err()); + assert_eq!(state.store.snapshot(), before_store); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), before_disk); + assert_eq!(state.keyboard.current_mode(), before_mode); + assert_eq!(state.snapshot_key_counters(), before_counters); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); + } + + // 그룹 정의와 참조는 같은 editor 트랜잭션에서 함께 저장 + #[test] + fn group_ids_and_definitions_commit_atomically() { let dir = test_directory("group-order-test"); std::fs::create_dir_all(&dir).unwrap(); let store = AppStore::initialize_in_dir(&dir).unwrap(); @@ -2153,8 +3158,6 @@ mod tests { list[0].group_id = Some("new-group".to_string()); list[1].group_id = Some("new-group".to_string()); } - store.update_positions(positions).unwrap(); - let mut groups = crate::models::LayerGroups::new(); groups.insert( "4key".to_string(), @@ -2163,7 +3166,24 @@ mod tests { name: "New Group".to_string(), }], ); - store.update_layer_groups(groups).unwrap(); + let rejected_positions = positions.clone(); + assert!( + legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { + data.key_positions = rejected_positions; + },) + .is_err() + ); + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("group_test".to_string()), + &[EditorField::KeyPositions, EditorField::LayerGroups], + move |data| { + data.key_positions = positions; + data.layer_groups = groups; + Ok(()) + }, + ) + .unwrap(); let data = store.snapshot(); assert_eq!( @@ -2750,11 +3770,12 @@ mod tests { assert!(image_url.contains("%20")); let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .update(|data| { - data.key_positions.get_mut("4key").unwrap()[0].active_image = Some(image_url); - }) - .unwrap(); + let mut positions = store.snapshot().key_positions; + positions.get_mut("4key").unwrap()[0].active_image = Some(image_url); + legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { + data.key_positions = positions; + }) + .unwrap(); store.cleanup_orphan_assets_now().unwrap(); @@ -2773,12 +3794,13 @@ mod tests { std::fs::write(&image_path, b"image").unwrap(); let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .update(|data| { - data.key_positions.get_mut("4key").unwrap()[0].active_image = - Some("file:///tmp/broken%ZZ.png".to_string()); - }) - .unwrap(); + let mut positions = store.snapshot().key_positions; + positions.get_mut("4key").unwrap()[0].active_image = + Some("file:///tmp/broken%ZZ.png".to_string()); + legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { + data.key_positions = positions; + }) + .unwrap(); store.cleanup_orphan_assets_now().unwrap(); @@ -2831,13 +3853,14 @@ mod tests { } let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .update(|data| { - let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; - position.active_image = Some(r"\unresolved-image.png".to_string()); - position.sound_path = Some("C:unresolved-sound.wav".to_string()); - }) - .unwrap(); + let mut positions = store.snapshot().key_positions; + let position = &mut positions.get_mut("4key").unwrap()[0]; + position.active_image = Some(r"\unresolved-image.png".to_string()); + position.sound_path = Some("C:unresolved-sound.wav".to_string()); + legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { + data.key_positions = positions; + }) + .unwrap(); store.cleanup_orphan_assets_now().unwrap(); @@ -3170,6 +4193,123 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn invalid_pre_migration_backup_path_aborts_before_store_rewrite() { + let dir = test_directory("pre-migration-backup-path-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store_path = dir.join("store.json"); + let original = br#"{ + "hardwareAcceleration": true, + "alwaysOnTop": true, + "overlayLocked": false, + "noteEffect": false, + "selectedKeyType": "4key", + "angleMode": "d3d11", + "language": "ko", + "laboratoryEnabled": false, + "backgroundColor": "transparent", + "useCustomCss": false, + "overlayResizeAnchor": "top-left", + "overlayBounds": null, + "overlayLastContentTopOffset": null, + "soundLibrary": { + "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } + } +}"#; + std::fs::write(&store_path, original).unwrap(); + std::fs::create_dir(dir.join("store.json.pre-migration.bak")).unwrap(); + + let result = AppStore::initialize_in_dir(&dir); + + if let Ok(store) = result { + let _ = store.flush_and_shutdown(); + panic!("store initialization unexpectedly rewrote data without a valid backup"); + } + assert_eq!(std::fs::read(&store_path).unwrap(), original); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn malformed_existing_pre_migration_backup_aborts_before_store_rewrite() { + let dir = test_directory("pre-migration-backup-content-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store_path = dir.join("store.json"); + let original = br#"{ + "hardwareAcceleration": true, + "alwaysOnTop": true, + "overlayLocked": false, + "noteEffect": false, + "selectedKeyType": "4key", + "angleMode": "d3d11", + "language": "ko", + "laboratoryEnabled": false, + "backgroundColor": "transparent", + "useCustomCss": false, + "overlayResizeAnchor": "top-left", + "overlayBounds": null, + "overlayLastContentTopOffset": null, + "soundLibrary": { + "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } + } +}"#; + std::fs::write(&store_path, original).unwrap(); + std::fs::write(dir.join("store.json.pre-migration.bak"), b"not-json").unwrap(); + + let result = AppStore::initialize_in_dir(&dir); + + if let Ok(store) = result { + let _ = store.flush_and_shutdown(); + panic!("store initialization unexpectedly rewrote data without a valid backup"); + } + assert_eq!(std::fs::read(&store_path).unwrap(), original); + + let _ = std::fs::remove_dir_all(dir); + } + + #[cfg(unix)] + #[test] + fn symlink_pre_migration_backup_aborts_before_store_rewrite() { + use std::os::unix::fs::symlink; + + let dir = test_directory("pre-migration-backup-symlink-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store_path = dir.join("store.json"); + let original = br#"{ + "hardwareAcceleration": true, + "alwaysOnTop": true, + "overlayLocked": false, + "noteEffect": false, + "selectedKeyType": "4key", + "angleMode": "d3d11", + "language": "ko", + "laboratoryEnabled": false, + "backgroundColor": "transparent", + "useCustomCss": false, + "overlayResizeAnchor": "top-left", + "overlayBounds": null, + "overlayLastContentTopOffset": null, + "soundLibrary": { + "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } + } +}"#; + std::fs::write(&store_path, original).unwrap(); + let target = dir.join("unrelated.json"); + std::fs::write(&target, b"{}").unwrap(); + symlink(&target, dir.join("store.json.pre-migration.bak")).unwrap(); + + let result = AppStore::initialize_in_dir(&dir); + + if let Ok(store) = result { + let _ = store.flush_and_shutdown(); + panic!("store initialization unexpectedly followed a backup symlink"); + } + assert_eq!(std::fs::read(&store_path).unwrap(), original); + assert_eq!(std::fs::read(&target).unwrap(), b"{}"); + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn interrupted_sound_deletion_restores_files_still_referenced_by_store() { let dir = test_directory("sound-delete-startup-restore-test"); From 1ebf97b47ebf48ac64dad888b47f734d5bc42372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 17:20:48 +0900 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20=ED=86=B5=EA=B3=84=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=ED=83=80=EC=9E=85=EC=9D=84=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EA=B3=BC=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=96=91=EC=AA=BD=20?= =?UTF-8?q?4=EC=A2=85=EC=9C=BC=EB=A1=9C=20=EC=A0=95=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models/mod.rs | 22 ++++++++++- .../single/SingleSelectionPanel.tsx | 22 ++++------- src/types/key/statItems.test.ts | 37 +++++++++++++++++++ src/types/key/statItems.ts | 14 ++++++- 4 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 src/types/key/statItems.test.ts diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 1a650de6..bff87a41 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -391,9 +391,11 @@ impl Default for KeyPosition { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "camelCase")] pub enum StatType { Kps, + KpsAvg, + KpsMax, Total, } @@ -1951,7 +1953,23 @@ pub struct SettingsPatch { #[cfg(test)] mod tests { - use super::{FadePosition, KeyPosition, NoteColor, NoteSettings}; + use super::{FadePosition, KeyPosition, NoteColor, NoteSettings, StatType}; + + #[test] + fn stat_type_wire_values_round_trip() { + for (stat_type, wire_value) in [ + (StatType::Kps, "kps"), + (StatType::KpsAvg, "kpsAvg"), + (StatType::KpsMax, "kpsMax"), + (StatType::Total, "total"), + ] { + let serialized = serde_json::to_value(&stat_type).unwrap(); + assert_eq!(serialized, wire_value); + + let restored: StatType = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored, stat_type); + } + } // 필수 필드만 채운 최소 KeyPosition JSON. 시각 px 필드는 호출부에서 주입 fn key_position_json(visual_px: &str) -> String { diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index 22d6cad0..7b312ec8 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -1,7 +1,12 @@ /* eslint-disable react-hooks/refs */ import React, { useEffect, useRef, useState } from 'react'; import type { ImageFit, KeyPosition } from '@src/types/key/keys'; -import type { StatItemPosition, StatItemType } from '@src/types/key/statItems'; +import { + STAT_BASE_OPTIONS, + STAT_KPS_OPTIONS, + type StatItemPosition, + type StatItemType, +} from '@src/types/key/statItems'; import type { GraphItemPosition, GraphItemType, @@ -1391,17 +1396,6 @@ export const SingleKeyStatPanel: React.FC = ({ ? [TABS.STYLE, TABS.COUNTER] : [TABS.STYLE, TABS.NOTE, TABS.COUNTER]; - const statBaseOptions = [ - { label: 'KPS', value: 'kps' }, - { label: 'Total', value: 'total' }, - ]; - - const statKpsOptions = [ - { label: 'KPS', value: 'kps' }, - { label: 'AVG', value: 'kpsAvg' }, - { label: 'MAX', value: 'kpsMax' }, - ]; - const resolvedStatType = (singleStatPosition?.statType as StatItemType) || 'kps'; const statBaseValue = resolvedStatType === 'total' ? 'total' : 'kps'; @@ -1445,7 +1439,7 @@ export const SingleKeyStatPanel: React.FC = ({ <> { if (value === 'total') { @@ -1465,7 +1459,7 @@ export const SingleKeyStatPanel: React.FC = ({ {statBaseValue === 'kps' ? ( handleStatUpdate({ diff --git a/src/types/key/statItems.test.ts b/src/types/key/statItems.test.ts new file mode 100644 index 00000000..231b2b1d --- /dev/null +++ b/src/types/key/statItems.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + STAT_BASE_OPTIONS, + STAT_ITEM_TYPES, + STAT_KPS_OPTIONS, +} from './statItems'; + +const toLowerCamelCase = (value: string) => + `${value.charAt(0).toLowerCase()}${value.slice(1)}`; + +describe('statistics item wire contract', () => { + it('keeps every UI option accepted by the Rust store', () => { + const uiTypes = new Set( + [...STAT_BASE_OPTIONS, ...STAT_KPS_OPTIONS].map(({ value }) => value), + ); + expect(uiTypes).toEqual(new Set(STAT_ITEM_TYPES)); + + const rustSource = readFileSync( + resolve(process.cwd(), 'src-tauri/src/models/mod.rs'), + 'utf8', + ); + const declaration = rustSource.match( + /#\[serde\(rename_all = "camelCase"\)\]\s*pub enum StatType\s*\{([^}]*)\}/, + ); + expect(declaration, 'Rust StatType declaration').not.toBeNull(); + + const rustTypes = new Set( + [...declaration![1].matchAll(/^\s*([A-Z][A-Za-z0-9]*),\s*$/gm)].map( + ([, variant]) => toLowerCamelCase(variant), + ), + ); + expect(rustTypes).toEqual(uiTypes); + }); +}); diff --git a/src/types/key/statItems.ts b/src/types/key/statItems.ts index 9e858dcd..1b3c4242 100644 --- a/src/types/key/statItems.ts +++ b/src/types/key/statItems.ts @@ -1,7 +1,19 @@ import type { KeyPosition } from '@src/types/key/keys'; // Built-in statistics item types (plugin 없이 사용되는 통계 요소) -export type StatItemType = 'kps' | 'kpsAvg' | 'kpsMax' | 'total'; +export const STAT_ITEM_TYPES = ['kps', 'kpsAvg', 'kpsMax', 'total'] as const; +export type StatItemType = (typeof STAT_ITEM_TYPES)[number]; + +export const STAT_BASE_OPTIONS = [ + { label: 'KPS', value: 'kps' }, + { label: 'Total', value: 'total' }, +] satisfies Array<{ label: string; value: StatItemType }>; + +export const STAT_KPS_OPTIONS = [ + { label: 'KPS', value: 'kps' }, + { label: 'AVG', value: 'kpsAvg' }, + { label: 'MAX', value: 'kpsMax' }, +] satisfies Array<{ label: string; value: StatItemType }>; // KeyPosition 기반으로 스타일을 공유하고, statType으로 값 종류만 구분 export type StatItemPosition = KeyPosition & { statType: StatItemType }; From 96e2e04046ae9b5b27d08f5355fad57f6fce6c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 17:21:02 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=A0=80=EC=9E=A5=EC=9D=84=20revision=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=BD=94=EB=94=94=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/api/dmnoteApi.ts | 2 + src/renderer/api/modules/appApi.test.ts | 89 ++ src/renderer/api/modules/appApi.ts | 62 +- .../api/modules/editorAdapters.test.ts | 128 +++ src/renderer/api/modules/editorApi.ts | 58 + src/renderer/api/modules/itemsApi.ts | 38 +- src/renderer/api/modules/keysApi.ts | 31 +- .../components/main/Grid/PropertiesPanel.tsx | 68 +- .../batch/useBatchHandlers.test.ts | 120 ++ .../PropertiesPanel/batch/useBatchHandlers.ts | 142 ++- .../PropertiesPanel/layer/useLayerActions.ts | 372 +++--- .../Grid/PropertiesPanel/layer/useLayerDnD.ts | 109 +- .../components/main/Grid/core/Grid.tsx | 24 +- .../components/shared/PluginElement.tsx | 62 +- .../shared/PluginElementsRenderer.tsx | 3 +- .../runtime/editorCompatibilityQueue.ts | 13 + .../editor/runtime/editorCoordinator.test.ts | 962 ++++++++++++++++ .../editor/runtime/editorCoordinator.ts | 1000 +++++++++++++++++ src/renderer/editor/runtime/editorSnapshot.ts | 254 ++--- .../editor/runtime/editorStateCoordinator.ts | 85 ++ src/renderer/editor/runtime/index.ts | 25 + src/renderer/editor/runtime/persistState.ts | 5 +- .../hooks/Grid/useGridCanvasActions.ts | 21 - src/renderer/hooks/Grid/useGridResize.ts | 59 +- src/renderer/hooks/Grid/useGridSelection.ts | 289 +---- src/renderer/hooks/app/useAppBootstrap.ts | 289 +++-- .../hooks/app/useCustomJsInjection.ts | 5 +- .../app/usePluginDisplayElementsResponder.ts | 11 +- src/renderer/hooks/useKeyManager.ts | 59 +- src/renderer/locales/en.json | 8 + src/renderer/locales/ko.json | 8 + src/renderer/locales/ru.json | 8 + src/renderer/locales/zh-Hant.json | 8 + src/renderer/locales/zh-cn.json | 8 + .../plugins/runtime/api/defineElement.ts | 28 +- .../plugins/runtime/api/defineSettings.ts | 3 +- .../plugins/runtime/customJsRuntime.ts | 19 +- .../stores/data/useHistoryStore.test.ts | 16 + src/renderer/stores/data/useHistoryStore.ts | 11 +- .../plugin/usePluginDisplayElementStore.ts | 13 +- src/renderer/utils/grid/groupActions.ts | 56 +- .../utils/plugin/bridgeMessages.test.ts | 69 ++ src/renderer/utils/plugin/bridgeMessages.ts | 29 + src/renderer/windows/main/App.tsx | 2 +- src/renderer/windows/main/index.tsx | 34 +- src/renderer/windows/obs/index.tsx | 9 +- src/renderer/windows/overlay/App.tsx | 67 +- src/renderer/windows/overlay/index.tsx | 14 +- src/types/app.ts | 1 + src/types/editor.ts | 479 ++++++++ src/types/plugin/api.ts | 11 + 51 files changed, 3981 insertions(+), 1305 deletions(-) create mode 100644 src/renderer/api/modules/appApi.test.ts create mode 100644 src/renderer/api/modules/editorAdapters.test.ts create mode 100644 src/renderer/api/modules/editorApi.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.test.ts create mode 100644 src/renderer/editor/runtime/editorCompatibilityQueue.ts create mode 100644 src/renderer/editor/runtime/editorCoordinator.test.ts create mode 100644 src/renderer/editor/runtime/editorCoordinator.ts create mode 100644 src/renderer/editor/runtime/editorStateCoordinator.ts create mode 100644 src/renderer/utils/plugin/bridgeMessages.test.ts create mode 100644 src/renderer/utils/plugin/bridgeMessages.ts create mode 100644 src/types/editor.ts diff --git a/src/renderer/api/dmnoteApi.ts b/src/renderer/api/dmnoteApi.ts index 2b7b991f..383a5626 100644 --- a/src/renderer/api/dmnoteApi.ts +++ b/src/renderer/api/dmnoteApi.ts @@ -23,6 +23,7 @@ import { i18nApi } from './modules/i18nApi'; import { statsApi } from './modules/statsApi'; import { pluginApi } from './modules/pluginApi'; import { uiApi } from './modules/uiApi'; +import { editorApi } from './modules/editorApi'; // shared.ts is imported for its side-effects (locale init + settings listener) import './modules/shared'; @@ -52,6 +53,7 @@ const api: DMNoteAPI = { stats: statsApi, plugin: pluginApi, ui: uiApi as unknown as DMNoteAPI['ui'], + editor: editorApi, }; if (typeof window !== 'undefined') { diff --git a/src/renderer/api/modules/appApi.test.ts b/src/renderer/api/modules/appApi.test.ts new file mode 100644 index 00000000..10265f02 --- /dev/null +++ b/src/renderer/api/modules/appApi.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { invoke, coordinatorFlush } = vi.hoisted(() => ({ + invoke: vi.fn(), + coordinatorFlush: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ invoke })); +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { flush: coordinatorFlush }, +})); + +import { appApi, runAfterEditorFlush } from './appApi'; + +describe('runAfterEditorFlush', () => { + afterEach(() => { + vi.restoreAllMocks(); + invoke.mockReset(); + coordinatorFlush.mockReset(); + delete window.__dmn_window_type; + }); + + it('runs a lifecycle action only after pending editor state is saved', async () => { + const order: string[] = []; + const flush = vi.fn(async () => { + order.push('flush'); + }); + const run = vi.fn(async () => { + order.push('quit'); + return 'done'; + }); + + const result = await runAfterEditorFlush('app quit', run, async () => ({ + editorCoordinator: { flush }, + })); + + expect(result).toBe('done'); + expect(order).toEqual(['flush', 'quit']); + }); + + it('cancels the lifecycle action when saving fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const error = new Error('disk unavailable'); + const run = vi.fn(async () => undefined); + + await expect( + runAfterEditorFlush('window close', run, async () => ({ + editorCoordinator: { + flush: vi.fn().mockRejectedValue(error), + }, + })), + ).rejects.toBe(error); + + expect(run).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + '[Editor] window close canceled because saving failed', + error, + ); + }); + + it('restarts through the all-window handshake after a successful update', async () => { + window.__dmn_window_type = 'main'; + coordinatorFlush.mockResolvedValue(undefined); + const updateResult = { + previousVersion: '1.6.1', + updatedTo: '1.7.0', + downloadUrl: 'https://example.test/update', + }; + invoke.mockResolvedValueOnce(updateResult).mockResolvedValueOnce(undefined); + + await expect(appApi.autoUpdate('1.7.0')).resolves.toEqual(updateResult); + + expect(coordinatorFlush).toHaveBeenCalledOnce(); + expect(invoke.mock.calls).toEqual([ + ['app_auto_update', { tag: '1.7.0' }], + ['app_restart'], + ]); + }); + + it('rejects automatic update outside the main window', async () => { + window.__dmn_window_type = 'overlay'; + + await expect(appApi.autoUpdate('1.7.0')).rejects.toThrow( + 'automatic update is only available in the main window', + ); + expect(coordinatorFlush).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/api/modules/appApi.ts b/src/renderer/api/modules/appApi.ts index b72632ff..59d4d5f9 100644 --- a/src/renderer/api/modules/appApi.ts +++ b/src/renderer/api/modules/appApi.ts @@ -3,19 +3,69 @@ import { invoke } from '@tauri-apps/api/core'; import type { AppAutoUpdateResult } from '@src/types/plugin/api'; import type { BootstrapPayload } from '@src/types/app'; +interface FlushableEditorCoordinator { + flush(): Promise; +} + +export type EditorCoordinatorLoader = () => Promise<{ + editorCoordinator: FlushableEditorCoordinator; +}>; + +const loadEditorCoordinator: EditorCoordinatorLoader = () => + import('@src/renderer/editor/runtime/editorStateCoordinator'); + +export async function runAfterEditorFlush( + action: string, + run: () => Promise, + load: EditorCoordinatorLoader = loadEditorCoordinator, +): Promise { + try { + const { editorCoordinator } = await load(); + await editorCoordinator.flush(); + } catch (error) { + console.error(`[Editor] ${action} canceled because saving failed`, error); + throw error; + } + return run(); +} + +export const acknowledgeLifecycleAfterEditorFlush = (handshakeId: string) => + runAfterEditorFlush('app lifecycle', () => + invoke('app_quit_after_editor_flush', { handshakeId }), + ); + +export const cancelLifecycleEditorFlush = (handshakeId: string) => + invoke('app_cancel_editor_flush', { handshakeId }); + export const appApi = { bootstrap: () => invoke('app_bootstrap'), - autoUpdate: (tag: string) => - invoke('app_auto_update', { tag }), + autoUpdate: (tag: string) => { + if (window.__dmn_window_type !== 'main') { + return Promise.reject( + new Error('automatic update is only available in the main window'), + ); + } + return runAfterEditorFlush('app update', async () => { + const result = await invoke('app_auto_update', { + tag, + }); + await invoke('app_restart'); + return result; + }); + }, openExternal: (url: string) => invoke('app_open_external', { url }), - restart: () => invoke('app_restart'), - quit: () => invoke('app_quit'), + restart: () => + runAfterEditorFlush('app restart', () => invoke('app_restart')), + quit: () => runAfterEditorFlush('app quit', () => invoke('app_quit')), }; export const windowApi = { - type: window.__dmn_window_type as 'main' | 'overlay', + get type() { + return window.__dmn_window_type as 'main' | 'overlay'; + }, minimize: () => invoke('window_minimize'), - close: () => invoke('window_close'), + close: () => + runAfterEditorFlush('window close', () => invoke('window_close')), showMain: () => invoke('window_show_main'), openDevtoolsAll: () => invoke('window_open_devtools_all'), }; diff --git a/src/renderer/api/modules/editorAdapters.test.ts b/src/renderer/api/modules/editorAdapters.test.ts new file mode 100644 index 00000000..fe44a4b0 --- /dev/null +++ b/src/renderer/api/modules/editorAdapters.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; + +const { commitPatch } = vi.hoisted(() => ({ + commitPatch: vi.fn(), +})); + +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { commitPatch }, +})); + +import { keysApi } from './keysApi'; +import { + graphItemsApi, + knobItemsApi, + layerGroupsApi, + statItemsApi, +} from './itemsApi'; + +import type { EditorDocumentV1 } from '@src/types/editor'; +import type { KeyPositions } from '@src/types/key/keys'; + +const document: EditorDocumentV1 = { + schemaVersion: 1, + keys: { '4key': ['A'] }, + keyPositions: { '4key': [createDefaultKeyPosition()] }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: { '4key': [{ id: 'group-1', name: 'Group 1' }] }, +}; + +describe('editor API compatibility adapters', () => { + beforeEach(() => { + commitPatch.mockReset(); + commitPatch.mockResolvedValue(structuredClone(document)); + }); + + it('routes key writers through editor_commit and preserves return shapes', async () => { + const mappings = { '4key': ['A'] }; + const positions = { + '4key': [createDefaultKeyPosition()], + } as KeyPositions; + + await expect(keysApi.update(mappings)).resolves.toEqual(document.keys); + await expect(keysApi.updatePositions(positions)).resolves.toEqual( + document.keyPositions, + ); + await expect( + keysApi.updateWithPositions(mappings, positions), + ).resolves.toEqual({ + keys: document.keys, + positions: document.keyPositions, + }); + + expect(commitPatch.mock.calls).toEqual([ + [{ schemaVersion: 1, keys: mappings }], + [{ schemaVersion: 1, keyPositions: positions }], + [ + { + schemaVersion: 1, + keys: mappings, + keyPositions: positions, + }, + ], + ]); + }); + + it('routes every item collection writer and returns its canonical field', async () => { + await expect(statItemsApi.updatePositions({})).resolves.toEqual({}); + await expect(graphItemsApi.updatePositions({})).resolves.toEqual({}); + await expect(knobItemsApi.updatePositions({})).resolves.toEqual({}); + await expect(layerGroupsApi.update(document.layerGroups)).resolves.toEqual( + document.layerGroups, + ); + + expect(commitPatch.mock.calls).toEqual([ + [{ schemaVersion: 1, statPositions: {} }], + [{ schemaVersion: 1, graphPositions: {} }], + [{ schemaVersion: 1, knobPositions: {} }], + [{ schemaVersion: 1, layerGroups: document.layerGroups }], + ]); + }); + + it('preserves each legacy caller return value when writes are queued together', async () => { + const first = { '4key': ['B'] }; + const second = { '4key': ['C'] }; + let resolveFirst!: (value: EditorDocumentV1) => void; + const firstCommit = new Promise((resolve) => { + resolveFirst = resolve; + }); + commitPatch + .mockImplementationOnce(() => firstCommit) + .mockResolvedValueOnce({ ...document, keys: second }); + + const firstWrite = keysApi.update(first); + const secondWrite = keysApi.update(second); + + await vi.waitFor(() => expect(commitPatch).toHaveBeenCalledOnce()); + resolveFirst({ ...document, keys: first }); + await expect(firstWrite).resolves.toEqual(first); + await vi.waitFor(() => expect(commitPatch).toHaveBeenCalledTimes(2)); + await expect(secondWrite).resolves.toEqual(second); + }); + + it('does not reject an acknowledged legacy write when the next queued write fails', async () => { + const first = { '4key': ['B'] }; + const second = { '4key': ['C'] }; + const secondError = new Error('second write failed'); + let resolveFirst!: (value: EditorDocumentV1) => void; + const firstCommit = new Promise((resolve) => { + resolveFirst = resolve; + }); + commitPatch + .mockImplementationOnce(() => firstCommit) + .mockRejectedValueOnce(secondError); + + const firstWrite = keysApi.update(first); + const secondWrite = keysApi.update(second); + const secondOutcome = secondWrite.catch((error) => error); + + await vi.waitFor(() => expect(commitPatch).toHaveBeenCalledOnce()); + resolveFirst({ ...document, keys: first }); + await expect(firstWrite).resolves.toEqual(first); + await vi.waitFor(() => expect(commitPatch).toHaveBeenCalledTimes(2)); + await expect(secondOutcome).resolves.toBe(secondError); + }); +}); diff --git a/src/renderer/api/modules/editorApi.ts b/src/renderer/api/modules/editorApi.ts new file mode 100644 index 00000000..708d803b --- /dev/null +++ b/src/renderer/api/modules/editorApi.ts @@ -0,0 +1,58 @@ +import { invoke } from '@tauri-apps/api/core'; +import { subscribe } from './shared'; + +import { + assertEditorCommitResult, + assertEditorCommittedEvent, + assertEditorDocument, + assertEditorGetResult, + assertSafeEditorRevision, +} from '@src/types/editor'; +import { EditorReadOnlyError } from '@src/renderer/editor/runtime/editorCoordinator'; + +import type { + EditorCommitRequest, + EditorCommitResult, + EditorCommittedV1, + EditorGetResult, + EditorHistoryRestoreRequest, +} from '@src/types/editor'; + +export const restoreEditorHistory = async ( + request: EditorHistoryRestoreRequest, +): Promise => { + if (window.__dmn_runtime === 'obs') throw new EditorReadOnlyError(); + assertSafeEditorRevision(request.baseRevision, 'history baseRevision'); + assertEditorDocument(request.document, 'history document'); + const result = await invoke('editor_history_restore', { + request, + }); + assertEditorCommitResult(result); + return result; +}; + +export const editorApi = { + get: async (): Promise => { + const result = await invoke('editor_get'); + assertEditorGetResult(result); + return result; + }, + commit: async (request: EditorCommitRequest): Promise => { + if (window.__dmn_runtime === 'obs') throw new EditorReadOnlyError(); + assertSafeEditorRevision(request.baseRevision, 'baseRevision'); + const result = await invoke('editor_commit', { + request, + }); + assertEditorCommitResult(result); + return result; + }, + onCommitted: (listener: (event: EditorCommittedV1) => void) => + subscribe('editor:committed', (event) => { + try { + assertEditorCommittedEvent(event); + listener(event); + } catch (error) { + console.error('[Editor] Ignored an invalid committed event', error); + } + }), +}; diff --git a/src/renderer/api/modules/itemsApi.ts b/src/renderer/api/modules/itemsApi.ts index b1cece01..498e098a 100644 --- a/src/renderer/api/modules/itemsApi.ts +++ b/src/renderer/api/modules/itemsApi.ts @@ -1,5 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; +import { enqueueEditorCompatibilityWrite } from '@src/renderer/editor/runtime/editorCompatibilityQueue'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; @@ -9,7 +11,14 @@ import type { LayerGroups } from '@src/types/layerGroups'; export const statItemsApi = { getPositions: () => invoke('stat_positions_get'), updatePositions: (positions: StatItemPositions) => - invoke('stat_positions_update', { positions }), + enqueueEditorCompatibilityWrite( + () => + editorCoordinator.commitPatch({ + schemaVersion: 1, + statPositions: positions, + }), + () => structuredClone(positions), + ), onPositionsChanged: (listener: (positions: StatItemPositions) => void) => subscribe('statPositions:changed', listener), }; @@ -17,7 +26,14 @@ export const statItemsApi = { export const graphItemsApi = { getPositions: () => invoke('graph_positions_get'), updatePositions: (positions: GraphItemPositions) => - invoke('graph_positions_update', { positions }), + enqueueEditorCompatibilityWrite( + () => + editorCoordinator.commitPatch({ + schemaVersion: 1, + graphPositions: positions, + }), + () => structuredClone(positions), + ), onPositionsChanged: (listener: (positions: GraphItemPositions) => void) => subscribe('graphPositions:changed', listener), }; @@ -25,7 +41,14 @@ export const graphItemsApi = { export const knobItemsApi = { getPositions: () => invoke('knob_positions_get'), updatePositions: (positions: KnobItemPositions) => - invoke('knob_positions_update', { positions }), + enqueueEditorCompatibilityWrite( + () => + editorCoordinator.commitPatch({ + schemaVersion: 1, + knobPositions: positions, + }), + () => structuredClone(positions), + ), onPositionsChanged: (listener: (positions: KnobItemPositions) => void) => subscribe('knobPositions:changed', listener), }; @@ -33,7 +56,14 @@ export const knobItemsApi = { export const layerGroupsApi = { get: () => invoke('layer_groups_get'), update: (groups: LayerGroups) => - invoke('layer_groups_update', { groups }), + enqueueEditorCompatibilityWrite( + () => + editorCoordinator.commitPatch({ + schemaVersion: 1, + layerGroups: groups, + }), + () => structuredClone(groups), + ), onChanged: (listener: (groups: LayerGroups) => void) => subscribe('layerGroups:changed', listener), }; diff --git a/src/renderer/api/modules/keysApi.ts b/src/renderer/api/modules/keysApi.ts index 43a76091..985ba656 100644 --- a/src/renderer/api/modules/keysApi.ts +++ b/src/renderer/api/modules/keysApi.ts @@ -1,6 +1,8 @@ import { invoke } from '@tauri-apps/api/core'; 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 type { KeyCounterUpdate, @@ -26,16 +28,33 @@ export const keysApi = { get: () => invoke('keys_get'), getCounters: () => invoke('keys_get_counters'), update: (mappings: KeyMappings) => - invoke('keys_update', { mappings }), - // keys와 positions를 단일 store 트랜잭션으로 커밋 — 한쪽만 저장된 채 중단되는 불일치 방지 + enqueueEditorCompatibilityWrite( + () => editorCoordinator.commitPatch({ schemaVersion: 1, keys: mappings }), + () => structuredClone(mappings), + ), updateWithPositions: (mappings: KeyMappings, positions: KeyPositions) => - invoke<{ keys: KeyMappings; positions: KeyPositions }>( - 'keys_update_with_positions', - { mappings, positions }, + enqueueEditorCompatibilityWrite( + () => + editorCoordinator.commitPatch({ + schemaVersion: 1, + keys: mappings, + keyPositions: positions, + }), + () => ({ + keys: structuredClone(mappings), + positions: structuredClone(positions), + }), ), getPositions: () => invoke('positions_get'), updatePositions: (positions: KeyPositions) => - invoke('positions_update', { positions }), + enqueueEditorCompatibilityWrite( + () => + editorCoordinator.commitPatch({ + schemaVersion: 1, + keyPositions: positions, + }), + () => structuredClone(positions), + ), setMode: (mode: string) => invoke('keys_set_mode', { mode }), resetAll: () => invoke('keys_reset_all'), diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 7da397f1..2c833d72 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -93,7 +93,7 @@ interface PropertiesPanelProps { onKeyUpdate: (data: Partial & { index: number }) => void; onKeyBatchUpdate?: ( updates: Array<{ index: number } & Partial>, - options?: { skipHistory?: boolean }, + options?: { skipHistory?: boolean; deferSave?: boolean }, ) => void; onKeyPreview?: (index: number, updates: Partial) => void; onKeyBatchPreview?: ( @@ -661,9 +661,6 @@ const PropertiesPanel: React.FC = ({ useKnobItemStore.getState().setPositions(nextPositions); try { await window.api.knobItems.updatePositions(nextPositions); - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: nextPositions, - }); } finally { useKnobItemStore.getState().setLocalUpdateInProgress(false); } @@ -1283,13 +1280,6 @@ const PropertiesPanel: React.FC = ({ .finally(() => { useStatItemStore.getState().setLocalUpdateInProgress(false); }); - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: nextPositions, - }); - } catch { - // ignore - } }; const handleStatPreview = ( @@ -1362,7 +1352,7 @@ const PropertiesPanel: React.FC = ({ const handleStatBatchUpdate = ( updates: Array<{ index: number } & Partial>, - options?: { skipHistory?: boolean }, + options?: { skipHistory?: boolean; deferSave?: boolean }, ) => { if (updates.length === 0) return; @@ -1400,6 +1390,11 @@ const PropertiesPanel: React.FC = ({ }); const nextPositions = { ...current, [mode]: nextList }; + if (options?.deferSave) { + useStatItemStore.getState().setPositions(nextPositions); + return; + } + useStatItemStore.getState().setLocalUpdateInProgress(true); useStatItemStore.getState().setPositions(nextPositions); window.api.statItems @@ -1410,13 +1405,6 @@ const PropertiesPanel: React.FC = ({ .finally(() => { useStatItemStore.getState().setLocalUpdateInProgress(false); }); - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: nextPositions, - }); - } catch { - // ignore - } }; const handleGraphUpdate = ( @@ -1458,13 +1446,6 @@ const PropertiesPanel: React.FC = ({ .finally(() => { useGraphItemStore.getState().setLocalUpdateInProgress(false); }); - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: nextPositions, - }); - } catch { - // ignore - } }; const handleKnobUpdate = ( @@ -1506,13 +1487,6 @@ const PropertiesPanel: React.FC = ({ .finally(() => { useKnobItemStore.getState().setLocalUpdateInProgress(false); }); - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: nextPositions, - }); - } catch { - // ignore - } }; const handleKnobPreview = ( @@ -1583,7 +1557,7 @@ const PropertiesPanel: React.FC = ({ const handleKnobBatchUpdate = ( updates: Array<{ index: number } & Partial>, - options?: { skipHistory?: boolean }, + options?: { skipHistory?: boolean; deferSave?: boolean }, ) => { if (updates.length === 0) return; @@ -1621,6 +1595,11 @@ const PropertiesPanel: React.FC = ({ }); const nextPositions = { ...current, [mode]: nextList }; + if (options?.deferSave) { + useKnobItemStore.getState().setPositions(nextPositions); + return; + } + useKnobItemStore.getState().setLocalUpdateInProgress(true); useKnobItemStore.getState().setPositions(nextPositions); window.api.knobItems @@ -1631,13 +1610,6 @@ const PropertiesPanel: React.FC = ({ .finally(() => { useKnobItemStore.getState().setLocalUpdateInProgress(false); }); - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: nextPositions, - }); - } catch { - // ignore - } }; const handleGraphPreview = ( @@ -1710,7 +1682,7 @@ const PropertiesPanel: React.FC = ({ const handleGraphBatchUpdate = ( updates: Array<{ index: number } & Partial>, - options?: { skipHistory?: boolean }, + options?: { skipHistory?: boolean; deferSave?: boolean }, ) => { if (updates.length === 0) return; @@ -1748,6 +1720,11 @@ const PropertiesPanel: React.FC = ({ }); const nextPositions = { ...current, [mode]: nextList }; + if (options?.deferSave) { + useGraphItemStore.getState().setPositions(nextPositions); + return; + } + useGraphItemStore.getState().setLocalUpdateInProgress(true); useGraphItemStore.getState().setPositions(nextPositions); window.api.graphItems @@ -1758,13 +1735,6 @@ const PropertiesPanel: React.FC = ({ .finally(() => { useGraphItemStore.getState().setLocalUpdateInProgress(false); }); - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: nextPositions, - }); - } catch { - // ignore - } }; // 크기 변경 완료 (blur 시 저장) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.test.ts new file mode 100644 index 00000000..9c45265b --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; + +import { useBatchHandlers } from './useBatchHandlers'; + +import type { KeyPosition } from '@src/types/key/keys'; +import type { StatItemPosition } from '@src/types/key/statItems'; +import type { GraphItemPosition } from '@src/types/key/graphItems'; +import type { KnobItemPosition } from '@src/types/key/knobs'; + +const { commitPatchMock } = vi.hoisted(() => ({ + commitPatchMock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { commitPatch: commitPatchMock }, +})); + +type BatchOptions = { skipHistory?: boolean; deferSave?: boolean }; + +const position = (width = 40) => + ({ dx: 0, dy: 0, width, height: 40 } as KeyPosition); + +const applyUpdates = ( + values: T[], + updates: Array<{ index: number } & Partial>, +): T[] => + values.map((value, index) => { + const update = updates.find((candidate) => candidate.index === index); + if (!update) return value; + const { index: _index, ...changes } = update; + return { ...value, ...changes }; + }); + +describe('useBatchHandlers mixed collection commits', () => { + beforeEach(() => { + commitPatchMock.mockClear(); + useKeyStore.setState({ + selectedKeyType: '4key', + positions: { '4key': [position()] }, + }); + useStatItemStore.setState({ + positions: { '4key': [position() as StatItemPosition] }, + }); + useGraphItemStore.setState({ + positions: { '4key': [position() as GraphItemPosition] }, + }); + useKnobItemStore.setState({ + positions: { '4key': [position() as KnobItemPosition] }, + }); + }); + + it('saves a four-type resize as one editor patch', () => { + const deferredOptions: BatchOptions[] = []; + const handlers = useBatchHandlers({ + selectedKeyLikeElements: [ + { type: 'key', index: 0 }, + { type: 'stat', index: 0 }, + { type: 'graph', index: 0 }, + { type: 'knob', index: 0 }, + ], + keyPositions: useKeyStore.getState().positions, + statPositions: useStatItemStore.getState().positions, + graphPositions: useGraphItemStore.getState().positions, + knobPositions: useKnobItemStore.getState().positions, + selectedKeyType: '4key', + onKeyUpdate: vi.fn(), + onKeyBatchUpdate: (updates, options) => { + deferredOptions.push(options ?? {}); + const current = useKeyStore.getState().positions; + useKeyStore.getState().setPositions({ + ...current, + '4key': applyUpdates(current['4key'], updates), + }); + }, + onStatUpdate: vi.fn(), + onStatBatchUpdate: (updates, options) => { + deferredOptions.push(options ?? {}); + const current = useStatItemStore.getState().positions; + useStatItemStore.getState().setPositions({ + ...current, + '4key': applyUpdates(current['4key'], updates), + }); + }, + onGraphBatchUpdate: (updates, options) => { + deferredOptions.push(options ?? {}); + const current = useGraphItemStore.getState().positions; + useGraphItemStore.getState().setPositions({ + ...current, + '4key': applyUpdates(current['4key'], updates), + }); + }, + onKnobBatchUpdate: (updates, options) => { + deferredOptions.push(options ?? {}); + const current = useKnobItemStore.getState().positions; + useKnobItemStore.getState().setPositions({ + ...current, + '4key': applyUpdates(current['4key'], updates), + }); + }, + }); + + handlers.handleBatchResize('width', 88); + + expect(deferredOptions).toHaveLength(4); + expect(deferredOptions.every((options) => options.deferSave)).toBe(true); + expect(commitPatchMock).toHaveBeenCalledOnce(); + expect(commitPatchMock).toHaveBeenCalledWith({ + schemaVersion: 1, + keyPositions: { '4key': [expect.objectContaining({ width: 88 })] }, + statPositions: { '4key': [expect.objectContaining({ width: 88 })] }, + graphPositions: { '4key': [expect.objectContaining({ width: 88 })] }, + knobPositions: { '4key': [expect.objectContaining({ width: 88 })] }, + }); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts index 12f045e8..6136cdac 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts @@ -7,6 +7,13 @@ import { normalizeCounterSettings } from '@src/types/key/keys'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; + +import type { EditorPatchV1 } from '@src/types/editor'; const DEFAULT_ACTIVE_BACKGROUND_COLOR = 'rgba(121, 121, 121, 0.9)'; const DEFAULT_ACTIVE_BORDER_COLOR = 'rgba(255, 255, 255, 0.9)'; @@ -39,6 +46,7 @@ type KeyLikeBatchUpdate = { type BatchCommitOptions = { skipHistory?: boolean; + deferSave?: boolean; }; interface SpacingAxisPlan { @@ -724,26 +732,47 @@ export function useBatchHandlers({ if (keyUpdates.length > 0) { dispatchKeyUpdates(keyUpdates, 'commit', { skipHistory: hasSavedHistory, + deferSave: true, }); hasSavedHistory = true; } if (statUpdates.length > 0) { dispatchStatUpdates(statUpdates, 'commit', { skipHistory: hasSavedHistory, + deferSave: true, }); hasSavedHistory = true; } if (graphUpdates.length > 0) { dispatchGraphUpdates(graphUpdates, 'commit', { skipHistory: hasSavedHistory, + deferSave: true, }); hasSavedHistory = true; } if (knobUpdates.length > 0) { dispatchKnobUpdates(knobUpdates, 'commit', { skipHistory: hasSavedHistory, + deferSave: true, }); } + + const patch: EditorPatchV1 = { schemaVersion: 1 }; + if (keyUpdates.length > 0) { + patch.keyPositions = useKeyStore.getState().positions; + } + if (statUpdates.length > 0) { + patch.statPositions = useStatItemStore.getState().positions; + } + if (graphUpdates.length > 0) { + patch.graphPositions = useGraphItemStore.getState().positions; + } + if (knobUpdates.length > 0) { + patch.knobPositions = useKnobItemStore.getState().positions; + } + void editorCoordinator.commitPatch(patch).catch((error) => { + console.error('Failed to commit combined batch update', error); + }); }; // 스타일 변경 (프리뷰) @@ -832,18 +861,6 @@ export function useBatchHandlers({ index: number; } & Partial; }); - let hasSavedHistory = false; - if (keyUpdates.length > 0) { - dispatchKeyUpdates( - keyUpdates as Array<{ index: number } & Partial>, - 'commit', - { - skipHistory: hasSavedHistory, - }, - ); - hasSavedHistory = true; - } - const statUpdates = selectedStats .filter((el) => el.index !== undefined) .map((el) => { @@ -888,25 +905,11 @@ export function useBatchHandlers({ index: number; } & Partial; }); - if (statUpdates.length > 0) { - dispatchStatUpdates(statUpdates, 'commit', { - skipHistory: hasSavedHistory, - }); - hasSavedHistory = true; - } - const graphUpdates = selectedGraphs .filter((el) => el.index !== undefined) .map((el) => ({ index: el.index!, [property]: value })) as Array< { index: number } & Partial >; - if (graphUpdates.length > 0) { - dispatchGraphUpdates(graphUpdates, 'commit', { - skipHistory: hasSavedHistory, - }); - hasSavedHistory = true; - } - const currentKnobs = knobPositions?.[selectedKeyType] || []; const knobUpdates = selectedKnobs .filter((el) => el.index !== undefined) @@ -943,11 +946,21 @@ export function useBatchHandlers({ index: number; } & Partial; }); - if (knobUpdates.length > 0) { - dispatchKnobUpdates(knobUpdates, 'commit', { - skipHistory: hasSavedHistory, - }); - } + dispatchKeyLikeUpdates([ + ...keyUpdates.map((update) => ({ type: 'key' as const, ...update })), + ...statUpdates.map((update) => ({ + type: 'stat' as const, + ...update, + })), + ...graphUpdates.map((update) => ({ + type: 'graph' as const, + ...update, + })), + ...knobUpdates.map((update) => ({ + type: 'knob' as const, + ...update, + })), + ] as KeyLikeBatchUpdate[]); }; // 정렬 핸들러 @@ -1195,54 +1208,41 @@ export function useBatchHandlers({ // 일괄 크기 변경 핸들러 const handleBatchResize = (dimension: 'width' | 'height', value: number) => { - let hasSavedHistory = false; - const keyUpdates = selectedKeys .filter((el) => el.index !== undefined) .map((el) => ({ index: el.index!, [dimension]: value })) as Array< { index: number } & Partial >; - if (keyUpdates.length > 0) { - dispatchKeyUpdates(keyUpdates, 'commit', { - skipHistory: hasSavedHistory, - }); - hasSavedHistory = true; - } - const statUpdates = selectedStats .filter((el) => el.index !== undefined) .map((el) => ({ index: el.index!, [dimension]: value })) as Array< { index: number } & Partial >; - if (statUpdates.length > 0) { - dispatchStatUpdates(statUpdates, 'commit', { - skipHistory: hasSavedHistory, - }); - hasSavedHistory = true; - } - const graphUpdates = selectedGraphs .filter((el) => el.index !== undefined) .map((el) => ({ index: el.index!, [dimension]: value })) as Array< { index: number } & Partial >; - if (graphUpdates.length > 0) { - dispatchGraphUpdates(graphUpdates, 'commit', { - skipHistory: hasSavedHistory, - }); - hasSavedHistory = true; - } - const knobUpdates = selectedKnobs .filter((el) => el.index !== undefined) .map((el) => ({ index: el.index!, [dimension]: value })) as Array< { index: number } & Partial >; - if (knobUpdates.length > 0) { - dispatchKnobUpdates(knobUpdates, 'commit', { - skipHistory: hasSavedHistory, - }); - } + dispatchKeyLikeUpdates([ + ...keyUpdates.map((update) => ({ type: 'key' as const, ...update })), + ...statUpdates.map((update) => ({ + type: 'stat' as const, + ...update, + })), + ...graphUpdates.map((update) => ({ + type: 'graph' as const, + ...update, + })), + ...knobUpdates.map((update) => ({ + type: 'knob' as const, + ...update, + })), + ] as KeyLikeBatchUpdate[]); }; // 카운터 업데이트 핸들러 @@ -1260,18 +1260,6 @@ export function useBatchHandlers({ (update): update is { index: number; counter: KeyCounterSettings } => update !== null, ); - let hasSavedHistory = false; - if (keyUpdates.length > 0) { - dispatchKeyUpdates( - keyUpdates as Array<{ index: number } & Partial>, - 'commit', - { - skipHistory: hasSavedHistory, - }, - ); - hasSavedHistory = true; - } - const statUpdates = selectedStats .filter((el) => el.index !== undefined) .map((el) => { @@ -1291,11 +1279,13 @@ export function useBatchHandlers({ counter: KeyCounterSettings; } & Partial => update !== null, ); - if (statUpdates.length > 0) { - dispatchStatUpdates(statUpdates, 'commit', { - skipHistory: hasSavedHistory, - }); - } + dispatchKeyLikeUpdates([ + ...keyUpdates.map((update) => ({ type: 'key' as const, ...update })), + ...statUpdates.map((update) => ({ + type: 'stat' as const, + ...update, + })), + ] as KeyLikeBatchUpdate[]); }; // 노트 색상 변경 (프리뷰) - 키 요소만 diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts index c8de8958..a321f12a 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts @@ -18,6 +18,9 @@ import { normalizeLayerGroupsForMode, resolveSingleGroupIdFromSelection, } from '@utils/layerGroupUtils'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { stableStringify } from '@utils/core/stableStringify'; +import type { EditorPatchV1 } from '@src/types/editor'; import type { LayerGroups } from '@src/types/layerGroups'; import type { LayerGroupDef } from '@src/types/layerGroups'; import type { ListItem } from '@components/main/Modal/ListPopup'; @@ -59,6 +62,10 @@ function pushCurrentStateToHistory(layerGroups?: LayerGroups) { }); } +function hasChanged(current: unknown, next: unknown) { + return stableStringify(current) !== stableStringify(next); +} + // ============================================================================ // 훅 // ============================================================================ @@ -158,13 +165,6 @@ export function useLayerActions({ } finally { useStatItemStore.getState().setLocalUpdateInProgress(false); } - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: updatedPositions, - }); - } catch { - // ignore - } return; } @@ -191,13 +191,6 @@ export function useLayerActions({ } finally { useGraphItemStore.getState().setLocalUpdateInProgress(false); } - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: updatedPositions, - }); - } catch { - // ignore - } return; } @@ -224,13 +217,6 @@ export function useLayerActions({ } finally { useKnobItemStore.getState().setLocalUpdateInProgress(false); } - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: updatedPositions, - }); - } catch { - // ignore - } return; } @@ -264,6 +250,8 @@ export function useLayerActions({ pushCurrentStateToHistory(); + const changes: EditorPatchV1 = { schemaVersion: 1 }; + // 키 positions const keyChildren = children.filter( (c) => c.type === 'key' && c.index !== undefined, @@ -281,13 +269,8 @@ export function useLayerActions({ } }); updatedPositions[selectedKeyType] = modePositions; - useKeyStore.getState().setLocalUpdateInProgress(true); useKeyStore.getState().setPositions(updatedPositions); - try { - await window.api.keys.updatePositions(updatedPositions); - } finally { - useKeyStore.getState().setLocalUpdateInProgress(false); - } + changes.keyPositions = updatedPositions; } // 통계 positions @@ -309,13 +292,8 @@ export function useLayerActions({ ...current, [selectedKeyType]: modePositions, }; - useStatItemStore.getState().setLocalUpdateInProgress(true); useStatItemStore.getState().setPositions(updatedPositions); - try { - await window.api.statItems.updatePositions(updatedPositions); - } finally { - useStatItemStore.getState().setLocalUpdateInProgress(false); - } + changes.statPositions = updatedPositions; } // 그래프 positions @@ -337,13 +315,8 @@ export function useLayerActions({ ...current, [selectedKeyType]: modePositions, }; - useGraphItemStore.getState().setLocalUpdateInProgress(true); useGraphItemStore.getState().setPositions(updatedPositions); - try { - await window.api.graphItems.updatePositions(updatedPositions); - } finally { - useGraphItemStore.getState().setLocalUpdateInProgress(false); - } + changes.graphPositions = updatedPositions; } // 노브 positions @@ -365,19 +338,15 @@ export function useLayerActions({ ...current, [selectedKeyType]: modePositions, }; - useKnobItemStore.getState().setLocalUpdateInProgress(true); useKnobItemStore.getState().setPositions(updatedPositions); + changes.knobPositions = updatedPositions; + } + + if (Object.keys(changes).length > 1) { try { - await window.api.knobItems.updatePositions(updatedPositions); - } finally { - useKnobItemStore.getState().setLocalUpdateInProgress(false); - } - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: updatedPositions, - }); - } catch { - // ignore + await editorCoordinator.commitPatch(changes); + } catch (error) { + console.error('Failed to toggle group visibility', error); } } @@ -483,13 +452,6 @@ export function useLayerActions({ } finally { useKnobItemStore.getState().setLocalUpdateInProgress(false); } - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: updatedPositions, - }); - } catch { - // ignore - } } }; @@ -602,11 +564,6 @@ export function useLayerActions({ }); } - useKeyStore.getState().setLocalUpdateInProgress(true); - useStatItemStore.getState().setLocalUpdateInProgress(true); - useGraphItemStore.getState().setLocalUpdateInProgress(true); - useKnobItemStore.getState().setLocalUpdateInProgress(true); - useKeyStore.getState().setPositions(normalized.keyPositions); useStatItemStore.getState().setPositions(normalized.statPositions); useGraphItemStore.getState().setPositions(normalized.graphPositions); @@ -616,20 +573,24 @@ export function useLayerActions({ useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); } - try { - await window.api.keys.updatePositions(normalized.keyPositions); - await window.api.statItems.updatePositions(normalized.statPositions); - await window.api.graphItems.updatePositions(normalized.graphPositions); - await window.api.knobItems.updatePositions(normalized.knobPositions); - if (shouldPersistGroups) { - await window.api.layerGroups.update(normalized.layerGroups); - } - } finally { - useKeyStore.getState().setLocalUpdateInProgress(false); - useStatItemStore.getState().setLocalUpdateInProgress(false); - useGraphItemStore.getState().setLocalUpdateInProgress(false); - useKnobItemStore.getState().setLocalUpdateInProgress(false); + const changes: EditorPatchV1 = { schemaVersion: 1 }; + if (hasChanged(pos, normalized.keyPositions)) { + changes.keyPositions = normalized.keyPositions; + } + if (hasChanged(currentStatPositions, normalized.statPositions)) { + changes.statPositions = normalized.statPositions; + } + if (hasChanged(currentGraphPositions, normalized.graphPositions)) { + changes.graphPositions = normalized.graphPositions; + } + if (hasChanged(currentKnobPositions, normalized.knobPositions)) { + changes.knobPositions = normalized.knobPositions; } + if (hasChanged(storeLayerGroups, normalized.layerGroups)) { + changes.layerGroups = normalized.layerGroups; + } + + await editorCoordinator.commitPatch(changes); return true; }; @@ -894,138 +855,63 @@ export function useLayerActions({ onSelectionFromPanel?.(); useGridSelectionStore.getState().clearSelection(); - // 키 삭제 - if (keysToDelete.length > 0) { - const { keyMappings: km, positions: pos } = useKeyStore.getState(); - const mapping = km[selectedKeyType] || []; - const posArray = pos[selectedKeyType] || []; - - const deleteSet = new Set(keysToDelete); - - const updatedMappings = { - ...km, - [selectedKeyType]: mapping.filter( - (_, index) => !deleteSet.has(index), - ), - }; - - const updatedPositions = { - ...pos, - [selectedKeyType]: posArray.filter( - (_, index) => !deleteSet.has(index), - ), - }; - - useKeyStore.getState().setLocalUpdateInProgress(true); - useKeyStore - .getState() - .setKeyMappingsAndPositions(updatedMappings, updatedPositions); - - try { - await window.api.keys.update(updatedMappings); - await window.api.keys.updatePositions(updatedPositions); - } catch (error) { - console.error('Failed to delete keys', error); - } finally { - useKeyStore.getState().setLocalUpdateInProgress(false); - } - } - - // 통계 삭제 - if (statsToDelete.length > 0) { - const current = useStatItemStore.getState().positions; - const posArray = current[selectedKeyType] || []; - const deleteSet = new Set(statsToDelete); - - const updatedPositions = { - ...current, - [selectedKeyType]: posArray.filter( - (_, index) => !deleteSet.has(index), - ), - }; - - useStatItemStore.getState().setLocalUpdateInProgress(true); - useStatItemStore.getState().setPositions(updatedPositions); - try { - await window.api.statItems.updatePositions(updatedPositions); - } catch (error) { - console.error('Failed to delete stat items', error); - } finally { - useStatItemStore.getState().setLocalUpdateInProgress(false); - } - - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: updatedPositions, - }); - } catch { - // ignore - } - } - - // 그래프 삭제 - if (graphsToDelete.length > 0) { - const current = useGraphItemStore.getState().positions; - const posArray = current[selectedKeyType] || []; - const deleteSet = new Set(graphsToDelete); - - const updatedPositions = { - ...current, - [selectedKeyType]: posArray.filter( - (_, index) => !deleteSet.has(index), - ), - }; - - useGraphItemStore.getState().setLocalUpdateInProgress(true); - useGraphItemStore.getState().setPositions(updatedPositions); - try { - await window.api.graphItems.updatePositions(updatedPositions); - } catch (error) { - console.error('Failed to delete graph items', error); - } finally { - useGraphItemStore.getState().setLocalUpdateInProgress(false); - } - - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: updatedPositions, - }); - } catch { - // ignore - } - } - - // 노브 삭제 - if (knobsToDelete.length > 0) { - const current = useKnobItemStore.getState().positions; - const posArray = current[selectedKeyType] || []; - const deleteSet = new Set(knobsToDelete); - - const updatedPositions = { - ...current, - [selectedKeyType]: posArray.filter( - (_, index) => !deleteSet.has(index), - ), - }; - - useKnobItemStore.getState().setLocalUpdateInProgress(true); - useKnobItemStore.getState().setPositions(updatedPositions); - try { - await window.api.knobItems.updatePositions(updatedPositions); - } catch (error) { - console.error('Failed to delete knob items', error); - } finally { - useKnobItemStore.getState().setLocalUpdateInProgress(false); - } - - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: updatedPositions, - }); - } catch { - // ignore - } - } + const { keyMappings: currentMappings, positions: currentKeyPositions } = + useKeyStore.getState(); + const currentStatPositions = useStatItemStore.getState().positions; + const currentGraphPositions = useGraphItemStore.getState().positions; + const currentKnobPositions = useKnobItemStore.getState().positions; + const currentLayerGroups = useLayerGroupStore.getState().layerGroups; + + const keyDeleteSet = new Set(keysToDelete); + const statDeleteSet = new Set(statsToDelete); + const graphDeleteSet = new Set(graphsToDelete); + const knobDeleteSet = new Set(knobsToDelete); + + const nextMappings = + keysToDelete.length > 0 + ? { + ...currentMappings, + [selectedKeyType]: ( + currentMappings[selectedKeyType] || [] + ).filter((_, index) => !keyDeleteSet.has(index)), + } + : currentMappings; + const nextKeyPositions = + keysToDelete.length > 0 + ? { + ...currentKeyPositions, + [selectedKeyType]: ( + currentKeyPositions[selectedKeyType] || [] + ).filter((_, index) => !keyDeleteSet.has(index)), + } + : currentKeyPositions; + const nextStatPositions = + statsToDelete.length > 0 + ? { + ...currentStatPositions, + [selectedKeyType]: ( + currentStatPositions[selectedKeyType] || [] + ).filter((_, index) => !statDeleteSet.has(index)), + } + : currentStatPositions; + const nextGraphPositions = + graphsToDelete.length > 0 + ? { + ...currentGraphPositions, + [selectedKeyType]: ( + currentGraphPositions[selectedKeyType] || [] + ).filter((_, index) => !graphDeleteSet.has(index)), + } + : currentGraphPositions; + const nextKnobPositions = + knobsToDelete.length > 0 + ? { + ...currentKnobPositions, + [selectedKeyType]: ( + currentKnobPositions[selectedKeyType] || [] + ).filter((_, index) => !knobDeleteSet.has(index)), + } + : currentKnobPositions; // 플러그인 삭제 if (pluginsToDelete.length > 0) { @@ -1040,40 +926,58 @@ export function useLayerActions({ const normalized = normalizeLayerGroupsForMode({ mode: selectedKeyType, - keyPositions: useKeyStore.getState().positions, - statPositions: useStatItemStore.getState().positions, - graphPositions: useGraphItemStore.getState().positions, - knobPositions: useKnobItemStore.getState().positions, - layerGroups: useLayerGroupStore.getState().layerGroups, + keyPositions: nextKeyPositions, + statPositions: nextStatPositions, + graphPositions: nextGraphPositions, + knobPositions: nextKnobPositions, + layerGroups: currentLayerGroups, }); - if (normalized.positionsChanged || normalized.groupsChanged) { - useKeyStore.getState().setLocalUpdateInProgress(true); - useStatItemStore.getState().setLocalUpdateInProgress(true); - useGraphItemStore.getState().setLocalUpdateInProgress(true); - useKnobItemStore.getState().setLocalUpdateInProgress(true); + if (hasChanged(currentMappings, nextMappings)) { + useKeyStore + .getState() + .setKeyMappingsAndPositions(nextMappings, normalized.keyPositions); + } else if (hasChanged(currentKeyPositions, normalized.keyPositions)) { useKeyStore.getState().setPositions(normalized.keyPositions); + } + if (hasChanged(currentStatPositions, normalized.statPositions)) { useStatItemStore.getState().setPositions(normalized.statPositions); + } + if (hasChanged(currentGraphPositions, normalized.graphPositions)) { useGraphItemStore.getState().setPositions(normalized.graphPositions); + } + if (hasChanged(currentKnobPositions, normalized.knobPositions)) { useKnobItemStore.getState().setPositions(normalized.knobPositions); - if (normalized.groupsChanged) { - useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); - } + } + if (hasChanged(currentLayerGroups, normalized.layerGroups)) { + useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); + } + + const changes: EditorPatchV1 = { schemaVersion: 1 }; + if (hasChanged(currentMappings, nextMappings)) { + changes.keys = nextMappings; + changes.keyPositions = normalized.keyPositions; + } else if (hasChanged(currentKeyPositions, normalized.keyPositions)) { + changes.keyPositions = normalized.keyPositions; + } + if (hasChanged(currentStatPositions, normalized.statPositions)) { + changes.statPositions = normalized.statPositions; + } + if (hasChanged(currentGraphPositions, normalized.graphPositions)) { + changes.graphPositions = normalized.graphPositions; + } + if (hasChanged(currentKnobPositions, normalized.knobPositions)) { + changes.knobPositions = normalized.knobPositions; + } + if (hasChanged(currentLayerGroups, normalized.layerGroups)) { + changes.layerGroups = normalized.layerGroups; + } + + if (Object.keys(changes).length > 1) { try { - await window.api.keys.updatePositions(normalized.keyPositions); - await window.api.statItems.updatePositions(normalized.statPositions); - await window.api.graphItems.updatePositions( - normalized.graphPositions, - ); - await window.api.knobItems.updatePositions(normalized.knobPositions); - if (normalized.groupsChanged) { - await window.api.layerGroups.update(normalized.layerGroups); - } - } finally { - useKeyStore.getState().setLocalUpdateInProgress(false); - useStatItemStore.getState().setLocalUpdateInProgress(false); - useGraphItemStore.getState().setLocalUpdateInProgress(false); - useKnobItemStore.getState().setLocalUpdateInProgress(false); + await editorCoordinator.commitPatch(changes); + } catch (error) { + console.error('Failed to delete layers', error); } } } diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts index 39a52258..b1ad6329 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts @@ -13,6 +13,8 @@ import { useHistoryStore } from '@stores/data/useHistoryStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { normalizeLayerGroupsForMode } from '@utils/layerGroupUtils'; +import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; import type { LayerItem, DisplayItem } from '../types'; // ============================================================================ @@ -31,48 +33,11 @@ interface UseLayerDnDParams { // 오버레이 동기화 헬퍼 // ============================================================================ -function syncOverlayPositions( - keyPositions: ReturnType['positions'], - statPositions: ReturnType['positions'], - graphPositions: ReturnType['positions'], - knobPositions: ReturnType['positions'], -) { - try { - window.api.bridge.sendTo('overlay', 'positions:sync', { - positions: keyPositions, - }); - } catch { - // ignore - } - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: statPositions, - }); - } catch { - // ignore - } - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: graphPositions, - }); - } catch { - // ignore - } - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: knobPositions, - }); - } catch { - // ignore - } - try { - const pluginEls = usePluginDisplayElementStore.getState().elements; - window.api.bridge.sendTo('overlay', 'plugin:displayElements:sync', { - elements: pluginEls, - }); - } catch { - // ignore - } +function syncOverlayPluginElements() { + const pluginEls = usePluginDisplayElementStore.getState().elements; + sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { + elements: pluginEls, + }); } // ============================================================================ @@ -561,34 +526,20 @@ export function useLayerDnD({ useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); } - // 백엔드/오버레이 동기화 - useKeyStore.getState().setLocalUpdateInProgress(true); - useStatItemStore.getState().setLocalUpdateInProgress(true); - useGraphItemStore.getState().setLocalUpdateInProgress(true); - useKnobItemStore.getState().setLocalUpdateInProgress(true); try { - await window.api.keys.updatePositions(normalized.keyPositions); - await window.api.statItems.updatePositions(normalized.statPositions); - await window.api.graphItems.updatePositions(normalized.graphPositions); - await window.api.knobItems.updatePositions(normalized.knobPositions); - if (normalized.groupsChanged) { - await window.api.layerGroups.update(normalized.layerGroups); - } + await editorCoordinator.commitPatch({ + schemaVersion: 1, + keyPositions: normalized.keyPositions, + statPositions: normalized.statPositions, + graphPositions: normalized.graphPositions, + knobPositions: normalized.knobPositions, + layerGroups: normalized.layerGroups, + }); } catch (error) { console.error('Failed to reorder layers', error); - } finally { - useKeyStore.getState().setLocalUpdateInProgress(false); - useStatItemStore.getState().setLocalUpdateInProgress(false); - useGraphItemStore.getState().setLocalUpdateInProgress(false); - useKnobItemStore.getState().setLocalUpdateInProgress(false); } - syncOverlayPositions( - normalized.keyPositions, - normalized.statPositions, - normalized.graphPositions, - normalized.knobPositions, - ); + syncOverlayPluginElements(); }; // ────────────────────────────────────────────────────────────────────────── @@ -740,31 +691,19 @@ export function useLayerDnD({ updatedKnobPositions[selectedKeyType] = currentKnobModePositions; useKnobItemStore.getState().setPositions(updatedKnobPositions); - // 백엔드 동기화 - useKeyStore.getState().setLocalUpdateInProgress(true); - useStatItemStore.getState().setLocalUpdateInProgress(true); - useGraphItemStore.getState().setLocalUpdateInProgress(true); - useKnobItemStore.getState().setLocalUpdateInProgress(true); try { - await window.api.keys.updatePositions(updatedPositions); - await window.api.statItems.updatePositions(updatedStatPositions); - await window.api.graphItems.updatePositions(updatedGraphPositions); - await window.api.knobItems.updatePositions(updatedKnobPositions); + await editorCoordinator.commitPatch({ + schemaVersion: 1, + keyPositions: updatedPositions, + statPositions: updatedStatPositions, + graphPositions: updatedGraphPositions, + knobPositions: updatedKnobPositions, + }); } catch (error) { console.error('Failed to reorder group', error); - } finally { - useKeyStore.getState().setLocalUpdateInProgress(false); - useStatItemStore.getState().setLocalUpdateInProgress(false); - useGraphItemStore.getState().setLocalUpdateInProgress(false); - useKnobItemStore.getState().setLocalUpdateInProgress(false); } - syncOverlayPositions( - updatedPositions, - updatedStatPositions, - updatedGraphPositions, - updatedKnobPositions, - ); + syncOverlayPluginElements(); }; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 6d609431..07b2722c 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -1069,13 +1069,6 @@ const Grid = ({ window.api.statItems.updatePositions(nextPositions).catch((error) => { console.error('Failed to update stat item positions', error); }); - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: nextPositions, - }); - } catch { - // ignore - } }; return items.map((position: StatItemPosition, index: number) => ( @@ -1173,13 +1166,6 @@ const Grid = ({ window.api.graphItems.updatePositions(nextPositions).catch((error) => { console.error('Failed to update graph item positions', error); }); - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: nextPositions, - }); - } catch { - // ignore - } }; return items.map((position, index) => ( @@ -1273,13 +1259,6 @@ const Grid = ({ window.api.knobItems.updatePositions(nextPositions).catch((error) => { console.error('Failed to update knob item positions', error); }); - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: nextPositions, - }); - } catch { - // ignore - } }; return items.map((position, index) => ( @@ -1469,7 +1448,7 @@ const Grid = ({ }} data-grid-container className="relative w-full h-full bg-panel rounded-[0px] overflow-hidden" - style={{ backgroundColor: color === 'transparent' ? '#141417' : color }} + style={color === 'transparent' ? undefined : { backgroundColor: color }} onContextMenu={(e) => { if (duplicateState) { setDuplicateState(null); @@ -1547,7 +1526,6 @@ const Grid = ({ zoom={zoom} panX={panX} panY={panY} - color={color === 'transparent' ? '#141417' : color} /> {/* 줌/팬이 적용되는 콘텐츠 영역 */}
= ({ lastSentMenuState[key] = value; } if (Object.keys(changed).length === 0) return; - try { - void Promise.resolve( - window.api?.bridge?.sendTo( - 'main', - 'plugin:displayElement:syncMenuState', - { fullId: element.fullId, state: changed }, - ), - ).catch((error) => { - console.error('[PluginElement] Failed to sync menu state:', error); - }); - } catch (error) { - console.error('[PluginElement] Failed to sync menu state:', error); - } + sendBridgeMessageBestEffort( + 'main', + 'plugin:displayElement:syncMenuState', + { fullId: element.fullId, state: changed }, + ); }; // OBS WS 재연결 시 단절 중 유실됐을 수 있는 제어 상태 재송신 @@ -1176,16 +1169,14 @@ export const PluginElement: React.FC = ({ // 오버레이 로컬 스토어 업데이트 updateElement(element.fullId, { resizeAnchor: anchor }); // 메인 윈도우로 동기화 (브릿지 통해) - if (window.api?.bridge) { - window.api.bridge.sendTo( - 'main', - 'plugin:displayElement:updateAnchor', - { - fullId: element.fullId, - resizeAnchor: anchor, - }, - ); - } + sendBridgeMessageBestEffort( + 'main', + 'plugin:displayElement:updateAnchor', + { + fullId: element.fullId, + resizeAnchor: anchor, + }, + ); }, getAnchor: (): ElementResizeAnchor => { const currentElement = usePluginDisplayElementStore @@ -1549,22 +1540,15 @@ export const PluginElement: React.FC = ({ get: (_target, prop: string | symbol) => { if (typeof prop !== 'string') return undefined; return (...args: unknown[]) => { - try { - window.api?.bridge?.sendTo( - 'overlay', - 'plugin:displayElement:invokeAction', - { - elementId, - action: prop, - args, - }, - ); - } catch (error) { - console.error( - '[PluginElement] Failed to invoke exposed action', - error, - ); - } + sendBridgeMessageBestEffort( + 'overlay', + 'plugin:displayElement:invokeAction', + { + elementId, + action: prop, + args, + }, + ); }; }, }, diff --git a/src/renderer/components/shared/PluginElementsRenderer.tsx b/src/renderer/components/shared/PluginElementsRenderer.tsx index d3f1d6a0..d698cd3e 100644 --- a/src/renderer/components/shared/PluginElementsRenderer.tsx +++ b/src/renderer/components/shared/PluginElementsRenderer.tsx @@ -7,6 +7,7 @@ import type { ElementResizeAnchor, } from '@src/types/plugin/api'; import { invokeExposedAction } from '@utils/displayElementActions'; +import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; import { obsApi } from '@api/modules/obsApi'; import { useGridSelectionStore, @@ -125,7 +126,7 @@ export const PluginElementsRenderer: React.FC = ({ }); const requestElementsFromMain = () => { - window.api.bridge.sendTo('main', 'plugin:displayElements:request', {}); + sendBridgeMessageBestEffort('main', 'plugin:displayElements:request', {}); }; // 오버레이 초기 로드 시 메인에 현재 상태 요청 diff --git a/src/renderer/editor/runtime/editorCompatibilityQueue.ts b/src/renderer/editor/runtime/editorCompatibilityQueue.ts new file mode 100644 index 00000000..8ac2030a --- /dev/null +++ b/src/renderer/editor/runtime/editorCompatibilityQueue.ts @@ -0,0 +1,13 @@ +let compatibilityWriteQueue: Promise = Promise.resolve(); + +export const enqueueEditorCompatibilityWrite = ( + write: () => Promise, + result: () => T, +): Promise => { + const operation = compatibilityWriteQueue.then(write); + compatibilityWriteQueue = operation.then( + () => undefined, + () => undefined, + ); + return operation.then(result); +}; diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts new file mode 100644 index 00000000..16e0d5b5 --- /dev/null +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -0,0 +1,962 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; + +import { + EditorProtocolError, + assertEditorGetResult, + assertSafeEditorRevision, +} from '@src/types/editor'; + +import { + EditorReadOnlyError, + applyEditorPatch, + createEditorCoordinator, + createEditorPatch, + getChangedEditorFields, +} from './editorCoordinator'; + +import type { + EditorCommitError, + EditorCommitRequest, + EditorCommitResult, + EditorCommittedV1, + EditorDocumentV1, + EditorGetResult, +} from '@src/types/editor'; +import type { + EditorApplyReason, + EditorCoordinatorOptions, + EditorCoordinatorTransport, + EditorReadyUnsubscribe, +} from './editorCoordinator'; + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(reason?: unknown): void; +} + +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +}; + +const makeDocument = (key = 'A'): EditorDocumentV1 => ({ + schemaVersion: 1, + keys: { '4key': [key] }, + keyPositions: { '4key': [createDefaultKeyPosition()] }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: {}, +}); + +const withGroups = ( + document: EditorDocumentV1, + id: string, +): EditorDocumentV1 => ({ + ...structuredClone(document), + layerGroups: { '4key': [{ id, name: id }] }, +}); + +const revisionConflict = (): EditorCommitError => ({ + errorCode: 'REVISION_CONFLICT', + message: 'revision conflict', + details: { currentRevision: 1 }, + retryable: true, +}); + +const ioError = (): EditorCommitError => ({ + errorCode: 'IO_ERROR', + message: 'disk unavailable', + retryable: true, +}); + +const validationError = (): EditorCommitError => ({ + errorCode: 'VALIDATION_FAILED', + message: 'invalid editor document', + details: { validationCode: 'INVALID_STAT_TYPE' }, + retryable: false, +}); + +class FakeTransport implements EditorCoordinatorTransport { + canonical: EditorGetResult; + readonly getMock = vi.fn<() => Promise>(); + readonly commitMock = + vi.fn<(request: EditorCommitRequest) => Promise>(); + private listener: ((event: EditorCommittedV1) => void) | null = null; + + constructor(document: EditorDocumentV1, revision = 0) { + this.canonical = { revision, document: structuredClone(document) }; + this.getMock.mockImplementation(async () => + structuredClone(this.canonical), + ); + this.commitMock.mockImplementation(async (request) => { + const before = this.canonical.document; + const next = applyEditorPatch(before, request.changes); + const changedFields = getChangedEditorFields(before, next); + if (changedFields.length > 0) this.canonical.revision += 1; + this.canonical.document = next; + return { + revision: this.canonical.revision, + changedFields, + }; + }); + } + + get(): Promise { + return this.getMock(); + } + + commit(request: EditorCommitRequest): Promise { + return this.commitMock(request); + } + + onCommitted( + listener: (event: EditorCommittedV1) => void, + ): EditorReadyUnsubscribe { + this.listener = listener; + return Object.assign( + () => { + this.listener = null; + }, + { ready: Promise.resolve() }, + ); + } + + emit(event: EditorCommittedV1): void { + this.listener?.(structuredClone(event)); + } +} + +const eventFor = ( + revision: number, + mutationId: string, + base: EditorDocumentV1, + next: EditorDocumentV1, +): EditorCommittedV1 => ({ + schemaVersion: 1, + revision, + mutationId, + origin: 'legacy:test', + changedFields: getChangedEditorFields(base, next), + patch: createEditorPatch(base, next), +}); + +const createHarness = ( + initial: EditorDocumentV1, + options: Partial< + Pick< + EditorCoordinatorOptions, + 'focusTarget' | 'visibilityTarget' | 'readOnly' + > + > = {}, +) => { + const transport = new FakeTransport(initial); + let local = structuredClone(initial); + const applications: Array<{ + document: EditorDocumentV1; + reason: EditorApplyReason; + }> = []; + let mutationSequence = 0; + const coordinator = createEditorCoordinator({ + transport, + readDocument: () => structuredClone(local), + applyDocument: (document, reason) => { + local = structuredClone(document); + applications.push({ document: structuredClone(document), reason }); + }, + createMutationId: () => + `00000000-0000-4000-8000-${String(++mutationSequence).padStart(12, '0')}`, + focusTarget: options.focusTarget ?? null, + visibilityTarget: options.visibilityTarget ?? null, + readOnly: options.readOnly, + }); + + return { + coordinator, + transport, + applications, + getLocal: () => structuredClone(local), + setLocal: (document: EditorDocumentV1) => { + local = structuredClone(document); + }, + }; +}; + +describe('editor document helpers', () => { + it('creates and applies a top-level collection patch', () => { + const base = makeDocument(); + const next = withGroups({ ...base, keys: { '4key': ['B'] } }, 'group-1'); + + const patch = createEditorPatch(base, next); + + expect(patch).toEqual({ + schemaVersion: 1, + keys: { '4key': ['B'] }, + layerGroups: { '4key': [{ id: 'group-1', name: 'group-1' }] }, + }); + expect(applyEditorPatch(base, patch)).toEqual(next); + }); + + it('rejects revisions that cannot be represented safely in JavaScript', () => { + expect(() => assertSafeEditorRevision(Number.MAX_SAFE_INTEGER + 1)).toThrow( + EditorProtocolError, + ); + expect(() => assertSafeEditorRevision(-1)).toThrow(EditorProtocolError); + expect(() => assertSafeEditorRevision(1.5)).toThrow(EditorProtocolError); + }); + + it('accepts null serialized by Rust for optional position fields', () => { + const rustWirePosition = { + ...createDefaultKeyPosition(), + activeImage: null, + inactiveImage: null, + soundEnabled: null, + soundPath: null, + soundVolume: null, + noteOpacityTop: null, + noteOpacityBottom: null, + noteBorderRadius: null, + noteWidth: null, + noteGlowOpacityTop: null, + noteGlowOpacityBottom: null, + noteGlowColor: null, + noteOffsetX: null, + noteOffsetY: null, + noteBorderWidth: null, + noteBorderColor: null, + noteBorderSide: null, + className: null, + zIndex: null, + backgroundColor: null, + activeBackgroundColor: null, + borderColor: null, + activeBorderColor: null, + borderWidth: null, + borderRadius: null, + fontSize: null, + fontColor: null, + activeFontColor: null, + graphAnimationEnabled: null, + fontFamily: null, + imageFit: null, + idleImageFit: null, + activeImageFit: null, + useInlineStyles: null, + displayText: null, + fontWeight: null, + fontItalic: null, + fontUnderline: null, + fontStrikethrough: null, + layerName: null, + groupId: null, + }; + const result = { + revision: 0, + document: { + ...makeDocument(), + keyPositions: { '4key': [rustWirePosition] }, + }, + } as unknown as EditorGetResult; + + expect(() => assertEditorGetResult(result)).not.toThrow(); + }); + + it('still rejects null for required position fields', () => { + const result = { + revision: 0, + document: { + ...makeDocument(), + keyPositions: { + '4key': [{ ...createDefaultKeyPosition(), width: null }], + }, + }, + } as unknown as EditorGetResult; + + expect(() => assertEditorGetResult(result)).toThrow(EditorProtocolError); + }); + + it.each([ + ['null collection', { schemaVersion: 1, keys: null }], + ['unknown field', { schemaVersion: 1, unknown: {} }], + [ + 'mismatched paired length', + { schemaVersion: 1, keys: { '4key': ['A', 'B'] } }, + ], + [ + 'invalid geometry', + { + schemaVersion: 1, + keyPositions: { + '4key': [{ ...createDefaultKeyPosition(), width: -1 }], + }, + }, + ], + ])('rejects malformed %s before applying it', (_label, malformed) => { + expect(() => + applyEditorPatch( + makeDocument(), + malformed as unknown as import('@src/types/editor').EditorPatchV1, + ), + ).toThrow(EditorProtocolError); + }); + + it('does not optimistically apply or transport a malformed patch', async () => { + const harness = createHarness(makeDocument()); + await harness.coordinator.start(); + + await expect( + harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: null, + } as unknown as import('@src/types/editor').EditorPatchV1), + ).rejects.toThrow(EditorProtocolError); + + expect(harness.transport.commitMock).not.toHaveBeenCalled(); + expect( + harness.applications.filter(({ reason }) => reason === 'localPatch'), + ).toHaveLength(0); + harness.coordinator.stop(); + }); +}); + +describe('EditorSaveCoordinator', () => { + it('buffers events until the initial snapshot closes the subscription race', async () => { + const base = makeDocument(); + const next = { ...base, keys: { '4key': ['B'] } }; + const harness = createHarness(base); + const initialGet = deferred(); + harness.transport.getMock.mockReturnValueOnce(initialGet.promise); + + const starting = harness.coordinator.start(); + await vi.waitFor(() => + expect(harness.transport.getMock).toHaveBeenCalledOnce(), + ); + harness.transport.emit(eventFor(1, 'external-1', base, next)); + initialGet.resolve({ revision: 0, document: base }); + await starting; + + expect(harness.coordinator.getState().revision).toBe(1); + expect(harness.getLocal()).toEqual(next); + expect(harness.applications.map(({ reason }) => reason)).toEqual([ + 'initial', + 'event', + ]); + harness.coordinator.stop(); + }); + + it('keeps one request in flight and saves only the latest dirty snapshot next', async () => { + const base = makeDocument(); + const firstTarget = { ...base, keys: { '4key': ['B'] } }; + const latestTarget = { ...base, keys: { '4key': ['C'] } }; + const harness = createHarness(base); + const firstResult = deferred(); + const secondResult = deferred(); + harness.transport.commitMock + .mockReturnValueOnce(firstResult.promise) + .mockReturnValueOnce(secondResult.promise); + await harness.coordinator.start(); + + harness.setLocal(firstTarget); + const firstCommit = harness.coordinator.commitEditorState(); + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledTimes(1), + ); + + harness.setLocal(latestTarget); + const secondCommit = harness.coordinator.commitEditorState(); + expect(harness.transport.commitMock).toHaveBeenCalledTimes(1); + + firstResult.resolve({ revision: 1, changedFields: ['keys'] }); + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledTimes(2), + ); + secondResult.resolve({ revision: 2, changedFields: ['keys'] }); + await Promise.all([firstCommit, secondCommit]); + + const requests = harness.transport.commitMock.mock.calls.map( + ([request]) => request, + ); + expect(requests[0].baseRevision).toBe(0); + expect(requests[0].changes.keys).toEqual({ '4key': ['B'] }); + expect(requests[1].baseRevision).toBe(1); + expect(requests[1].changes.keys).toEqual({ '4key': ['C'] }); + expect(harness.coordinator.getState()).toMatchObject({ + phase: 'idle', + revision: 2, + dirty: false, + }); + harness.coordinator.stop(); + }); + + it('persists a quick revert that happens while the first value is in flight', async () => { + const base = makeDocument(); + const firstTarget = { ...base, keys: { '4key': ['B'] } }; + const harness = createHarness(base); + const firstResult = deferred(); + const revertResult = deferred(); + harness.transport.commitMock + .mockReturnValueOnce(firstResult.promise) + .mockReturnValueOnce(revertResult.promise); + await harness.coordinator.start(); + + const firstCommit = harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: firstTarget.keys, + }); + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledOnce(), + ); + + const revertCommit = harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: base.keys, + }); + firstResult.resolve({ revision: 1, changedFields: ['keys'] }); + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledTimes(2), + ); + + expect(harness.transport.commitMock.mock.calls[1][0].changes.keys).toEqual( + base.keys, + ); + revertResult.resolve({ revision: 2, changedFields: ['keys'] }); + await Promise.all([firstCommit, revertCommit]); + + expect(harness.getLocal()).toEqual(base); + expect(harness.coordinator.getState()).toMatchObject({ + revision: 2, + lastAck: base, + dirty: false, + }); + harness.coordinator.stop(); + }); + + it('merges same-tick patches without dropping intermediate collections', async () => { + const base = makeDocument(); + const keyPositions = structuredClone(base.keyPositions); + keyPositions['4key'][0].dx = 10; + const statPositions = { '4key': [] }; + const graphPositions = { '4key': [] }; + const knobPositions = { '4key': [] }; + const expected = { + ...base, + keyPositions, + statPositions, + graphPositions, + knobPositions, + }; + const harness = createHarness(base); + const firstResult = deferred(); + const mergedResult = deferred(); + harness.transport.commitMock + .mockReturnValueOnce(firstResult.promise) + .mockReturnValueOnce(mergedResult.promise); + await harness.coordinator.start(); + + const commits = [ + harness.coordinator.commitPatch({ schemaVersion: 1, keyPositions }), + harness.coordinator.commitPatch({ schemaVersion: 1, statPositions }), + harness.coordinator.commitPatch({ schemaVersion: 1, graphPositions }), + harness.coordinator.commitPatch({ schemaVersion: 1, knobPositions }), + ]; + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledOnce(), + ); + + firstResult.resolve({ revision: 1, changedFields: ['keyPositions'] }); + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledTimes(2), + ); + expect(harness.transport.commitMock.mock.calls[1][0].changes).toEqual({ + schemaVersion: 1, + statPositions, + graphPositions, + knobPositions, + }); + + mergedResult.resolve({ + revision: 2, + changedFields: ['statPositions', 'graphPositions', 'knobPositions'], + }); + await Promise.all(commits); + + expect(harness.getLocal()).toEqual(expected); + expect(harness.coordinator.getState()).toMatchObject({ + revision: 2, + lastAck: expected, + dirty: false, + }); + harness.coordinator.stop(); + }); + + it('does not apply its own event twice when it arrives before the response', async () => { + const base = makeDocument(); + const target = { ...base, keys: { '4key': ['B'] } }; + const harness = createHarness(base); + const response = deferred(); + harness.transport.commitMock.mockReturnValueOnce(response.promise); + await harness.coordinator.start(); + harness.setLocal(target); + + const committing = harness.coordinator.commitEditorState(); + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledOnce(), + ); + const request = harness.transport.commitMock.mock.calls[0][0]; + harness.transport.emit(eventFor(1, request.mutationId, base, target)); + await vi.waitFor(() => + expect(harness.coordinator.getState().revision).toBe(1), + ); + + expect(harness.applications.map(({ reason }) => reason)).toEqual([ + 'initial', + ]); + response.resolve({ revision: 1, changedFields: ['keys'] }); + await committing; + expect(harness.coordinator.getState().lastAck).toEqual(target); + harness.coordinator.stop(); + }); + + it('resynchronizes a revision gap and ignores an older event', async () => { + const base = makeDocument(); + const remote = withGroups({ ...base, keys: { '4key': ['C'] } }, 'remote'); + const harness = createHarness(base); + harness.transport.canonical = { revision: 1, document: base }; + await harness.coordinator.start(); + harness.transport.canonical = { revision: 3, document: remote }; + + harness.transport.emit(eventFor(3, 'external-3', base, remote)); + await vi.waitFor(() => + expect(harness.coordinator.getState().revision).toBe(3), + ); + expect(harness.getLocal()).toEqual(remote); + expect(harness.transport.getMock).toHaveBeenCalledTimes(2); + + harness.transport.emit(eventFor(2, 'external-2', base, remote)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(harness.transport.getMock).toHaveBeenCalledTimes(2); + harness.coordinator.stop(); + }); + + it('revalidates on focus and visible lifecycle transitions', async () => { + class VisibilityTarget extends EventTarget { + visibilityState = 'visible'; + } + + const base = makeDocument(); + const remote = withGroups(base, 'remote'); + const focusTarget = new EventTarget(); + const visibilityTarget = new VisibilityTarget(); + const harness = createHarness(base, { focusTarget, visibilityTarget }); + await harness.coordinator.start(); + + harness.transport.canonical = { revision: 1, document: remote }; + focusTarget.dispatchEvent(new Event('focus')); + await vi.waitFor(() => + expect(harness.coordinator.getState().revision).toBe(1), + ); + expect(harness.getLocal()).toEqual(remote); + + visibilityTarget.visibilityState = 'hidden'; + visibilityTarget.dispatchEvent(new Event('visibilitychange')); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(harness.transport.getMock).toHaveBeenCalledTimes(2); + + visibilityTarget.visibilityState = 'visible'; + visibilityTarget.dispatchEvent(new Event('visibilitychange')); + await vi.waitFor(() => + expect(harness.transport.getMock).toHaveBeenCalledTimes(3), + ); + + harness.coordinator.stop(); + focusTarget.dispatchEvent(new Event('focus')); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(harness.transport.getMock).toHaveBeenCalledTimes(3); + }); + + it('can reapply the latest coordinator state after a stale bootstrap write', async () => { + const staleBootstrap = makeDocument('A'); + const canonical = makeDocument('B'); + const harness = createHarness(staleBootstrap); + harness.transport.canonical = { revision: 1, document: canonical }; + await harness.coordinator.start(); + expect(harness.getLocal()).toEqual(canonical); + + harness.setLocal(staleBootstrap); + await harness.coordinator.sync({ reapply: true }); + + expect(harness.coordinator.getState()).toMatchObject({ + revision: 1, + lastAck: canonical, + dirty: false, + }); + expect(harness.getLocal()).toEqual(canonical); + expect(harness.applications.at(-1)).toEqual({ + document: canonical, + reason: 'resync', + }); + harness.coordinator.stop(); + }); + + it('rejects OBS writes before creating optimistic or dirty state', async () => { + const canonical = makeDocument('A'); + const harness = createHarness(canonical, { readOnly: true }); + await harness.coordinator.start(); + + await expect( + harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: { '4key': ['PLUGIN'] }, + }), + ).rejects.toBeInstanceOf(EditorReadOnlyError); + await harness.coordinator.sync({ reapply: true }); + + expect(harness.transport.commitMock).not.toHaveBeenCalled(); + expect(harness.getLocal()).toEqual(canonical); + expect(harness.coordinator.getState()).toMatchObject({ + phase: 'idle', + lastAck: canonical, + dirty: false, + conflict: null, + }); + harness.coordinator.stop(); + }); + + it('evaluates read-only access when a write starts, not at module import time', async () => { + const canonical = makeDocument('A'); + let readOnly = true; + const harness = createHarness(canonical, { readOnly: () => readOnly }); + await harness.coordinator.start(); + + await expect( + harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: { '4key': ['BLOCKED'] }, + }), + ).rejects.toBeInstanceOf(EditorReadOnlyError); + + readOnly = false; + await expect( + harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: { '4key': ['ALLOWED'] }, + }), + ).resolves.toMatchObject({ keys: { '4key': ['ALLOWED'] } }); + expect(harness.transport.commitMock).toHaveBeenCalledOnce(); + harness.coordinator.stop(); + }); + + it('automatically rebases disjoint fields and returns the final canonical document', async () => { + const base = makeDocument(); + const local = { ...base, keys: { '4key': ['L'] } }; + const remote = withGroups(base, 'remote'); + const expected = withGroups(local, 'remote'); + const harness = createHarness(base); + const conflict = revisionConflict(); + harness.transport.commitMock + .mockRejectedValueOnce(conflict) + .mockResolvedValueOnce({ revision: 2, changedFields: ['keys'] }); + await harness.coordinator.start(); + harness.transport.canonical = { revision: 1, document: remote }; + harness.setLocal(local); + + const result = await harness.coordinator.commitEditorState(); + + expect(result).toEqual(expected); + expect(harness.getLocal()).toEqual(expected); + expect(harness.transport.commitMock).toHaveBeenCalledTimes(2); + const retried = harness.transport.commitMock.mock.calls[1][0]; + expect(retried.baseRevision).toBe(1); + expect(retried.changes).toEqual({ + schemaVersion: 1, + keys: { '4key': ['L'] }, + }); + expect(harness.coordinator.getState().conflict).toBeNull(); + harness.coordinator.stop(); + }); + + it('exposes all three documents without losing an overlapping local edit', async () => { + const base = makeDocument(); + const local = { ...base, keys: { '4key': ['L'] } }; + const remote = { ...base, keys: { '4key': ['R'] } }; + const harness = createHarness(base); + const conflictError = revisionConflict(); + harness.transport.commitMock.mockRejectedValueOnce(conflictError); + await harness.coordinator.start(); + harness.transport.canonical = { revision: 1, document: remote }; + harness.setLocal(local); + + await expect(harness.coordinator.commitEditorState()).rejects.toEqual( + conflictError, + ); + expect(harness.coordinator.getState().conflict).toMatchObject({ + lastAck: base, + pendingLocal: local, + canonical: remote, + canonicalRevision: 1, + localFields: ['keys'], + overlappingFields: ['keys'], + reason: 'overlap', + }); + expect(harness.getLocal()).toEqual(local); + + const accepted = await harness.coordinator.resolveConflict( + 'acceptCanonical', + ); + expect(accepted).toEqual(remote); + expect(harness.getLocal()).toEqual(remote); + expect(harness.transport.commitMock).toHaveBeenCalledOnce(); + harness.coordinator.stop(); + }); + + it('can keep the local side of an overlap and recommit it on the canonical revision', async () => { + const base = makeDocument(); + const local = { ...base, keys: { '4key': ['L'] } }; + const remote = withGroups({ ...base, keys: { '4key': ['R'] } }, 'remote'); + const expected = withGroups(local, 'remote'); + const harness = createHarness(base); + harness.transport.commitMock + .mockRejectedValueOnce(revisionConflict()) + .mockResolvedValueOnce({ revision: 2, changedFields: ['keys'] }); + await harness.coordinator.start(); + harness.transport.canonical = { revision: 1, document: remote }; + harness.setLocal(local); + + await expect(harness.coordinator.commitEditorState()).rejects.toMatchObject( + { + errorCode: 'REVISION_CONFLICT', + }, + ); + const result = await harness.coordinator.resolveConflict('keepLocal'); + + expect(result).toEqual(expected); + expect(harness.getLocal()).toEqual(expected); + expect(harness.transport.commitMock.mock.calls[1][0]).toMatchObject({ + baseRevision: 1, + changes: { schemaVersion: 1, keys: { '4key': ['L'] } }, + }); + harness.coordinator.stop(); + }); + + it('turns an overlapping external event into a conflict while a save is dirty', async () => { + const base = makeDocument(); + const local = { ...base, keys: { '4key': ['L'] } }; + const remote = { ...base, keys: { '4key': ['R'] } }; + const harness = createHarness(base); + const response = deferred(); + const conflictError = revisionConflict(); + harness.transport.commitMock.mockReturnValueOnce(response.promise); + await harness.coordinator.start(); + harness.setLocal(local); + + const committing = harness.coordinator.commitEditorState(); + const rejectedCommit = expect(committing).rejects.toEqual(conflictError); + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledOnce(), + ); + harness.transport.emit(eventFor(1, 'external-1', base, remote)); + await vi.waitFor(() => + expect(harness.coordinator.getState().phase).toBe('conflict'), + ); + response.reject(conflictError); + await rejectedCommit; + + expect(harness.coordinator.getState().conflict).toMatchObject({ + lastAck: base, + pendingLocal: local, + canonical: remote, + overlappingFields: ['keys'], + }); + expect(harness.getLocal()).toEqual(local); + harness.coordinator.stop(); + }); + + it('treats an external write of the same desired value as an acknowledgement', async () => { + const base = makeDocument(); + const local = { ...base, keys: { '4key': ['L'] } }; + const harness = createHarness(base); + const response = deferred(); + const conflictError = revisionConflict(); + harness.transport.commitMock.mockReturnValueOnce(response.promise); + await harness.coordinator.start(); + harness.setLocal(local); + + const committing = harness.coordinator.commitEditorState(); + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledOnce(), + ); + harness.transport.canonical = { revision: 1, document: local }; + harness.transport.emit(eventFor(1, 'external-1', base, local)); + response.reject(conflictError); + + await expect(committing).resolves.toEqual(local); + expect(harness.coordinator.getState()).toMatchObject({ + revision: 1, + dirty: false, + conflict: null, + }); + harness.coordinator.stop(); + }); + + it('keeps an IO-failed snapshot dirty and retries it explicitly', async () => { + const base = makeDocument(); + const local = { ...base, keys: { '4key': ['L'] } }; + const harness = createHarness(base); + const error = ioError(); + harness.transport.commitMock + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ revision: 1, changedFields: ['keys'] }); + await harness.coordinator.start(); + harness.setLocal(local); + + await expect(harness.coordinator.commitEditorState()).rejects.toEqual( + error, + ); + expect(harness.coordinator.getState()).toMatchObject({ + phase: 'error', + dirty: true, + pendingLocal: local, + error, + failureKind: 'transient', + }); + + const result = await harness.coordinator.retryPending(); + expect(result).toEqual(local); + expect(harness.coordinator.getState()).toMatchObject({ + phase: 'idle', + revision: 1, + dirty: false, + }); + harness.coordinator.stop(); + }); + + it.each([ + ['a non-retryable backend rejection', validationError()], + ['an unstructured transport rejection', new Error('invalid args')], + ])( + 'drops %s, restores the last acknowledgement, and allows the next edit', + async (_label, error) => { + const base = makeDocument(); + const rejected = { ...base, keys: { '4key': ['REJECTED'] } }; + const next = { ...base, keys: { '4key': ['NEXT'] } }; + const harness = createHarness(base); + harness.transport.commitMock + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ revision: 1, changedFields: ['keys'] }); + await harness.coordinator.start(); + harness.setLocal(rejected); + + await expect(harness.coordinator.commitEditorState()).rejects.toBe(error); + + expect(harness.getLocal()).toEqual(base); + expect(harness.coordinator.getState()).toMatchObject({ + phase: 'error', + dirty: false, + pendingLocal: null, + error, + failureKind: 'permanent', + }); + expect(harness.applications.at(-1)).toEqual({ + document: base, + reason: 'rejected', + }); + + await expect(harness.coordinator.retryPending()).resolves.toEqual(base); + expect(harness.transport.commitMock).toHaveBeenCalledOnce(); + + harness.setLocal(next); + await expect(harness.coordinator.commitEditorState()).resolves.toEqual( + next, + ); + expect(harness.transport.commitMock).toHaveBeenCalledTimes(2); + expect(harness.coordinator.getState()).toMatchObject({ + phase: 'idle', + dirty: false, + failureKind: null, + }); + harness.coordinator.stop(); + }, + ); + + it('uses commitPatch as an optimistic compatibility adapter and skips full-state no-ops', async () => { + const base = makeDocument(); + const target = withGroups(base, 'group-1'); + const harness = createHarness(base); + await harness.coordinator.start(); + + const result = await harness.coordinator.commitPatch({ + schemaVersion: 1, + layerGroups: target.layerGroups, + }); + + expect(result).toEqual(target); + expect(harness.getLocal()).toEqual(target); + expect(harness.applications.map(({ reason }) => reason)).toEqual([ + 'initial', + 'localPatch', + ]); + expect(harness.transport.commitMock).toHaveBeenCalledOnce(); + + await harness.coordinator.commitEditorState(target); + expect(harness.transport.commitMock).toHaveBeenCalledOnce(); + harness.coordinator.stop(); + }); + + it('forwards an explicit compatibility patch even when its value is unchanged', async () => { + const base = makeDocument(); + const harness = createHarness(base); + await harness.coordinator.start(); + + const result = await harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: structuredClone(base.keys), + }); + + expect(result).toEqual(base); + expect(harness.transport.commitMock).toHaveBeenCalledOnce(); + expect(harness.transport.commitMock.mock.calls[0][0].changes).toEqual({ + schemaVersion: 1, + keys: base.keys, + }); + expect(harness.coordinator.getState()).toMatchObject({ + revision: 0, + dirty: false, + phase: 'idle', + }); + harness.coordinator.stop(); + }); + + it('does not pull an unrelated local preview into a compatibility patch', async () => { + const base = makeDocument(); + const preview = { + ...base, + graphPositions: { '4key': [] }, + }; + const harness = createHarness(base); + await harness.coordinator.start(); + harness.setLocal(preview); + + const result = await harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: { '4key': ['B'] }, + }); + + expect(harness.transport.commitMock.mock.calls[0][0].changes).toEqual({ + schemaVersion: 1, + keys: { '4key': ['B'] }, + }); + expect(result.graphPositions).toEqual(base.graphPositions); + expect(harness.getLocal()).toMatchObject({ + keys: { '4key': ['B'] }, + graphPositions: preview.graphPositions, + }); + harness.coordinator.stop(); + }); +}); diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts new file mode 100644 index 00000000..8a8ee8ba --- /dev/null +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -0,0 +1,1000 @@ +import { stableStringify } from '@utils/core/stableStringify'; + +import { + EDITOR_FIELDS, + EDITOR_SCHEMA_VERSION, + assertEditorCommitResult, + assertEditorCommittedEvent, + assertEditorDocument, + assertEditorGetResult, + assertEditorPatch, + isEditorCommitError, +} from '@src/types/editor'; + +import type { + EditorCommitError, + EditorCommitRequest, + EditorCommitResult, + EditorCommittedV1, + EditorDocumentV1, + EditorField, + EditorGetResult, + EditorPatchV1, +} from '@src/types/editor'; + +export type EditorApplyReason = + | 'initial' + | 'localPatch' + | 'event' + | 'resync' + | 'rebase' + | 'rejected' + | 'keepLocal' + | 'acceptCanonical'; + +export type EditorConflictReason = 'overlap' | 'rebaseLimit'; +export type EditorConflictResolution = 'keepLocal' | 'acceptCanonical'; + +export interface EditorConflictState { + lastAck: EditorDocumentV1; + pendingLocal: EditorDocumentV1; + canonical: EditorDocumentV1; + canonicalRevision: number; + localFields: EditorField[]; + overlappingFields: EditorField[]; + reason: EditorConflictReason; +} + +export type EditorCoordinatorPhase = + | 'idle' + | 'initializing' + | 'saving' + | 'conflict' + | 'error' + | 'stopped'; + +export interface EditorCoordinatorState { + phase: EditorCoordinatorPhase; + revision: number | null; + dirty: boolean; + inFlightMutationId: string | null; + lastAck: EditorDocumentV1 | null; + pendingLocal: EditorDocumentV1 | null; + conflict: EditorConflictState | null; + error: unknown; + failureKind: 'transient' | 'permanent' | null; +} + +export type EditorReadyUnsubscribe = (() => void) & { ready: Promise }; + +export interface EditorCoordinatorTransport { + get(): Promise; + commit(request: EditorCommitRequest): Promise; + onCommitted( + listener: (event: EditorCommittedV1) => void, + ): EditorReadyUnsubscribe; +} + +interface EditorEventTarget { + addEventListener(type: string, listener: EventListener): void; + removeEventListener(type: string, listener: EventListener): void; +} + +interface EditorVisibilityTarget extends EditorEventTarget { + visibilityState?: string; +} + +export interface EditorCoordinatorOptions { + transport: EditorCoordinatorTransport; + readDocument(): EditorDocumentV1; + applyDocument(document: EditorDocumentV1, reason: EditorApplyReason): void; + createMutationId?: () => string; + focusTarget?: EditorEventTarget | null; + visibilityTarget?: EditorVisibilityTarget | null; + readOnly?: boolean | (() => boolean); +} + +export interface EditorSyncOptions { + reapply?: boolean; +} + +export class EditorReadOnlyError extends Error { + constructor() { + super('editor coordinator is read-only'); + this.name = 'EditorReadOnlyError'; + } +} + +interface InFlightCommit { + mutationId: string; + baseRevision: number; + baseDocument: EditorDocumentV1; + target: EditorDocumentV1; + localFields: EditorField[]; + requestFields: EditorField[]; +} + +const MAX_AUTO_REBASE_ATTEMPTS = 2; +const MAX_TRACKED_MUTATIONS = 64; + +const clone = (value: T): T => structuredClone(value); + +const fieldsOverlap = ( + first: readonly EditorField[], + second: readonly EditorField[], +): EditorField[] => { + const secondSet = new Set(second); + return first.filter((field) => secondSet.has(field)); +}; + +const unresolvedLocalFields = ( + localFields: readonly EditorField[], + pendingLocal: EditorDocumentV1, + canonical: EditorDocumentV1, +): EditorField[] => + localFields.filter( + (field) => + stableStringify(pendingLocal[field]) !== + stableStringify(canonical[field]), + ); + +const patchForFields = ( + document: EditorDocumentV1, + fields: readonly EditorField[], +): EditorPatchV1 => { + const patch: EditorPatchV1 = { schemaVersion: EDITOR_SCHEMA_VERSION }; + fields.forEach((field) => { + Object.assign(patch, { [field]: clone(document[field]) }); + }); + return patch; +}; + +export function getChangedEditorFields( + base: EditorDocumentV1, + next: EditorDocumentV1, +): EditorField[] { + assertEditorDocument(base, 'base editor document'); + assertEditorDocument(next, 'next editor document'); + + return EDITOR_FIELDS.filter( + (field) => stableStringify(base[field]) !== stableStringify(next[field]), + ); +} + +export function createEditorPatch( + base: EditorDocumentV1, + next: EditorDocumentV1, +): EditorPatchV1 { + return patchForFields(next, getChangedEditorFields(base, next)); +} + +export function applyEditorPatch( + base: EditorDocumentV1, + patch: EditorPatchV1, +): EditorDocumentV1 { + assertEditorDocument(base, 'base editor document'); + assertEditorPatch(patch); + + const next = clone(base); + EDITOR_FIELDS.forEach((field) => { + if (patch[field] !== undefined) { + Object.assign(next, { [field]: clone(patch[field]) }); + } + }); + assertEditorDocument(next, 'patched editor document'); + return next; +} + +export function rebaseEditorDocument( + canonical: EditorDocumentV1, + pendingLocal: EditorDocumentV1, + localFields: readonly EditorField[], +): EditorDocumentV1 { + return applyEditorPatch(canonical, patchForFields(pendingLocal, localFields)); +} + +export class EditorSaveCoordinator { + private readonly transport: EditorCoordinatorTransport; + private readonly readDocument: () => EditorDocumentV1; + private readonly applyDocument: EditorCoordinatorOptions['applyDocument']; + private readonly createMutationId: () => string; + private readonly focusTarget: EditorEventTarget | null; + private readonly visibilityTarget: EditorVisibilityTarget | null; + private readonly isReadOnly: () => boolean; + + private phase: EditorCoordinatorPhase = 'idle'; + private revision: number | null = null; + private lastAck: EditorDocumentV1 | null = null; + private pendingLocal: EditorDocumentV1 | null = null; + private pendingFields: EditorField[] = []; + private pendingRequestFields: EditorField[] = []; + private inFlight: InFlightCommit | null = null; + private conflict: EditorConflictState | null = null; + private error: unknown = null; + private failureKind: 'transient' | 'permanent' | null = null; + private stopped = false; + private initializing = false; + + private startPromise: Promise | null = null; + private drainPromise: Promise | null = null; + private syncPromise: Promise | null = null; + private eventQueue: Promise = Promise.resolve(); + private unsubscribeCommitted: EditorReadyUnsubscribe | null = null; + private bufferedEvents: EditorCommittedV1[] = []; + private ownMutations = new Set(); + private listeners = new Set<(state: EditorCoordinatorState) => void>(); + + private readonly handleFocus = () => { + void this.sync().catch((error) => this.recordBackgroundError(error)); + }; + + private readonly handleVisibilityChange = () => { + if (this.visibilityTarget?.visibilityState !== 'hidden') { + void this.sync().catch((error) => this.recordBackgroundError(error)); + } + }; + + constructor(options: EditorCoordinatorOptions) { + this.transport = options.transport; + this.readDocument = options.readDocument; + this.applyDocument = options.applyDocument; + this.createMutationId = + options.createMutationId ?? (() => crypto.randomUUID()); + this.focusTarget = + options.focusTarget === undefined + ? typeof window === 'undefined' + ? null + : window + : options.focusTarget; + this.visibilityTarget = + options.visibilityTarget === undefined + ? typeof document === 'undefined' + ? null + : document + : options.visibilityTarget; + const readOnly = options.readOnly; + this.isReadOnly = + typeof readOnly === 'function' ? readOnly : () => readOnly ?? false; + } + + getState(): EditorCoordinatorState { + const latestPending = this.getLatestPendingDocument(); + return { + phase: this.phase, + revision: this.revision, + dirty: latestPending !== null, + inFlightMutationId: this.inFlight?.mutationId ?? null, + lastAck: this.lastAck ? clone(this.lastAck) : null, + pendingLocal: latestPending ? clone(latestPending) : null, + conflict: this.conflict ? clone(this.conflict) : null, + error: this.error, + failureKind: this.failureKind, + }; + } + + subscribe(listener: (state: EditorCoordinatorState) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + start(): Promise { + if (this.stopped) { + return Promise.reject(new Error('editor coordinator is stopped')); + } + if (this.startPromise) return this.startPromise; + + this.startPromise = this.initialize().catch((error) => { + this.startPromise = null; + this.initializing = false; + this.phase = 'error'; + this.error = error; + this.detachLifecycle(); + this.unsubscribeCommitted?.(); + this.unsubscribeCommitted = null; + this.notify(); + throw error; + }); + return this.startPromise; + } + + async commitEditorState( + document?: EditorDocumentV1, + ): Promise { + this.assertWritable(); + await this.start(); + const currentDocument = document ?? this.readDocument(); + assertEditorDocument(currentDocument); + const snapshot = clone(currentDocument); + + const projected = this.getLatestCommitBase(); + const newIntentFields = getChangedEditorFields(projected, snapshot); + return this.queueSnapshot(snapshot, newIntentFields, []); + } + + private async queueSnapshot( + snapshot: EditorDocumentV1, + newIntentFields: readonly EditorField[], + requestFields: readonly EditorField[], + ): Promise { + if (this.conflict) { + const conflict = this.conflict; + const newlyChangedFields = getChangedEditorFields( + conflict.pendingLocal, + snapshot, + ); + conflict.pendingLocal = snapshot; + conflict.localFields = EDITOR_FIELDS.filter( + (field) => + conflict.localFields.includes(field) || + newlyChangedFields.includes(field), + ); + this.notify(); + return Promise.reject(this.error ?? new Error('editor conflict pending')); + } + + const outstandingFields = new Set([ + ...(this.inFlight?.localFields ?? []), + ...this.pendingFields, + ...newIntentFields, + ]); + this.pendingLocal = snapshot; + this.pendingFields = EDITOR_FIELDS.filter((field) => + outstandingFields.has(field), + ); + const requested = new Set([ + ...this.pendingRequestFields, + ...requestFields, + ]); + this.pendingRequestFields = EDITOR_FIELDS.filter((field) => + requested.has(field), + ); + this.error = null; + this.failureKind = null; + this.notify(); + await this.drainUntilSettled(); + return clone(this.requireLastAck()); + } + + async commitPatch(changes: EditorPatchV1): Promise { + this.assertWritable(); + await this.start(); + assertEditorPatch(changes); + + const projected = this.getLatestCommitBase(); + const target = applyEditorPatch(projected, changes); + const newIntentFields = getChangedEditorFields(projected, target); + const requestFields = EDITOR_FIELDS.filter( + (field) => changes[field] !== undefined, + ); + const currentDocument = this.readDocument(); + assertEditorDocument(currentDocument); + const optimisticDocument = applyEditorPatch(currentDocument, changes); + if ( + getChangedEditorFields(currentDocument, optimisticDocument).length > 0 + ) { + this.applyDocument(clone(optimisticDocument), 'localPatch'); + } + return this.queueSnapshot(target, newIntentFields, requestFields); + } + + async retryPending(): Promise { + this.assertWritable(); + await this.start(); + if (this.conflict) { + throw this.error ?? new Error('editor conflict pending'); + } + if (!this.pendingLocal && this.pendingRequestFields.length === 0) { + return clone(this.requireLastAck()); + } + + this.error = null; + this.failureKind = null; + await this.drainUntilSettled(); + return clone(this.requireLastAck()); + } + + async resolveConflict( + resolution: EditorConflictResolution, + ): Promise { + if (resolution === 'keepLocal') this.assertWritable(); + if (this.drainPromise) { + await this.drainPromise.catch(() => undefined); + } + + const conflict = this.conflict; + if (!conflict) return clone(this.requireLastAck()); + + this.conflict = null; + this.error = null; + this.failureKind = null; + this.revision = conflict.canonicalRevision; + this.lastAck = clone(conflict.canonical); + + if (resolution === 'acceptCanonical') { + this.pendingLocal = null; + this.pendingFields = []; + this.pendingRequestFields = []; + this.phase = 'idle'; + this.applyDocument(clone(conflict.canonical), 'acceptCanonical'); + this.notify(); + return clone(conflict.canonical); + } + + const rebased = rebaseEditorDocument( + conflict.canonical, + conflict.pendingLocal, + conflict.localFields, + ); + this.pendingLocal = rebased; + this.pendingFields = [...conflict.localFields]; + this.applyDocument(clone(rebased), 'keepLocal'); + this.notify(); + await this.drainUntilSettled(); + return clone(this.requireLastAck()); + } + + async sync(options: EditorSyncOptions = {}): Promise { + await this.start(); + if (this.syncPromise) { + await this.syncPromise; + } else { + this.syncPromise = this.fetchAndApplyCanonical('resync').finally(() => { + this.syncPromise = null; + }); + await this.syncPromise; + } + + if (options.reapply) { + const displayDocument = + this.getLatestPendingDocument() ?? this.requireLastAck(); + this.applyDocument(clone(displayDocument), 'resync'); + this.notify(); + } + } + + async flush(): Promise { + if (this.isReadOnly()) { + await this.start(); + await this.eventQueue; + return clone(this.requireLastAck()); + } + await this.commitEditorState(this.readDocument()); + if (this.drainPromise) await this.drainPromise; + await this.eventQueue; + return clone(this.requireLastAck()); + } + + stop(): void { + if (this.stopped) return; + this.stopped = true; + this.unsubscribeCommitted?.(); + this.unsubscribeCommitted = null; + this.detachLifecycle(); + this.phase = 'stopped'; + this.notify(); + } + + private async initialize(): Promise { + this.phase = 'initializing'; + this.error = null; + this.failureKind = null; + this.initializing = true; + this.notify(); + + this.unsubscribeCommitted = this.transport.onCommitted((event) => { + if (this.stopped) return; + if (this.initializing) { + this.bufferedEvents.push(clone(event)); + return; + } + void this.enqueueEvent(event).catch((error) => + this.recordBackgroundError(error), + ); + }); + await this.unsubscribeCommitted.ready; + this.attachLifecycle(); + + const result = await this.transport.get(); + assertEditorGetResult(result); + this.revision = result.revision; + this.lastAck = clone(result.document); + this.applyDocument(clone(result.document), 'initial'); + this.initializing = false; + + const buffered = this.bufferedEvents; + this.bufferedEvents = []; + for (const event of buffered) { + await this.processCommittedEvent(event); + } + + if (!this.conflict) this.phase = 'idle'; + this.notify(); + return clone(result); + } + + private ensureDrain(): Promise { + if (this.drainPromise) return this.drainPromise; + + this.drainPromise = this.drain().finally(() => { + this.drainPromise = null; + if (!this.conflict && !this.error && !this.stopped) { + this.phase = 'idle'; + } + this.notify(); + }); + return this.drainPromise; + } + + private async drainUntilSettled(): Promise { + do { + await this.ensureDrain(); + } while ( + (this.pendingLocal || this.pendingRequestFields.length > 0) && + !this.conflict && + !this.stopped + ); + } + + private async drain(): Promise { + let rebaseAttempts = 0; + + while ( + (this.pendingLocal || this.pendingRequestFields.length > 0) && + !this.conflict && + !this.stopped + ) { + const target = this.pendingLocal ?? clone(this.requireLastAck()); + const actualChangedFields = getChangedEditorFields( + this.requireLastAck(), + target, + ); + const localFields = this.pendingFields.filter((field) => + actualChangedFields.includes(field), + ); + const requested = new Set([ + ...localFields, + ...this.pendingRequestFields, + ]); + const requestFields = EDITOR_FIELDS.filter((field) => + requested.has(field), + ); + this.pendingLocal = null; + this.pendingFields = []; + this.pendingRequestFields = []; + + if (requestFields.length === 0) { + continue; + } + + const baseDocument = clone(this.requireLastAck()); + const baseRevision = this.requireRevision(); + const mutationId = this.createMutationId(); + const inFlight: InFlightCommit = { + mutationId, + baseRevision, + baseDocument, + target: clone(target), + localFields, + requestFields, + }; + this.inFlight = inFlight; + this.rememberOwnMutation(inFlight); + this.phase = 'saving'; + this.notify(); + + try { + const result = await this.transport.commit({ + baseRevision, + mutationId, + changes: patchForFields(target, requestFields), + }); + assertEditorCommitResult(result); + await this.applyCommitResult(inFlight, result); + rebaseAttempts = 0; + } catch (error) { + if (this.inFlight?.mutationId === mutationId) this.inFlight = null; + + if ( + isEditorCommitError(error) && + error.errorCode === 'REVISION_CONFLICT' + ) { + let didRebase: boolean; + try { + didRebase = await this.handleRevisionConflict( + inFlight, + error, + rebaseAttempts, + ); + } catch (syncError) { + this.preservePending( + inFlight.target, + inFlight.localFields, + inFlight.requestFields, + ); + this.phase = 'error'; + this.error = syncError; + this.failureKind = 'transient'; + this.notify(); + throw syncError; + } + if (didRebase) { + rebaseAttempts += 1; + continue; + } + } else if (isEditorCommitError(error) && error.retryable) { + this.preservePending( + inFlight.target, + inFlight.localFields, + inFlight.requestFields, + ); + this.phase = 'error'; + this.error = error; + this.failureKind = 'transient'; + this.notify(); + } else { + this.discardRejectedPending(error, mutationId); + } + throw error; + } finally { + if (this.inFlight?.mutationId === mutationId) this.inFlight = null; + } + } + } + + private async applyCommitResult( + inFlight: InFlightCommit, + result: EditorCommitResult, + ): Promise { + const currentRevision = this.requireRevision(); + if (result.revision > currentRevision + 1) { + await this.fetchAndApplyCanonical('resync'); + return; + } + + if (result.revision >= currentRevision) { + this.revision = result.revision; + if ( + result.revision > currentRevision || + result.changedFields.length === 0 + ) { + this.lastAck = clone(inFlight.target); + } + } + this.error = null; + this.failureKind = null; + } + + private async handleRevisionConflict( + inFlight: InFlightCommit, + error: EditorCommitError, + rebaseAttempts: number, + ): Promise { + const requested = new Set([ + ...this.pendingRequestFields, + ...inFlight.requestFields, + ]); + this.pendingRequestFields = EDITOR_FIELDS.filter((field) => + requested.has(field), + ); + + if (this.conflict) { + this.error = error; + this.failureKind = null; + this.phase = 'conflict'; + this.notify(); + return false; + } + + const result = await this.transport.get(); + assertEditorGetResult(result); + + const pending = this.pendingLocal ?? inFlight.target; + const localFields = + this.pendingLocal !== null + ? [...this.pendingFields] + : inFlight.localFields; + const remainingLocalFields = unresolvedLocalFields( + localFields, + pending, + result.document, + ); + const remoteFields = getChangedEditorFields( + inFlight.baseDocument, + result.document, + ); + const overlappingFields = fieldsOverlap(remainingLocalFields, remoteFields); + const rebased = rebaseEditorDocument( + result.document, + pending, + remainingLocalFields, + ); + + this.revision = result.revision; + this.lastAck = clone(result.document); + this.applyDocument(clone(rebased), 'rebase'); + + if ( + overlappingFields.length > 0 || + (remainingLocalFields.length > 0 && + rebaseAttempts >= MAX_AUTO_REBASE_ATTEMPTS) + ) { + this.pendingLocal = null; + this.pendingFields = []; + this.conflict = { + lastAck: clone(inFlight.baseDocument), + pendingLocal: clone(pending), + canonical: clone(result.document), + canonicalRevision: result.revision, + localFields: [...remainingLocalFields], + overlappingFields, + reason: overlappingFields.length > 0 ? 'overlap' : 'rebaseLimit', + }; + this.error = error; + this.failureKind = null; + this.phase = 'conflict'; + this.notify(); + return false; + } + + this.pendingLocal = remainingLocalFields.length > 0 ? rebased : null; + this.pendingFields = [...remainingLocalFields]; + this.error = null; + this.failureKind = null; + this.notify(); + return true; + } + + private enqueueEvent(event: EditorCommittedV1): Promise { + const queued = this.eventQueue.then(() => + this.processCommittedEvent(event), + ); + this.eventQueue = queued.catch(() => undefined); + return queued; + } + + private async processCommittedEvent(event: EditorCommittedV1): Promise { + assertEditorCommittedEvent(event); + if (this.stopped || this.revision === null || !this.lastAck) return; + + if (event.revision <= this.revision) return; + if (event.revision > this.revision + 1) { + await this.fetchAndApplyCanonical('resync'); + return; + } + + const previousCanonical = this.lastAck; + const canonical = applyEditorPatch(previousCanonical, event.patch); + const isOwnMutation = this.ownMutations.has(event.mutationId); + if (isOwnMutation) this.ownMutations.delete(event.mutationId); + this.revision = event.revision; + this.lastAck = clone(canonical); + + if (isOwnMutation) { + this.error = null; + this.notify(); + return; + } + + this.applyExternalCanonical( + canonical, + event.changedFields, + event.revision, + 'event', + previousCanonical, + ); + } + + private async fetchAndApplyCanonical( + reason: Extract, + ): Promise { + const result = await this.transport.get(); + assertEditorGetResult(result); + + if (this.revision !== null && result.revision < this.revision) return; + if (this.revision === result.revision && this.lastAck) return; + + const previous = this.lastAck; + this.revision = result.revision; + this.lastAck = clone(result.document); + + if (!previous) { + this.applyDocument(clone(result.document), reason); + this.notify(); + return; + } + + const remoteFields = getChangedEditorFields(previous, result.document); + this.applyExternalCanonical( + result.document, + remoteFields, + result.revision, + reason, + previous, + ); + } + + private applyExternalCanonical( + canonical: EditorDocumentV1, + changedFields: readonly EditorField[], + canonicalRevision: number, + reason: Extract, + previousCanonical?: EditorDocumentV1, + ): void { + const comparisonBase = this.conflict?.lastAck ?? previousCanonical; + const pending = this.getLatestPendingDocument(); + + if (!pending || !comparisonBase) { + this.applyDocument(clone(canonical), reason); + this.error = null; + this.failureKind = null; + this.notify(); + return; + } + + const localFields = this.getLatestPendingFields(comparisonBase, pending); + const remainingLocalFields = unresolvedLocalFields( + localFields, + pending, + canonical, + ); + const remoteFields = this.conflict + ? getChangedEditorFields(comparisonBase, canonical) + : [...changedFields]; + const overlappingFields = fieldsOverlap(remainingLocalFields, remoteFields); + const rebased = rebaseEditorDocument( + canonical, + pending, + remainingLocalFields, + ); + + this.applyDocument(clone(rebased), 'rebase'); + if ( + overlappingFields.length > 0 || + (this.conflict?.reason === 'rebaseLimit' && + remainingLocalFields.length > 0) + ) { + this.pendingLocal = null; + this.pendingFields = []; + this.conflict = { + lastAck: clone(comparisonBase), + pendingLocal: clone(pending), + canonical: clone(canonical), + canonicalRevision, + localFields: [...remainingLocalFields], + overlappingFields, + reason: + overlappingFields.length > 0 + ? 'overlap' + : this.conflict?.reason ?? 'rebaseLimit', + }; + this.phase = 'conflict'; + } else { + this.conflict = null; + this.pendingLocal = remainingLocalFields.length > 0 ? rebased : null; + this.pendingFields = [...remainingLocalFields]; + } + this.notify(); + } + + private getLatestCommitBase(): EditorDocumentV1 { + if (this.conflict) return clone(this.conflict.pendingLocal); + if (this.pendingLocal) return clone(this.pendingLocal); + if (this.inFlight) return clone(this.inFlight.target); + return clone(this.requireLastAck()); + } + + private getLatestPendingDocument(): EditorDocumentV1 | null { + return ( + this.conflict?.pendingLocal ?? + this.pendingLocal ?? + this.inFlight?.target ?? + null + ); + } + + private getLatestPendingFields( + comparisonBase: EditorDocumentV1, + pending: EditorDocumentV1, + ): EditorField[] { + if (this.conflict) return [...this.conflict.localFields]; + if (this.pendingLocal) return [...this.pendingFields]; + if (this.inFlight) return [...this.inFlight.localFields]; + return getChangedEditorFields(comparisonBase, pending); + } + + private preservePending( + document: EditorDocumentV1, + localFields: readonly EditorField[], + requestFields: readonly EditorField[] = [], + ): void { + if (!this.pendingLocal) { + this.pendingLocal = clone(document); + this.pendingFields = [...localFields]; + } + const requested = new Set([ + ...this.pendingRequestFields, + ...requestFields, + ]); + this.pendingRequestFields = EDITOR_FIELDS.filter((field) => + requested.has(field), + ); + } + + private discardRejectedPending(error: unknown, mutationId: string): void { + this.ownMutations.delete(mutationId); + this.pendingLocal = null; + this.pendingFields = []; + this.pendingRequestFields = []; + this.phase = 'error'; + this.error = error; + this.failureKind = 'permanent'; + this.applyDocument(clone(this.requireLastAck()), 'rejected'); + this.notify(); + } + + private rememberOwnMutation(inFlight: InFlightCommit): void { + this.ownMutations.add(inFlight.mutationId); + while (this.ownMutations.size > MAX_TRACKED_MUTATIONS) { + const oldest = this.ownMutations.values().next().value; + if (oldest === undefined) break; + this.ownMutations.delete(oldest); + } + } + + private attachLifecycle(): void { + this.focusTarget?.addEventListener('focus', this.handleFocus); + this.visibilityTarget?.addEventListener( + 'visibilitychange', + this.handleVisibilityChange, + ); + } + + private detachLifecycle(): void { + this.focusTarget?.removeEventListener('focus', this.handleFocus); + this.visibilityTarget?.removeEventListener( + 'visibilitychange', + this.handleVisibilityChange, + ); + } + + private recordBackgroundError(error: unknown): void { + this.error = error; + this.failureKind = null; + if (!this.inFlight && !this.conflict) this.phase = 'error'; + this.notify(); + } + + private requireRevision(): number { + if (this.revision === null) { + throw new Error('editor coordinator has not initialized a revision'); + } + return this.revision; + } + + private requireLastAck(): EditorDocumentV1 { + if (!this.lastAck) { + throw new Error('editor coordinator has not initialized a document'); + } + return this.lastAck; + } + + private assertWritable(): void { + if (this.isReadOnly()) throw new EditorReadOnlyError(); + } + + private notify(): void { + if (this.listeners.size === 0) return; + const state = this.getState(); + this.listeners.forEach((listener) => { + try { + listener(state); + } catch (error) { + console.error('[Editor] Coordinator state listener failed', error); + } + }); + } +} + +export const createEditorCoordinator = ( + options: EditorCoordinatorOptions, +): EditorSaveCoordinator => new EditorSaveCoordinator(options); diff --git a/src/renderer/editor/runtime/editorSnapshot.ts b/src/renderer/editor/runtime/editorSnapshot.ts index fda60167..eb2bac86 100644 --- a/src/renderer/editor/runtime/editorSnapshot.ts +++ b/src/renderer/editor/runtime/editorSnapshot.ts @@ -3,6 +3,7 @@ */ import { useKeyStore } from '@stores/data/useKeyStore'; +import { unstable_batchedUpdates } from 'react-dom'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; @@ -15,6 +16,9 @@ import { useFontStore, syncFontCSS } from '@stores/useFontStore'; import { applyCounterSnapshot } from '@stores/signals/keyCounterSignals'; import { applyCounterCacheSnapshot } from '@stores/signals/keyCounterCache'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; +import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; +import { restoreEditorHistory } from '@api/modules/editorApi'; +import { editorCoordinator } from './editorStateCoordinator'; // ---------------------------------------------------------------------------- // 히스토리에 현재 상태 저장 @@ -99,7 +103,7 @@ interface RestoredState { graphPositions: import('@src/types/key/graphItems').GraphItemPositions; knobPositions: import('@src/types/key/knobs').KnobItemPositions; pluginElements?: PluginDisplayElementInternal[]; - layerGroups?: import('@src/types/layerGroups').LayerGroups; + layerGroups: import('@src/types/layerGroups').LayerGroups; keyCounters?: import('@src/types/key/keys').KeyCounters; customTabs: import('@src/types/key/keys').CustomTab[]; selectedKeyType: string; @@ -108,44 +112,44 @@ interface RestoredState { /** 복원된 상태를 로컬 store에 반영 */ export function applyRestoredStateToStores(state: RestoredState): void { - useKeyStore - .getState() - .setKeyMappingsAndPositions(state.keyMappings, state.positions); - useStatItemStore.getState().setPositions(state.statPositions); - useGraphItemStore.getState().setPositions(state.graphPositions); - if (state.knobPositions !== undefined) { - useKnobItemStore.getState().setPositions(state.knobPositions); - } + unstable_batchedUpdates(() => { + useKeyStore + .getState() + .setKeyMappingsAndPositions(state.keyMappings, state.positions); + useStatItemStore.getState().setPositions(state.statPositions); + useGraphItemStore.getState().setPositions(state.graphPositions); + if (state.knobPositions !== undefined) { + useKnobItemStore.getState().setPositions(state.knobPositions); + } - if (state.layerGroups !== undefined) { useLayerGroupStore.getState().setLayerGroups(state.layerGroups); - } - if (state.keyCounters) { - applyCounterCacheSnapshot(state.keyCounters); - if (window.__dmn_window_type === 'overlay') { - applyCounterSnapshot(state.keyCounters); + if (state.keyCounters) { + applyCounterCacheSnapshot(state.keyCounters); + if (window.__dmn_window_type === 'overlay') { + applyCounterSnapshot(state.keyCounters); + } } - } - useKeyStore.getState().setCustomTabs(state.customTabs); - useKeyStore.setState({ selectedKeyType: state.selectedKeyType }); - - // 설정 스냅샷 복원 (프리셋 로드 undo 전용) - if (state.settingsSnapshot) { - const snap = state.settingsSnapshot; - useSettingsStore.getState().merge({ - useCustomCSS: snap.useCustomCSS, - customCSSContent: snap.customCSSContent, - customCSSPath: snap.customCSSPath, - useCustomJS: snap.useCustomJS, - jsPlugins: snap.jsPlugins, - backgroundColor: snap.backgroundColor, - noteSettings: snap.noteSettings, - noteEffect: snap.noteEffect, - tabNoteOverrides: snap.tabNoteOverrides, - }); - useFontStore.getState().setAll(snap.fontSettings.customFonts); - syncFontCSS(); - } + useKeyStore.getState().setCustomTabs(state.customTabs); + useKeyStore.setState({ selectedKeyType: state.selectedKeyType }); + + // 설정 스냅샷 복원 (프리셋 로드 undo 전용) + if (state.settingsSnapshot) { + const snap = state.settingsSnapshot; + useSettingsStore.getState().merge({ + useCustomCSS: snap.useCustomCSS, + customCSSContent: snap.customCSSContent, + customCSSPath: snap.customCSSPath, + useCustomJS: snap.useCustomJS, + jsPlugins: snap.jsPlugins, + backgroundColor: snap.backgroundColor, + noteSettings: snap.noteSettings, + noteEffect: snap.noteEffect, + tabNoteOverrides: snap.tabNoteOverrides, + }); + useFontStore.getState().setAll(snap.fontSettings.customFonts); + syncFontCSS(); + } + }); } /** 복원된 플러그인 요소를 store에 반영하고 오버레이에 동기화 */ @@ -175,147 +179,53 @@ export function applyRestoredPluginElements( .setElements(finalElements as PluginDisplayElementInternal[]); // 오버레이로 동기화 - if (window.api?.bridge) { - window.api.bridge.sendTo('overlay', 'plugin:displayElements:sync', { - elements: finalElements, - }); - } + sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { + elements: finalElements, + }); } /** 복원된 상태를 Tauri 백엔드에 동기화 */ export async function persistRestoredState( state: RestoredState, + baseRevision: number, ): Promise { - // keys.update()가 counters를 sync하므로 먼저 실행 - await window.api.keys.update(state.keyMappings); - - // 나머지는 병렬 실행 (실패해도 핵심 복구를 막지 않음) - const promises: Promise[] = [ - window.api.keys.updatePositions(state.positions).catch((error) => { - console.error('Failed to persist positions', error); - }), - window.api.statItems.updatePositions(state.statPositions).catch((error) => { - console.error('Failed to persist stat positions', error); - }), - window.api.graphItems - .updatePositions(state.graphPositions) - .catch((error) => { - console.error('Failed to persist graph positions', error); - }), - ]; - - if (state.knobPositions !== undefined) { - promises.push( - window.api.knobItems - .updatePositions(state.knobPositions) - .catch((error) => { - console.error('Failed to persist knob positions', error); - }), - ); - } - - if (state.layerGroups !== undefined) { - promises.push( - window.api.layerGroups.update(state.layerGroups).catch((error) => { - console.error('Failed to persist layer groups', error); - }), - ); - } - if (state.keyCounters) { - promises.push(window.api.keys.setCounters(state.keyCounters)); - } - promises.push( - window.api.keys.customTabs - .restore(state.customTabs, state.selectedKeyType) - .catch((error) => { - console.error('Failed to restore custom tabs', error); - }), - ); - - await Promise.all(promises); - - // 오버레이 동기화 - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: state.statPositions, - }); - } catch { - /* 무시 */ - } - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: state.graphPositions, - }); - } catch { - /* 무시 */ - } - if (state.knobPositions !== undefined) { - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: state.knobPositions, - }); - } catch { - /* 무시 */ - } - } - - // 설정 스냅샷 백엔드 동기화 (프리셋 로드 undo 전용) - if (state.settingsSnapshot) { - const snap = state.settingsSnapshot; - - // 설정 전체 persist (CSS path/content, JS plugins 포함) 먼저 완료 - await window.api.settings - .update({ - backgroundColor: snap.backgroundColor, - noteSettings: snap.noteSettings, - noteEffect: snap.noteEffect, - fontSettings: snap.fontSettings, - customCSS: { content: snap.customCSSContent, path: snap.customCSSPath }, - customJS: { plugins: snap.jsPlugins }, - }) - .catch((e) => { - console.error('Failed to restore settings', e); - }); - - // CSS 이벤트 발생 (css:content + css:use) - const cssEvents = Promise.all([ - window.api.css.setContent(snap.customCSSContent).catch((e) => { - console.error('Failed to emit CSS content', e); - }), - window.api.css.toggle(snap.useCustomCSS).catch((e) => { - console.error('Failed to toggle CSS', e); - }), - ]); - - // JS 토글 → JS 리로드 - const jsEvents = window.api.js - .toggle(snap.useCustomJS) - .then(() => (snap.useCustomJS ? window.api.js.reload() : undefined)) - .catch((e) => { - console.error('Failed to restore JS', e); - }); - - // tabNoteOverrides 복원 (getAll 실패 시 store 값을 fallback으로 사용) - const currentTabOverrides = await window.api.noteTab - .getAll() - .catch(() => useSettingsStore.getState().tabNoteOverrides); - const tabIds = new Set([ - ...Object.keys(snap.tabNoteOverrides), - ...Object.keys(currentTabOverrides), - ]); - const tabRestore = Promise.all( - Array.from(tabIds).map((tabId) => { - const snapSettings = snap.tabNoteOverrides[tabId]; - return snapSettings !== undefined - ? window.api.noteTab.set(tabId, snapSettings).catch((e) => { - console.error('Failed to restore tab note', e); - }) - : window.api.noteTab.clear(tabId).catch((e) => { - console.error('Failed to clear tab note', e); - }); - }), - ); + const document = { + schemaVersion: 1, + keys: state.keyMappings, + keyPositions: state.positions, + statPositions: state.statPositions, + graphPositions: state.graphPositions, + knobPositions: state.knobPositions, + layerGroups: state.layerGroups, + } as const; + const settingsPatch = state.settingsSnapshot + ? { + useCustomCSS: state.settingsSnapshot.useCustomCSS, + useCustomJS: state.settingsSnapshot.useCustomJS, + backgroundColor: state.settingsSnapshot.backgroundColor, + noteSettings: state.settingsSnapshot.noteSettings, + noteEffect: state.settingsSnapshot.noteEffect, + fontSettings: state.settingsSnapshot.fontSettings, + customCSS: { + content: state.settingsSnapshot.customCSSContent, + path: state.settingsSnapshot.customCSSPath, + }, + customJS: { plugins: state.settingsSnapshot.jsPlugins }, + } + : undefined; + + await restoreEditorHistory({ + baseRevision, + document, + customTabs: state.customTabs, + selectedKeyType: state.selectedKeyType, + keyCounters: state.keyCounters, + settingsPatch, + tabNoteOverrides: state.settingsSnapshot?.tabNoteOverrides, + }); - await Promise.all([cssEvents, jsEvents, tabRestore]); - } + // 이벤트 유실·순서 역전을 막는 최종 canonical 대조 + await editorCoordinator.sync({ reapply: true }).catch((error) => { + console.error('Failed to resync restored editor history', error); + }); } diff --git a/src/renderer/editor/runtime/editorStateCoordinator.ts b/src/renderer/editor/runtime/editorStateCoordinator.ts new file mode 100644 index 00000000..b1d9910b --- /dev/null +++ b/src/renderer/editor/runtime/editorStateCoordinator.ts @@ -0,0 +1,85 @@ +import { editorApi } from '@api/modules/editorApi'; +import { unstable_batchedUpdates } from 'react-dom'; +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { stableStringify } from '@utils/core/stableStringify'; + +import { createEditorCoordinator } from './editorCoordinator'; + +import type { EditorDocumentV1 } from '@src/types/editor'; + +const hasChanged = (current: unknown, next: unknown) => + stableStringify(current) !== stableStringify(next); + +export const captureEditorDocument = (): EditorDocumentV1 => { + const keyState = useKeyStore.getState(); + return { + schemaVersion: 1, + keys: keyState.keyMappings, + keyPositions: keyState.positions, + statPositions: useStatItemStore.getState().positions, + graphPositions: useGraphItemStore.getState().positions, + knobPositions: useKnobItemStore.getState().positions, + layerGroups: useLayerGroupStore.getState().layerGroups, + }; +}; + +export const applyEditorDocument = (document: EditorDocumentV1): void => { + unstable_batchedUpdates(() => { + const keyState = useKeyStore.getState(); + const keyChanges: Partial< + Pick + > = {}; + if (hasChanged(keyState.keyMappings, document.keys)) { + keyChanges.keyMappings = document.keys; + } + if (hasChanged(keyState.positions, document.keyPositions)) { + keyChanges.positions = document.keyPositions; + } + if (Object.keys(keyChanges).length > 0) { + useKeyStore.setState(keyChanges); + } + + if ( + hasChanged(useStatItemStore.getState().positions, document.statPositions) + ) { + useStatItemStore.setState({ positions: document.statPositions }); + } + if ( + hasChanged( + useGraphItemStore.getState().positions, + document.graphPositions, + ) + ) { + useGraphItemStore.setState({ positions: document.graphPositions }); + } + if ( + hasChanged(useKnobItemStore.getState().positions, document.knobPositions) + ) { + useKnobItemStore.setState({ positions: document.knobPositions }); + } + if ( + hasChanged( + useLayerGroupStore.getState().layerGroups, + document.layerGroups, + ) + ) { + useLayerGroupStore.getState().setLayerGroups(document.layerGroups); + } + }); +}; + +export const editorCoordinator = createEditorCoordinator({ + transport: editorApi, + readDocument: captureEditorDocument, + // OBS 브릿지는 읽기 전용, 네이티브 창은 revision 충돌 검사를 거쳐 쓰기 허용 + readOnly: () => window.__dmn_runtime === 'obs', + applyDocument: (document, reason) => { + // OBS는 읽기 전용이므로 차단될 쓰기를 화면에 낙관 적용하지 않음 + if (reason === 'localPatch' && window.__dmn_runtime === 'obs') return; + applyEditorDocument(document); + }, +}); diff --git a/src/renderer/editor/runtime/index.ts b/src/renderer/editor/runtime/index.ts index d3e81a3c..665ff10b 100644 --- a/src/renderer/editor/runtime/index.ts +++ b/src/renderer/editor/runtime/index.ts @@ -12,3 +12,28 @@ export { persistPositions, persistPositionsWithFlag, } from './persistState'; + +export { + EditorSaveCoordinator, + applyEditorPatch, + createEditorCoordinator, + createEditorPatch, + getChangedEditorFields, + rebaseEditorDocument, +} from './editorCoordinator'; + +export type { + EditorApplyReason, + EditorConflictResolution, + EditorConflictState, + EditorCoordinatorOptions, + EditorCoordinatorPhase, + EditorCoordinatorState, + EditorCoordinatorTransport, +} from './editorCoordinator'; + +export { + applyEditorDocument, + captureEditorDocument, + editorCoordinator, +} from './editorStateCoordinator'; diff --git a/src/renderer/editor/runtime/persistState.ts b/src/renderer/editor/runtime/persistState.ts index 1d3be0f5..4bb41a0c 100644 --- a/src/renderer/editor/runtime/persistState.ts +++ b/src/renderer/editor/runtime/persistState.ts @@ -5,7 +5,7 @@ import type { KeyMappings, KeyPositions } from '@src/types/key/keys'; /** - * positions 변경을 로컬 스토어 + 백엔드 + 오버레이에 반영 + * positions 변경을 로컬 스토어 + 백엔드에 반영 * z-order 이동 등에서 공통 사용 */ export async function persistPositionsWithSync( @@ -18,9 +18,6 @@ export async function persistPositionsWithSync( try { await window.api.keys.updatePositions(updatedPositions); - window.api.bridge.sendTo('overlay', 'positions:sync', { - positions: updatedPositions, - }); } catch (error) { console.error('Failed to persist positions', error); } finally { diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.ts b/src/renderer/hooks/Grid/useGridCanvasActions.ts index bb68f69e..8377b23b 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.ts +++ b/src/renderer/hooks/Grid/useGridCanvasActions.ts @@ -122,13 +122,6 @@ async function persistStatPositions( } finally { store.setLocalUpdateInProgress(false); } - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: nextPositions, - }); - } catch { - /* 무시 */ - } } // Graph positions persist 헬퍼 @@ -146,13 +139,6 @@ async function persistGraphPositions( } finally { store.setLocalUpdateInProgress(false); } - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: nextPositions, - }); - } catch { - /* 무시 */ - } } // Knob positions persist 헬퍼 @@ -170,13 +156,6 @@ async function persistKnobPositions( } finally { store.setLocalUpdateInProgress(false); } - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: nextPositions, - }); - } catch { - /* 무시 */ - } } export interface CanvasActions { diff --git a/src/renderer/hooks/Grid/useGridResize.ts b/src/renderer/hooks/Grid/useGridResize.ts index 5140f6a0..f7d1e0ea 100644 --- a/src/renderer/hooks/Grid/useGridResize.ts +++ b/src/renderer/hooks/Grid/useGridResize.ts @@ -19,6 +19,8 @@ import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; import type { ElementBounds } from '@utils/grid/smartGuides'; +import type { EditorPatchV1 } from '@src/types/editor'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; interface ResizeHandle { id: string; @@ -895,13 +897,6 @@ export function useGridResize({ window.api.knobItems.updatePositions(nextPositions).catch((error) => { console.error('Failed to update knob positions after resize', error); }); - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: nextPositions, - }); - } catch { - // 무시 - } } else if (element.type === 'plugin') { // 플러그인 요소에 최종 크기 적용 const pluginStore = usePluginDisplayElementStore.getState(); @@ -957,6 +952,7 @@ export function useGridResize({ const knobStore = useKnobItemStore.getState(); const knobPositions = knobStore.positions; const currentKnobs = knobPositions[selectedKeyType] || []; + const editorChanges: EditorPatchV1 = { schemaVersion: 1 }; // 프리뷰 값을 그대로 사용 (스냅은 이미 드래그 중에 적용됨) // 추가 스냅 적용 시 프리뷰와 최종 위치가 달라지는 문제 발생 @@ -986,14 +982,7 @@ export function useGridResize({ }), }; setPositions(nextPositions); - - // 백엔드에 저장 - window.api.keys.updatePositions(nextPositions).catch((error) => { - console.error( - 'Failed to update key positions after group resize', - error, - ); - }); + editorChanges.keyPositions = nextPositions; } // 통계 요소들 업데이트 @@ -1022,14 +1011,7 @@ export function useGridResize({ }; statStore.setPositions(nextStatPositions); - window.api.statItems - .updatePositions(nextStatPositions) - .catch((error) => { - console.error( - 'Failed to update stat positions after group resize', - error, - ); - }); + editorChanges.statPositions = nextStatPositions; } // 그래프 요소들 업데이트 @@ -1059,14 +1041,7 @@ export function useGridResize({ }; graphStore.setPositions(nextGraphPositions); - window.api.graphItems - .updatePositions(nextGraphPositions) - .catch((error) => { - console.error( - 'Failed to update graph positions after group resize', - error, - ); - }); + editorChanges.graphPositions = nextGraphPositions; } // 노브 요소들 업데이트 @@ -1095,21 +1070,7 @@ export function useGridResize({ }; knobStore.setPositions(nextKnobPositions); - window.api.knobItems - .updatePositions(nextKnobPositions) - .catch((error) => { - console.error( - 'Failed to update knob positions after group resize', - error, - ); - }); - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: nextKnobPositions, - }); - } catch { - // 무시 - } + editorChanges.knobPositions = nextKnobPositions; } // 플러그인 요소들 업데이트 @@ -1126,6 +1087,12 @@ export function useGridResize({ }, }); } + + if (Object.keys(editorChanges).length > 1) { + void editorCoordinator.commitPatch(editorChanges).catch((error) => { + console.error('Failed to persist group resize', error); + }); + } } // 프리뷰 상태 클리어 diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index 9320d558..bd604db4 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -40,6 +40,8 @@ import { findPasteAnchorIndex, applyZIndexToLayerOrder, } from '@utils/layerGroupUtils'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; interface UseGridSelectionParams { selectedElements: SelectedElement[]; @@ -78,76 +80,30 @@ export function useGridSelection({ const clipboard = useGridSelectionStore((state) => state.clipboard); const setClipboard = useGridSelectionStore((state) => state.setClipboard); - // 선택된 요소들의 위치를 오버레이에 동기화하는 함수 + // 선택된 요소들의 최종 위치를 한 번에 저장 const syncSelectedElementsToOverlay = () => { - // 키 위치 동기화 const currentPositions = useKeyStore.getState().positions; - window.api.keys.updatePositions(currentPositions).catch((error: Error) => { - console.error('Failed to sync key positions to overlay', error); - }); - try { - window.api.bridge.sendTo('overlay', 'positions:sync', { - positions: currentPositions, - }); - } catch (error) { - console.error('Failed to broadcast key positions to overlay', error); - } - - // 통계 요소 위치 동기화 const currentStatPositions = useStatItemStore.getState().positions; - window.api.statItems - .updatePositions(currentStatPositions) - .catch((error: Error) => { - console.error('Failed to sync stat positions to overlay', error); - }); - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: currentStatPositions, - }); - } catch (error) { - console.error('Failed to broadcast stat positions to overlay', error); - } - - // 그래프 요소 위치 동기화 const currentGraphPositions = useGraphItemStore.getState().positions; - window.api.graphItems - .updatePositions(currentGraphPositions) - .catch((error: Error) => { - console.error('Failed to sync graph positions to overlay', error); - }); - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: currentGraphPositions, - }); - } catch (error) { - console.error('Failed to broadcast graph positions to overlay', error); - } - - // 노브 요소 위치 동기화 const currentKnobPositions = useKnobItemStore.getState().positions; - window.api.knobItems - .updatePositions(currentKnobPositions) + void editorCoordinator + .commitPatch({ + schemaVersion: 1, + keyPositions: currentPositions, + statPositions: currentStatPositions, + graphPositions: currentGraphPositions, + knobPositions: currentKnobPositions, + }) .catch((error: Error) => { - console.error('Failed to sync knob positions to overlay', error); - }); - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: currentKnobPositions, + console.error('Failed to persist selected element positions', error); }); - } catch (error) { - console.error('Failed to broadcast knob positions to overlay', error); - } // 플러그인 요소도 명시적으로 동기화 (드래그 종료 시 skipSync로 인해 동기화되지 않았을 수 있음) const currentPluginElements = usePluginDisplayElementStore.getState().elements; - try { - window.api.bridge.sendTo('overlay', 'plugin:displayElements:sync', { - elements: currentPluginElements, - }); - } catch (error) { - console.error('Failed to sync plugin elements to overlay', error); - } + sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { + elements: currentPluginElements, + }); }; // 선택된 요소들 일괄 이동 함수 (배치 업데이트) @@ -200,13 +156,6 @@ export function useGridSelection({ newPositions[selectedKeyType] = tabPositions; useKeyStore.getState().setPositions(newPositions); - - // syncToOverlay가 true일 때만 API 호출 (드래그 중에는 false) - if (syncToOverlay) { - window.api.keys.updatePositions(newPositions).catch((error: Error) => { - console.error('Failed to sync key positions to overlay', error); - }); - } } // 통계 요소 배치 업데이트 @@ -232,14 +181,6 @@ export function useGridSelection({ newStatPositions[selectedKeyType] = tabPositions; useStatItemStore.getState().setPositions(newStatPositions); - - if (syncToOverlay) { - window.api.statItems - .updatePositions(newStatPositions) - .catch((error: Error) => { - console.error('Failed to sync stat positions to overlay', error); - }); - } } // 그래프 요소 배치 업데이트 @@ -265,14 +206,6 @@ export function useGridSelection({ newGraphPositions[selectedKeyType] = tabPositions; useGraphItemStore.getState().setPositions(newGraphPositions); - - if (syncToOverlay) { - window.api.graphItems - .updatePositions(newGraphPositions) - .catch((error: Error) => { - console.error('Failed to sync graph positions to overlay', error); - }); - } } // 노브 요소 배치 업데이트 @@ -298,14 +231,6 @@ export function useGridSelection({ newKnobPositions[selectedKeyType] = tabPositions; useKnobItemStore.getState().setPositions(newKnobPositions); - - if (syncToOverlay) { - window.api.knobItems - .updatePositions(newKnobPositions) - .catch((error: Error) => { - console.error('Failed to sync knob positions to overlay', error); - }); - } } // 플러그인 요소 배치 업데이트 @@ -331,6 +256,10 @@ export function useGridSelection({ .getState() .setElements(newElements, { skipSync: !syncToOverlay }); } + + if (syncToOverlay) { + syncSelectedElementsToOverlay(); + } }; // 선택된 요소들 삭제 함수 (배치 삭제) @@ -403,24 +332,10 @@ export function useGridSelection({ [selectedKeyType]: posArray.filter((_, index) => !deleteSet.has(index)), }; - // 로컬 업데이트 플래그 설정 (백엔드 이벤트 무시) - useKeyStore.getState().setLocalUpdateInProgress(true); - // Atomic update: mappings, positions 동시 업데이트로 중간 상태 방지 useKeyStore .getState() .setKeyMappingsAndPositions(updatedMappings, updatedPositions); - - // API 동기화 (순차 실행으로 일관성 보장) - try { - await window.api.keys.update(updatedMappings); - await window.api.keys.updatePositions(updatedPositions); - } catch (error) { - console.error('Failed to delete keys', error); - } finally { - // 플래그 해제 - useKeyStore.getState().setLocalUpdateInProgress(false); - } } // 플러그인 요소 배치 삭제 @@ -443,24 +358,7 @@ export function useGridSelection({ [selectedKeyType]: tabPositions.filter((_, idx) => !deleteSet.has(idx)), }; - useStatItemStore.getState().setLocalUpdateInProgress(true); useStatItemStore.getState().setPositions(updatedPositions); - try { - await window.api.statItems.updatePositions(updatedPositions); - } catch (error) { - console.error('Failed to delete stat items', error); - } finally { - useStatItemStore.getState().setLocalUpdateInProgress(false); - } - - // 오버레이 동기화 (이벤트 레이스/누락 대비) - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: updatedPositions, - }); - } catch { - // 무시 - } } // 그래프 요소 배치 삭제 @@ -473,23 +371,7 @@ export function useGridSelection({ [selectedKeyType]: tabPositions.filter((_, idx) => !deleteSet.has(idx)), }; - useGraphItemStore.getState().setLocalUpdateInProgress(true); useGraphItemStore.getState().setPositions(updatedPositions); - try { - await window.api.graphItems.updatePositions(updatedPositions); - } catch (error) { - console.error('Failed to delete graph items', error); - } finally { - useGraphItemStore.getState().setLocalUpdateInProgress(false); - } - - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: updatedPositions, - }); - } catch { - // 무시 - } } // 노브 요소 배치 삭제 @@ -502,23 +384,7 @@ export function useGridSelection({ [selectedKeyType]: tabPositions.filter((_, idx) => !deleteSet.has(idx)), }; - useKnobItemStore.getState().setLocalUpdateInProgress(true); useKnobItemStore.getState().setPositions(updatedPositions); - try { - await window.api.knobItems.updatePositions(updatedPositions); - } catch (error) { - console.error('Failed to delete knob items', error); - } finally { - useKnobItemStore.getState().setLocalUpdateInProgress(false); - } - - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: updatedPositions, - }); - } catch { - // 무시 - } } const normalized = normalizeLayerGroupsForMode({ @@ -531,10 +397,6 @@ export function useGridSelection({ }); if (normalized.positionsChanged || normalized.groupsChanged) { - useKeyStore.getState().setLocalUpdateInProgress(true); - useStatItemStore.getState().setLocalUpdateInProgress(true); - useGraphItemStore.getState().setLocalUpdateInProgress(true); - useKnobItemStore.getState().setLocalUpdateInProgress(true); useKeyStore.getState().setPositions(normalized.keyPositions); useStatItemStore.getState().setPositions(normalized.statPositions); useGraphItemStore.getState().setPositions(normalized.graphPositions); @@ -542,22 +404,31 @@ export function useGridSelection({ if (normalized.groupsChanged) { useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); } + } + const hasEditorDeletion = + keysToDelete.length > 0 || + statsToDelete.length > 0 || + graphsToDelete.length > 0 || + knobsToDelete.length > 0; + if ( + hasEditorDeletion || + normalized.positionsChanged || + normalized.groupsChanged + ) { + const keyState = useKeyStore.getState(); try { - await window.api.keys.updatePositions(normalized.keyPositions); - await window.api.statItems.updatePositions(normalized.statPositions); - await window.api.graphItems.updatePositions(normalized.graphPositions); - await window.api.knobItems.updatePositions(normalized.knobPositions); - if (normalized.groupsChanged) { - await window.api.layerGroups.update(normalized.layerGroups); - } + await editorCoordinator.commitPatch({ + schemaVersion: 1, + ...(keysToDelete.length > 0 ? { keys: keyState.keyMappings } : {}), + keyPositions: keyState.positions, + statPositions: useStatItemStore.getState().positions, + graphPositions: useGraphItemStore.getState().positions, + knobPositions: useKnobItemStore.getState().positions, + layerGroups: useLayerGroupStore.getState().layerGroups, + }); } catch (error) { - console.error('Failed to normalize layer groups after delete', error); - } finally { - useKeyStore.getState().setLocalUpdateInProgress(false); - useStatItemStore.getState().setLocalUpdateInProgress(false); - useGraphItemStore.getState().setLocalUpdateInProgress(false); - useKnobItemStore.getState().setLocalUpdateInProgress(false); + console.error('Failed to persist selected element deletion', error); } } }; @@ -708,11 +579,6 @@ export function useGridSelection({ [selectedKeyType]: modeGroups, }; useLayerGroupStore.getState().setLayerGroups(updatedLayerGroups); - window.api.layerGroups - .update(updatedLayerGroups) - .catch((error: Error) => { - console.error('Failed to paste layer groups', error); - }); // 원본 그룹의 collapsed 상태 복원 for (const cg of clipboardGroups) { @@ -793,6 +659,9 @@ export function useGridSelection({ // 새로 추가된 요소들의 선택을 위한 인덱스 추적 const newSelectedElements: SelectedElement[] = []; + // 붙여넣은 키 매핑 — 저장은 zIndex 확정 후 마지막에 1회만 (중간 저장은 순서 역전·패딩 위험) + let pastedKeyMappings: KeyMappings | null = null; + // 키 추가 if (keysToAdd.length > 0) { const km = useKeyStore.getState().keyMappings; @@ -819,10 +688,7 @@ export function useGridSelection({ .getState() .setKeyMappingsAndPositions(updatedMappings, updatedPositions); - // 키 매핑 sync (동기 흐름을 깨지 않도록 fire-and-forget) - window.api.keys.update(updatedMappings).catch((error) => { - console.error('Failed to paste key mappings', error); - }); + pastedKeyMappings = updatedMappings; } // 통계 요소 추가 @@ -889,14 +755,6 @@ export function useGridSelection({ [selectedKeyType]: posArray, }; useKnobItemStore.getState().setPositions(updatedPositions); - window.api.knobItems.updatePositions(updatedPositions).catch(() => {}); - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: updatedPositions, - }); - } catch { - // 무시 - } } // 플러그인 요소 추가 @@ -993,51 +851,24 @@ export function useGridSelection({ } } - // 백엔드/오버레이 통합 동기화 (async — UI는 이미 반영됨) - useKeyStore.getState().setLocalUpdateInProgress(true); - useStatItemStore.getState().setLocalUpdateInProgress(true); - useGraphItemStore.getState().setLocalUpdateInProgress(true); - useKnobItemStore.getState().setLocalUpdateInProgress(true); + // 붙여넣기 전체를 한 revision으로 저장 try { - await window.api.keys.updatePositions(patch.keyPositions); - await window.api.statItems.updatePositions(patch.statPositions); - await window.api.graphItems.updatePositions(patch.graphPositions); - await window.api.knobItems.updatePositions(patch.knobPositions); + await editorCoordinator.commitPatch({ + schemaVersion: 1, + ...(pastedKeyMappings ? { keys: pastedKeyMappings } : {}), + keyPositions: patch.keyPositions, + statPositions: patch.statPositions, + graphPositions: patch.graphPositions, + knobPositions: patch.knobPositions, + layerGroups: useLayerGroupStore.getState().layerGroups, + }); } catch (error) { - console.error('Failed to sync zIndex after paste', error); - } finally { - useKeyStore.getState().setLocalUpdateInProgress(false); - useStatItemStore.getState().setLocalUpdateInProgress(false); - useGraphItemStore.getState().setLocalUpdateInProgress(false); - useKnobItemStore.getState().setLocalUpdateInProgress(false); + console.error('Failed to persist pasted elements', error); } - - try { - window.api.bridge.sendTo('overlay', 'positions:sync', { - positions: patch.keyPositions, - }); - } catch {} - try { - window.api.bridge.sendTo('overlay', 'statPositions:sync', { - positions: patch.statPositions, - }); - } catch {} - try { - window.api.bridge.sendTo('overlay', 'graphPositions:sync', { - positions: patch.graphPositions, - }); - } catch {} - try { - window.api.bridge.sendTo('overlay', 'knobPositions:sync', { - positions: patch.knobPositions, - }); - } catch {} - try { - const pluginEls = usePluginDisplayElementStore.getState().elements; - window.api.bridge.sendTo('overlay', 'plugin:displayElements:sync', { - elements: pluginEls, - }); - } catch {} + const pluginEls = usePluginDisplayElementStore.getState().elements; + sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { + elements: pluginEls, + }); }; return { diff --git a/src/renderer/hooks/app/useAppBootstrap.ts b/src/renderer/hooks/app/useAppBootstrap.ts index 9a040183..ecfa7c2d 100644 --- a/src/renderer/hooks/app/useAppBootstrap.ts +++ b/src/renderer/hooks/app/useAppBootstrap.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; @@ -19,10 +19,17 @@ import { } from '@stores/signals/keyCounterCache'; import { getUndoRedoInProgress } from '@api/pluginDisplayElements'; import { obsApi } from '@api/modules/obsApi'; -import { notifyLocaleChanged } from '@api/modules/shared'; +import { notifyLocaleChanged, subscribe } from '@api/modules/shared'; +import { + acknowledgeLifecycleAfterEditorFlush, + cancelLifecycleEditorFlush, +} from '@api/modules/appApi'; import { stableStringify } from '@utils/core/stableStringify'; +import { useTranslation } from '@contexts/useTranslation'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; import type { BootstrapPayload } from '@src/types/app'; -import type { KeyMappings, KeyPositions, CustomTab } from '@src/types/key/keys'; +import type { CustomTab } from '@src/types/key/keys'; +import type { EditorCoordinatorState } from '@src/renderer/editor/runtime/editorCoordinator'; import type { TabNoteOverrides } from '@src/types/settings/noteSettings'; import type { SettingsDiff, @@ -100,9 +107,18 @@ function buildSettingsSnapshot( // 앱 초기 구동 시 메인 스냅샷을 가져오고, // 이후 변경 이벤트를 구독해 Zustand 스토어를 최신 상태로 유지 export function useAppBootstrap() { + const { t } = useTranslation(); + const translationRef = useRef(t); + + useEffect(() => { + translationRef.current = t; + }, [t]); + useEffect(() => { let disposed = false; const isOverlayWindow = window.__dmn_window_type === 'overlay'; + let conflictDialogOpen = false; + let lastShownPermanentEditorError: unknown = null; // 키 표시 딜레이와 동기화를 위한 카운터 업데이트 지연 const counterDelayTimers = new Map< string, @@ -170,6 +186,107 @@ export function useAppBootstrap() { const { setAll, merge } = useSettingsStore.getState(); + const getEditorCopy = (key: string, korean: string, english: string) => { + const translated = translationRef.current(key); + if (translated && translated !== key) return translated; + return useSettingsStore.getState().language === 'ko' ? korean : english; + }; + + const handleEditorConflict = (state: EditorCoordinatorState) => { + if (disposed || !state.conflict || conflictDialogOpen) return; + conflictDialogOpen = true; + + const resolve = async () => { + try { + if (isOverlayWindow) { + await editorCoordinator.resolveConflict('acceptCanonical'); + return; + } + + let keepLocal = true; + try { + const confirm = window.api.ui?.dialog?.confirm; + if (typeof confirm === 'function') { + keepLocal = await confirm( + getEditorCopy( + 'editorConflict.message', + "현재 편집 내용과 외부 변경이 겹쳤습니다. '내 편집 유지'는 지금 편집한 내용을 다시 적용하고, '외부 변경 수용'은 저장되지 않은 내 편집을 취소합니다.", + "Your edit overlaps an external change. 'Keep My Edit' reapplies your edit, while 'Accept External Change' discards the unsaved local edit.", + ), + { + confirmText: getEditorCopy( + 'editorConflict.keepLocal', + '내 편집 유지', + 'Keep My Edit', + ), + cancelText: getEditorCopy( + 'editorConflict.acceptExternal', + '외부 변경 수용', + 'Accept External Change', + ), + }, + ); + } + } catch (dialogError) { + console.warn( + '편집 충돌 대화상자를 열지 못해 내 편집을 유지합니다', + dialogError, + ); + } + await editorCoordinator.resolveConflict( + keepLocal ? 'keepLocal' : 'acceptCanonical', + ); + } catch (error) { + console.error('편집 충돌 해결 실패', error); + } finally { + conflictDialogOpen = false; + const latest = editorCoordinator.getState(); + if (!disposed && latest.conflict) { + queueMicrotask(() => handleEditorConflict(latest)); + } + } + }; + + void resolve(); + }; + + const handleEditorFailure = (state: EditorCoordinatorState) => { + if ( + disposed || + isOverlayWindow || + state.failureKind !== 'permanent' || + !state.error || + state.error === lastShownPermanentEditorError + ) { + return; + } + + lastShownPermanentEditorError = state.error; + console.error( + '저장할 수 없는 편집 내용을 마지막 저장 상태로 되돌렸습니다', + state.error, + ); + void window.api.ui.dialog + .alert( + getEditorCopy( + 'editorSave.permanentFailure', + '저장할 수 없는 편집 내용이라 마지막으로 저장된 상태로 되돌렸습니다. 방금 변경한 값을 확인해 주세요.', + 'This edit could not be saved, so the editor was restored to the last saved state. Please check the value you just changed.', + ), + { + confirmText: getEditorCopy('common.ok', '확인', 'OK'), + }, + ) + .catch((error) => { + console.error('편집 저장 실패 안내를 표시하지 못했습니다', error); + }); + }; + + const handleEditorCoordinatorState = (state: EditorCoordinatorState) => { + handleEditorConflict(state); + handleEditorFailure(state); + }; + const finalizeBootstrap = () => useKeyStore.setState((state) => state.isBootstrapped ? state : { ...state, isBootstrapped: true }, @@ -229,7 +346,7 @@ export function useAppBootstrap() { let resyncInFlight = false; let resyncQueued = false; - // 모든 슬라이스를 "변경 시에만" 적용 — 동일 데이터 재적용으로 인한 참조 + // EditorDocument 밖의 슬라이스를 "변경 시에만" 적용 — 동일 데이터 재적용으로 인한 참조 // 변경이 overlay 키 이벤트 effect 재실행(키 하이라이트 리셋) 등 시각적 // 부작용을 유발하는 것을 방지 const applyResyncSnapshot = (bootstrap: BootstrapPayload) => { @@ -260,27 +377,13 @@ export function useAppBootstrap() { syncFontCSS(); } - // 키 스토어: 변경 슬라이스만 모아 단일 setState + // 탭 메타데이터는 EditorDocument 밖이므로 bootstrap으로 재동기화 // setSelectedKeyType 액션은 백엔드 setMode RPC를 역발사하므로 사용 금지 const keyState = useKeyStore.getState(); const keyChanges: { - keyMappings?: KeyMappings; - positions?: KeyPositions; customTabs?: CustomTab[]; selectedKeyType?: string; } = {}; - if ( - stableStringify(keyState.keyMappings) !== - stableStringify(bootstrap.keys) - ) { - keyChanges.keyMappings = bootstrap.keys; - } - if ( - stableStringify(keyState.positions) !== - stableStringify(bootstrap.positions) - ) { - keyChanges.positions = bootstrap.positions; - } if ( stableStringify(keyState.customTabs) !== stableStringify(bootstrap.customTabs) @@ -294,47 +397,6 @@ export function useAppBootstrap() { useKeyStore.setState((state) => ({ ...state, ...keyChanges })); } - // stat/graph/knob positions: 변경 시에만 적용 - const nextStat = bootstrap.statPositions ?? {}; - if ( - stableStringify(useStatItemStore.getState().positions) !== - stableStringify(nextStat) - ) { - useStatItemStore.setState((state) => ({ - ...state, - positions: nextStat, - })); - } - const nextGraph = bootstrap.graphPositions ?? {}; - if ( - stableStringify(useGraphItemStore.getState().positions) !== - stableStringify(nextGraph) - ) { - useGraphItemStore.setState((state) => ({ - ...state, - positions: nextGraph, - })); - } - const nextKnob = bootstrap.knobPositions ?? {}; - if ( - stableStringify(useKnobItemStore.getState().positions) !== - stableStringify(nextKnob) - ) { - useKnobItemStore.setState((state) => ({ - ...state, - positions: nextKnob, - })); - } - - // 레이어 그룹: payload 내장값 사용 (추가 RPC 불필요) - const nextGroups = bootstrap.layerGroups ?? {}; - if ( - stableStringify(useLayerGroupStore.getState().layerGroups) !== - stableStringify(nextGroups) - ) { - useLayerGroupStore.getState().setLayerGroups(nextGroups); - } - // 카운터: 캐시는 무조건(순수 데이터), 시그널은 동일 값 무통지라 안전. // 딜레이 타이머로 대기 중인 키는 스킵 — 타이머가 스냅샷보다 최신 // 이벤트 값을 들고 있어, 스냅샷으로 덮으면 과거 값이 깜빡임 @@ -362,6 +424,7 @@ export function useAppBootstrap() { const bootstrap = await window.api.app.bootstrap(); if (disposed) return; applyResyncSnapshot(bootstrap); + await editorCoordinator.sync(); } catch (error) { console.error('OBS 재동기화 실패', error); } @@ -400,19 +463,14 @@ export function useAppBootstrap() { ...state, positions: bootstrap.knobPositions ?? {}, })); + useLayerGroupStore + .getState() + .setLayerGroups(bootstrap.layerGroups ?? {}); applyCounterCacheSnapshot(bootstrap.keyCounters); if (isOverlayWindow) { applyCounterSnapshot(bootstrap.keyCounters); } - // 레이어 그룹 로드 - window.api.layerGroups - .get() - .then((groups) => { - useLayerGroupStore.getState().setLayerGroups(groups); - }) - .catch(() => {}); - // 탭별 노트 트랙 설정 오버라이드 로드 window.api.noteTab .getAll() @@ -426,6 +484,17 @@ export function useAppBootstrap() { // macOS 커서 시스템 초기화 (시스템 설정 반영) initializeCursorSystem().catch(() => {}); + // 정식 이벤트를 먼저 구독한 뒤 최신 revision을 다시 읽어 + // bootstrap 도중 발생한 편집도 빠짐없이 반영 + try { + await editorCoordinator.start(); + // 플러그인이 bootstrap보다 먼저 coordinator를 시작했어도 + // 늦게 도착한 bootstrap 스냅샷이 최신 편집 화면을 덮지 않게 재적용 + await editorCoordinator.sync({ reapply: true }); + } catch (error) { + console.error('편집 상태 초기화 실패', error); + } + finalizeBootstrap(); } catch (error) { console.error('초기 부트스트랩 실패', error); @@ -440,52 +509,36 @@ export function useAppBootstrap() { })(); const unsubscribers = [ + editorCoordinator.subscribe(handleEditorCoordinatorState), + subscribe<{ handshakeId: string; action: 'quit' | 'restart' }>( + 'app:close-requested', + ({ handshakeId, action }) => { + if (disposed) return; + void acknowledgeLifecycleAfterEditorFlush(handshakeId).catch( + (error) => { + console.error(`편집 상태 저장 후 ${action} 실패`, error); + void (async () => { + await cancelLifecycleEditorFlush(handshakeId).catch( + () => undefined, + ); + const overlay = await window.api.overlay + .get() + .catch(() => null); + await window.api.window.showMain(); + if (overlay?.visible) { + await window.api.overlay.setVisible(true); + } + })().catch((showError) => { + console.error('종료 취소 후 창 복원 실패', showError); + }); + }, + ); + }, + ), window.api.settings.onChanged((diff: SettingsDiff) => { if (disposed || !diff) return; applyDiff(diff); }), - window.api.keys.onChanged((keys) => { - const isOverlayWindow = window.__dmn_window_type === 'overlay'; - if (!isOverlayWindow && useKeyStore.getState().isLocalUpdateInProgress) - return; - useKeyStore.setState((state) => ({ ...state, keyMappings: keys })); - }), - window.api.keys.onPositionsChanged((positions) => { - const isOverlayWindow = window.__dmn_window_type === 'overlay'; - if (!isOverlayWindow && useKeyStore.getState().isLocalUpdateInProgress) - return; - useKeyStore.setState((state) => ({ ...state, positions })); - }), - window.api.statItems.onPositionsChanged((positions) => { - const isOverlayWindow = window.__dmn_window_type === 'overlay'; - if ( - !isOverlayWindow && - useStatItemStore.getState().isLocalUpdateInProgress - ) - return; - useStatItemStore.setState((state) => ({ ...state, positions })); - }), - window.api.graphItems.onPositionsChanged((positions) => { - const isOverlayWindow = window.__dmn_window_type === 'overlay'; - if ( - !isOverlayWindow && - useGraphItemStore.getState().isLocalUpdateInProgress - ) - return; - useGraphItemStore.setState((state) => ({ ...state, positions })); - }), - window.api.knobItems.onPositionsChanged((positions) => { - const isOverlayWindow = window.__dmn_window_type === 'overlay'; - if ( - !isOverlayWindow && - useKnobItemStore.getState().isLocalUpdateInProgress - ) - return; - useKnobItemStore.setState((state) => ({ ...state, positions })); - }), - window.api.layerGroups.onChanged((groups) => { - useLayerGroupStore.getState().setLayerGroups(groups); - }), window.api.keys.onModeChanged(({ mode }) => { useKeyStore.setState((state) => ({ ...state, selectedKeyType: mode })); }), @@ -529,23 +582,9 @@ export function useAppBootstrap() { if (disposed) return; useKeyStore.setState((state) => ({ ...state, - keyMappings: snapshot.keys, - positions: snapshot.positions, customTabs: snapshot.customTabs, selectedKeyType: snapshot.selectedKeyType, })); - useStatItemStore.setState((state) => ({ - ...state, - positions: snapshot.statPositions, - })); - useGraphItemStore.setState((state) => ({ - ...state, - positions: snapshot.graphPositions, - })); - useKnobItemStore.setState((state) => ({ - ...state, - positions: snapshot.knobPositions ?? {}, - })); useSettingsStore.setState({ tabNoteOverrides: snapshot.tabNoteOverrides, }); @@ -590,6 +629,8 @@ export function useAppBootstrap() { }), ]; + handleEditorCoordinatorState(editorCoordinator.getState()); + if (isOverlayWindow) { unsubscribers.push( window.api.keys.onCounterChanged(({ mode, key, count }) => { diff --git a/src/renderer/hooks/app/useCustomJsInjection.ts b/src/renderer/hooks/app/useCustomJsInjection.ts index 60163cda..20df326d 100644 --- a/src/renderer/hooks/app/useCustomJsInjection.ts +++ b/src/renderer/hooks/app/useCustomJsInjection.ts @@ -1,13 +1,14 @@ import { useEffect } from 'react'; import { createCustomJsRuntime } from '@src/renderer/plugins/runtime/customJsRuntime'; -export function useCustomJsInjection() { +export function useCustomJsInjection(enabled = true) { useEffect(() => { + if (!enabled) return; const runtime = createCustomJsRuntime(); runtime.initialize(); return () => { runtime.dispose(); }; - }, []); + }, [enabled]); } diff --git a/src/renderer/hooks/app/usePluginDisplayElementsResponder.ts b/src/renderer/hooks/app/usePluginDisplayElementsResponder.ts index 7c21cf18..3c893dc0 100644 --- a/src/renderer/hooks/app/usePluginDisplayElementsResponder.ts +++ b/src/renderer/hooks/app/usePluginDisplayElementsResponder.ts @@ -5,6 +5,7 @@ import { normalizeStateKeys, setPluginMenuRuntimeState, } from '@utils/plugin/pluginMenuRuntimeState'; +import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; // 다중 OBS 클라이언트 동시 재연결 시 요청 버스트를 응답 1회로 코얼레싱 const RESPOND_DEBOUNCE_MS = 100; @@ -22,11 +23,11 @@ export function usePluginDisplayElementsResponder() { timer = setTimeout(() => { timer = null; const elements = usePluginDisplayElementStore.getState().elements; - window.api.bridge - .sendTo('overlay', 'plugin:displayElements:sync', { elements }) - .catch(() => { - // OBS 모드 전환 찰나에 overlay 윈도우 부재로 실패 가능 — 무시 - }); + sendBridgeMessageBestEffort( + 'overlay', + 'plugin:displayElements:sync', + { elements }, + ); }, RESPOND_DEBOUNCE_MS); }, ); diff --git a/src/renderer/hooks/useKeyManager.ts b/src/renderer/hooks/useKeyManager.ts index 5376939b..d071c9fa 100644 --- a/src/renderer/hooks/useKeyManager.ts +++ b/src/renderer/hooks/useKeyManager.ts @@ -50,6 +50,7 @@ import { persistPositions, persistPositionsWithFlag, } from '@src/renderer/editor/runtime/persistState'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; type SelectedKey = { key: string; index: number } | null; @@ -744,7 +745,7 @@ export function useKeyManager() { const handleKeyBatchStyleUpdate = ( updates: Array<{ index: number } & Partial>, - options?: { skipHistory?: boolean }, + options?: { skipHistory?: boolean; deferSave?: boolean }, ) => { if (updates.length === 0) return; @@ -762,6 +763,10 @@ export function useKeyManager() { pushCurrentStateToHistory(); } previewHistorySavedRef.current = false; + if (options?.deferSave) { + setPositions(updatedPositions); + return; + } persistPositionsWithFlag( updatedPositions, setPositions, @@ -797,6 +802,20 @@ export function useKeyManager() { ) => { setUndoRedoInProgress(true); try { + try { + await editorCoordinator.flush(); + } catch (error) { + console.error(`Failed to flush before ${label}`, error); + return; + } + const baseRevision = editorCoordinator.getState().revision; + if (baseRevision === null) { + console.error( + `Failed to apply ${label}: editor revision is unavailable`, + ); + return; + } + const currentKeyState = useKeyStore.getState(); const currentStatPositions = useStatItemStore.getState().positions; const currentGraphPositions = useGraphItemStore.getState().positions; @@ -804,6 +823,7 @@ export function useKeyManager() { const currentPluginElements = usePluginDisplayElementStore.getState().elements; const currentLayerGroups = useLayerGroupStore.getState().layerGroups; + const historyBefore = useHistoryStore.getState(); const targetState = action({ keyMappings: currentKeyState.keyMappings, positions: currentKeyState.positions, @@ -815,22 +835,33 @@ export function useKeyManager() { }); if (targetState) { - applyRestoredStateToStores(targetState); - - applyRestoredPluginElements( - targetState.pluginElements as - | PluginDisplayElementInternal[] - | undefined, - currentPluginElements, - targetState.pluginElements - ? new Set(targetState.pluginElements.map((el) => el.fullId)) - : undefined, - ); - try { - await persistRestoredState(targetState); + await persistRestoredState(targetState, baseRevision); } catch (error) { + useHistoryStore.setState({ + past: historyBefore.past, + future: historyBefore.future, + }); console.error(`Failed to apply ${label}`, error); + return; + } + + try { + applyRestoredStateToStores(targetState); + applyRestoredPluginElements( + targetState.pluginElements as + | PluginDisplayElementInternal[] + | undefined, + currentPluginElements, + targetState.pluginElements + ? new Set(targetState.pluginElements.map((el) => el.fullId)) + : undefined, + ); + } catch (error) { + console.error(`Failed to refresh the ${label} view`, error); + void editorCoordinator.sync({ reapply: true }).catch((syncError) => { + console.error(`Failed to resync after ${label}`, syncError); + }); } } } finally { diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index ebe5676f..f6e17eb8 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -190,6 +190,14 @@ "save": "Apply", "cancel": "Cancel" }, + "editorConflict": { + "message": "Your edit overlaps a change from another window or plugin. Choose which version to keep.\n'Keep My Edit' reapplies your local edit. 'Accept External Change' discards the unsaved local edit.", + "keepLocal": "Keep My Edit", + "acceptExternal": "Accept External Change" + }, + "editorSave": { + "permanentFailure": "This edit could not be saved, so the editor was restored to the last saved state. Please check the value you just changed." + }, "common": { "confirm": "Confirm", "cancel": "Cancel", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 34db0186..0c0d1032 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -190,6 +190,14 @@ "save": "저장", "cancel": "취소" }, + "editorConflict": { + "message": "현재 편집 내용과 다른 창 또는 플러그인의 변경이 겹쳤습니다. 어느 내용을 남길지 선택해 주세요.\n'내 편집 유지'는 지금 편집한 내용을 다시 적용하고, '외부 변경 수용'은 저장되지 않은 내 편집을 취소합니다.", + "keepLocal": "내 편집 유지", + "acceptExternal": "외부 변경 수용" + }, + "editorSave": { + "permanentFailure": "저장할 수 없는 편집 내용이라 마지막으로 저장된 상태로 되돌렸습니다. 방금 변경한 값을 확인해 주세요." + }, "common": { "confirm": "확인", "cancel": "취소", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index e1f771e0..02c24e3c 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -190,6 +190,14 @@ "save": "Применить", "cancel": "Отмена" }, + "editorConflict": { + "message": "Ваше изменение конфликтует с изменением из другого окна или плагина. Выберите версию, которую нужно сохранить.\n«Сохранить моё изменение» повторно применит локальную правку, а «Принять внешнее изменение» отменит несохранённую локальную правку.", + "keepLocal": "Сохранить моё изменение", + "acceptExternal": "Принять внешнее изменение" + }, + "editorSave": { + "permanentFailure": "Это изменение не удалось сохранить, поэтому редактор вернулся к последнему сохранённому состоянию. Проверьте значение, которое вы только что изменили." + }, "common": { "confirm": "Подтвердить", "cancel": "Отмена", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index c307361c..faf3a1f5 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -190,6 +190,14 @@ "save": "應用", "cancel": "取消" }, + "editorConflict": { + "message": "目前編輯與其他視窗或外掛程式的變更發生衝突。請選擇要保留的版本。\n「保留我的編輯」會重新套用本機變更,「接受外部變更」會捨棄尚未儲存的本機編輯。", + "keepLocal": "保留我的編輯", + "acceptExternal": "接受外部變更" + }, + "editorSave": { + "permanentFailure": "此編輯無法儲存,編輯器已還原到上次儲存的狀態。請檢查您剛剛變更的值。" + }, "common": { "confirm": "確認", "cancel": "取消", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index e4a30592..b4877a0a 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -190,6 +190,14 @@ "save": "应用", "cancel": "取消" }, + "editorConflict": { + "message": "当前编辑与其他窗口或插件的更改发生冲突。请选择要保留的版本。\n“保留我的编辑”会重新应用本地更改,“接受外部更改”会放弃尚未保存的本地编辑。", + "keepLocal": "保留我的编辑", + "acceptExternal": "接受外部更改" + }, + "editorSave": { + "permanentFailure": "此编辑无法保存,编辑器已恢复到上次保存的状态。请检查您刚刚更改的值。" + }, "common": { "confirm": "确认", "cancel": "取消", diff --git a/src/renderer/plugins/runtime/api/defineElement.ts b/src/renderer/plugins/runtime/api/defineElement.ts index 44ece598..04844f4c 100644 --- a/src/renderer/plugins/runtime/api/defineElement.ts +++ b/src/renderer/plugins/runtime/api/defineElement.ts @@ -9,6 +9,7 @@ import { buildValidTabIdSet } from '@constants/keyModes'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { translatePluginMessage } from '@utils/plugin/pluginI18n'; +import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; import { removeDisplayElementsInternal } from '../displayElement/displayElementApi'; import { createPluginInstanceLifecycle, @@ -237,24 +238,15 @@ export const createDefineElement = (deps: DefineElementDependencies) => { get: (_target, prop: string | symbol) => { if (typeof prop !== 'string') return undefined; return (...args: unknown[]) => { - try { - window.api?.bridge?.sendTo( - 'overlay', - 'plugin:displayElement:invokeAction', - { - elementId, - action: prop, - args, - }, - ); - } catch (error) { - console.error( - `[Plugin ${pluginId}] Failed to invoke exposed action '${String( - prop, - )}'`, - error, - ); - } + sendBridgeMessageBestEffort( + 'overlay', + 'plugin:displayElement:invokeAction', + { + elementId, + action: prop, + args, + }, + ); }; }, }, diff --git a/src/renderer/plugins/runtime/api/defineSettings.ts b/src/renderer/plugins/runtime/api/defineSettings.ts index dafe98f3..56f59476 100644 --- a/src/renderer/plugins/runtime/api/defineSettings.ts +++ b/src/renderer/plugins/runtime/api/defineSettings.ts @@ -6,6 +6,7 @@ import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; import { translatePluginMessage } from '@utils/plugin/pluginI18n'; +import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; import { SECTION_WRAPPER_CLASS, SECTION_LABEL_CLASS, @@ -100,7 +101,7 @@ export const createDefineSettings = (deps: DefineSettingsDependencies) => { // JSON 직렬화/역직렬화로 순수 데이터만 복사 (순환 참조 및 특수 객체 제거) const safeSettings = JSON.parse(JSON.stringify(newSettings)); - window.api?.bridge?.sendTo('overlay', 'plugin:settings:changed', { + sendBridgeMessageBestEffort('overlay', 'plugin:settings:changed', { pluginId, settings: safeSettings, }); diff --git a/src/renderer/plugins/runtime/customJsRuntime.ts b/src/renderer/plugins/runtime/customJsRuntime.ts index c8124daa..45c3376d 100644 --- a/src/renderer/plugins/runtime/customJsRuntime.ts +++ b/src/renderer/plugins/runtime/customJsRuntime.ts @@ -14,6 +14,7 @@ import { usePluginMenuStore } from '@stores/plugin/usePluginMenuStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; +import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; import { extractPluginId } from '@utils/plugin/pluginUtils'; import { handlerRegistry } from './handlers'; import { @@ -110,11 +111,9 @@ export function createCustomJsRuntime(): CustomJsRuntime { usePluginDisplayElementStore.getState().setElements([]); displayElementInstanceRegistry.clearAll(); - if (window.api?.bridge) { - window.api.bridge.sendTo('overlay', 'plugin:displayElements:sync', { - elements: [], - }); - } + sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { + elements: [], + }); } catch (error) { console.error('Failed to clear plugin UI elements', error); } @@ -138,11 +137,11 @@ export function createCustomJsRuntime(): CustomJsRuntime { usePluginDisplayElementStore.getState().clearByPluginId(pluginId); displayElementInstanceRegistry.clearByPluginId(pluginId); - if (window.api?.bridge) { - window.api.bridge.sendTo('overlay', 'plugin:displayElements:sync', { - elements: usePluginDisplayElementStore.getState().elements, - }); - } + sendBridgeMessageBestEffort( + 'overlay', + 'plugin:displayElements:sync', + { elements: usePluginDisplayElementStore.getState().elements }, + ); } catch (error) { console.error( `Failed to clear UI elements for plugin '${pluginId}'`, diff --git a/src/renderer/stores/data/useHistoryStore.test.ts b/src/renderer/stores/data/useHistoryStore.test.ts index 9e9c7272..8d465eb0 100644 --- a/src/renderer/stores/data/useHistoryStore.test.ts +++ b/src/renderer/stores/data/useHistoryStore.test.ts @@ -22,6 +22,7 @@ import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { StatItemPositions } from '@src/types/key/statItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; const createMappings = (key: string): KeyMappings => ({ '4key': [key], @@ -75,6 +76,7 @@ const resetStores = () => { isLocalUpdateInProgress: false, }); useKnobItemStore.setState({ positions: {} }); + useLayerGroupStore.setState({ layerGroups: {} }); applyCounterCacheSnapshot({}); }; @@ -274,6 +276,20 @@ describe('useHistoryStore', () => { ); }); + it('layerGroups 미제공 시 현재 group store에서 자동 캡처', () => { + const groups = { '4key': [{ id: 'group-1', name: 'Group 1' }] }; + useLayerGroupStore.setState({ layerGroups: groups }); + + useHistoryStore.getState().pushState({ + keyMappings: createMappings('A'), + positions: createPositions(1), + statPositions: EMPTY_STATS, + graphPositions: EMPTY_GRAPHS, + }); + + expect(useHistoryStore.getState().past[0]?.layerGroups).toEqual(groups); + }); + it('undo는 이전 knobPositions 스냅샷을 반환하고 현재 knob을 future에 저장', () => { useHistoryStore.getState().pushState({ keyMappings: createMappings('A'), diff --git a/src/renderer/stores/data/useHistoryStore.ts b/src/renderer/stores/data/useHistoryStore.ts index 2ca85451..750b11b6 100644 --- a/src/renderer/stores/data/useHistoryStore.ts +++ b/src/renderer/stores/data/useHistoryStore.ts @@ -12,6 +12,7 @@ import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import type { LayerGroups } from '@src/types/layerGroups'; import type { NoteSettings, @@ -47,7 +48,7 @@ export interface HistoryState { graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; pluginElements?: SerializablePluginElement[]; - layerGroups?: LayerGroups; + layerGroups: LayerGroups; keyCounters: KeyCounters; customTabs: CustomTab[]; selectedKeyType: string; @@ -136,6 +137,10 @@ function buildHistoryState( 'knobPositions' in input && input.knobPositions ? input.knobPositions : useKnobItemStore.getState().positions; + const layerGroups = + 'layerGroups' in input && input.layerGroups + ? input.layerGroups + : useLayerGroupStore.getState().layerGroups; return { keyMappings: JSON.parse(JSON.stringify(input.keyMappings)), @@ -146,9 +151,7 @@ function buildHistoryState( pluginElements: input.pluginElements ? serializePluginElements(input.pluginElements) : undefined, - layerGroups: input.layerGroups - ? JSON.parse(JSON.stringify(input.layerGroups)) - : undefined, + layerGroups: JSON.parse(JSON.stringify(layerGroups)), keyCounters: includeCounters && 'keyCounters' in input && input.keyCounters ? JSON.parse(JSON.stringify(input.keyCounters)) diff --git a/src/renderer/stores/plugin/usePluginDisplayElementStore.ts b/src/renderer/stores/plugin/usePluginDisplayElementStore.ts index d292d1fc..579ac783 100644 --- a/src/renderer/stores/plugin/usePluginDisplayElementStore.ts +++ b/src/renderer/stores/plugin/usePluginDisplayElementStore.ts @@ -3,6 +3,7 @@ import { PluginDisplayElementInternal, PluginDefinitionInternal, } from '@src/types/plugin/api'; +import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; import { useKeyStore } from '../data/useKeyStore'; // syncToOverlay 쓰로틀링을 위한 변수 @@ -366,15 +367,9 @@ export const usePluginDisplayElementStore = create( // 메인 윈도우에서 오버레이로 동기화 (즉시 실행) function syncToOverlay(elements: PluginDisplayElementInternal[]) { - try { - if (window.api?.bridge) { - window.api.bridge.sendTo('overlay', 'plugin:displayElements:sync', { - elements, - }); - } - } catch (error) { - console.error('[DisplayElement Store] Failed to sync to overlay:', error); - } + sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { + elements, + }); } // 쓰로틀링된 동기화 (빈번한 호출 방지) diff --git a/src/renderer/utils/grid/groupActions.ts b/src/renderer/utils/grid/groupActions.ts index 94af3687..a115f582 100644 --- a/src/renderer/utils/grid/groupActions.ts +++ b/src/renderer/utils/grid/groupActions.ts @@ -11,6 +11,7 @@ import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useHistoryStore } from '@stores/data/useHistoryStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; import { applyGroupIdToSelectedElements, buildNextLayerGroupName, @@ -104,26 +105,22 @@ export async function groupSelectedElements( useStatItemStore.getState().setPositions(normalized.statPositions); useGraphItemStore.getState().setPositions(normalized.graphPositions); useKnobItemStore.getState().setPositions(normalized.knobPositions); - - // API 동기화 - await Promise.all([ - window.api.keys.updatePositions(normalized.keyPositions).catch(() => {}), - window.api.statItems - .updatePositions(normalized.statPositions) - .catch(() => {}), - window.api.graphItems - .updatePositions(normalized.graphPositions) - .catch(() => {}), - window.api.knobItems - .updatePositions(normalized.knobPositions) - .catch(() => {}), - ]); - if (createdGroup || normalized.groupsChanged) { useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); - await window.api.layerGroups.update(normalized.layerGroups).catch(() => {}); } + // 참조와 그룹 정의를 같은 revision으로 저장 + await editorCoordinator + .commitPatch({ + schemaVersion: 1, + keyPositions: normalized.keyPositions, + statPositions: normalized.statPositions, + graphPositions: normalized.graphPositions, + knobPositions: normalized.knobPositions, + layerGroups: normalized.layerGroups, + }) + .catch(() => {}); + return true; } @@ -181,25 +178,20 @@ export async function ungroupSelectedElements( useStatItemStore.getState().setPositions(normalized.statPositions); useGraphItemStore.getState().setPositions(normalized.graphPositions); useKnobItemStore.getState().setPositions(normalized.knobPositions); - - // API 동기화 - await Promise.all([ - window.api.keys.updatePositions(normalized.keyPositions).catch(() => {}), - window.api.statItems - .updatePositions(normalized.statPositions) - .catch(() => {}), - window.api.graphItems - .updatePositions(normalized.graphPositions) - .catch(() => {}), - window.api.knobItems - .updatePositions(normalized.knobPositions) - .catch(() => {}), - ]); - if (normalized.groupsChanged) { useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); - await window.api.layerGroups.update(normalized.layerGroups).catch(() => {}); } + await editorCoordinator + .commitPatch({ + schemaVersion: 1, + keyPositions: normalized.keyPositions, + statPositions: normalized.statPositions, + graphPositions: normalized.graphPositions, + knobPositions: normalized.knobPositions, + layerGroups: normalized.layerGroups, + }) + .catch(() => {}); + return true; } diff --git a/src/renderer/utils/plugin/bridgeMessages.test.ts b/src/renderer/utils/plugin/bridgeMessages.test.ts new file mode 100644 index 00000000..16c2b92f --- /dev/null +++ b/src/renderer/utils/plugin/bridgeMessages.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sendBridgeMessageBestEffort } from './bridgeMessages'; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('sendBridgeMessageBestEffort', () => { + const originalApi = window.api; + const sendTo = vi.fn(); + + beforeEach(() => { + sendTo.mockReset(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + window.api = { + bridge: { sendTo }, + } as unknown as Window['api']; + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.api = originalApi; + }); + + it('sends an internal bridge message', async () => { + sendTo.mockResolvedValue(undefined); + + sendBridgeMessageBestEffort('overlay', 'test:sync', { value: 1 }); + await flushPromises(); + + expect(sendTo).toHaveBeenCalledWith('overlay', 'test:sync', { value: 1 }); + expect(console.error).not.toHaveBeenCalled(); + }); + + it('ignores an absent optional target window', async () => { + sendTo.mockRejectedValue("Window 'overlay' not found"); + + sendBridgeMessageBestEffort('overlay', 'test:sync'); + await flushPromises(); + + expect(console.error).not.toHaveBeenCalled(); + }); + + it('reports other asynchronous bridge failures', async () => { + const error = new Error('bridge unavailable'); + sendTo.mockRejectedValue(error); + + sendBridgeMessageBestEffort('main', 'test:sync'); + await flushPromises(); + + expect(console.error).toHaveBeenCalledWith( + "[Bridge] Failed to send 'test:sync' to 'main':", + error, + ); + }); + + it('reports synchronous bridge failures', () => { + const error = new Error('serialization failed'); + sendTo.mockImplementation(() => { + throw error; + }); + + expect(() => + sendBridgeMessageBestEffort('overlay', 'test:sync'), + ).not.toThrow(); + expect(console.error).toHaveBeenCalledWith( + "[Bridge] Failed to send 'test:sync' to 'overlay':", + error, + ); + }); +}); diff --git a/src/renderer/utils/plugin/bridgeMessages.ts b/src/renderer/utils/plugin/bridgeMessages.ts new file mode 100644 index 00000000..735c90fc --- /dev/null +++ b/src/renderer/utils/plugin/bridgeMessages.ts @@ -0,0 +1,29 @@ +import type { WindowTarget } from '@src/types/plugin/api'; + +const isMissingTargetWindow = ( + error: unknown, + target: WindowTarget, +): boolean => { + const message = error instanceof Error ? error.message : String(error); + return message.includes(`Window '${target}' not found`); +}; + +/** 선택적 창이 닫혀 있어도 내부 동기화 Promise를 안전하게 종료 */ +export const sendBridgeMessageBestEffort = ( + target: WindowTarget, + type: string, + data?: unknown, +): void => { + try { + const bridge = window.api?.bridge; + if (!bridge) return; + + void bridge.sendTo(target, type, data).catch((error: unknown) => { + if (isMissingTargetWindow(error, target)) return; + console.error(`[Bridge] Failed to send '${type}' to '${target}':`, error); + }); + } catch (error) { + if (isMissingTargetWindow(error, target)) return; + console.error(`[Bridge] Failed to send '${type}' to '${target}':`, error); + } +}; diff --git a/src/renderer/windows/main/App.tsx b/src/renderer/windows/main/App.tsx index e0a3fa73..63d466c9 100644 --- a/src/renderer/windows/main/App.tsx +++ b/src/renderer/windows/main/App.tsx @@ -58,7 +58,7 @@ export default function App() { const setGridAreaHovered = useUIStore((state) => state.setGridAreaHovered); const { selectedKeyType, setSelectedKeyType, isBootstrapped } = useKeyStore(); useCustomCssInjection(); - useCustomJsInjection(); + useCustomJsInjection(isBootstrapped); useAppBootstrap(); usePluginDisplayElementsResponder(); useBlockBrowserShortcuts(); diff --git a/src/renderer/windows/main/index.tsx b/src/renderer/windows/main/index.tsx index 8c173d1d..3e8e08cf 100644 --- a/src/renderer/windows/main/index.tsx +++ b/src/renderer/windows/main/index.tsx @@ -1,8 +1,3 @@ -import '@api/dmnoteApi'; -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App'; -import { I18nProvider } from '@contexts/I18nContext'; import '@styles/tokens.css'; import '@styles/global.css'; import '@styles/main.css'; @@ -10,9 +5,6 @@ import '@styles/main.css'; window.__dmn_window_type = 'main'; window.__dmn_runtime = 'tauri'; -const container = document.getElementById('root'); -if (!container) throw new Error('Root container not found'); - // 브라우저 컨텍스트 메뉴 비활성화 document.addEventListener( 'contextmenu', @@ -22,9 +14,23 @@ document.addEventListener( { capture: true }, ); -const root = createRoot(container); -root.render( - - - , -); +async function bootstrap() { + await import('@api/dmnoteApi'); + const [{ createRoot }, { I18nProvider }, { default: App }] = + await Promise.all([ + import('react-dom/client'), + import('@contexts/I18nContext'), + import('./App'), + ]); + const container = document.getElementById('root'); + if (!container) throw new Error('Root container not found'); + + const root = createRoot(container); + root.render( + + + , + ); +} + +void bootstrap(); diff --git a/src/renderer/windows/obs/index.tsx b/src/renderer/windows/obs/index.tsx index 8b2ad8c2..aa16ba7e 100644 --- a/src/renderer/windows/obs/index.tsx +++ b/src/renderer/windows/obs/index.tsx @@ -5,6 +5,9 @@ import '@styles/global.css'; import { initIpcShim, disposeIpcShim } from '@api/ipcShim'; async function bootstrap() { + window.__dmn_window_type = 'overlay'; + window.__dmn_runtime = 'obs'; + // URL 파라미터에서 WS 접속 정보 추출 const params = new URLSearchParams(window.location.search); const host = params.get('host') || window.location.hostname || '127.0.0.1'; @@ -19,11 +22,7 @@ async function bootstrap() { // 2. window.api 설치 (shim 위에서 동작) await import('@api/dmnoteApi'); - // 3. OBS 윈도우 타입 표시 - window.__dmn_window_type = 'overlay'; - window.__dmn_runtime = 'obs'; - - // 4. overlay/App.tsx를 I18nProvider로 래핑하여 렌더 + // 3. overlay/App.tsx를 I18nProvider로 래핑하여 렌더 const { I18nProvider } = await import('@contexts/I18nContext'); const { default: App } = await import('@src/renderer/windows/overlay/App'); diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index 52a37deb..1b0a9398 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -24,10 +24,6 @@ import { resetAllKeySignals, } from '@stores/signals/keySignals'; import { useSettingsStore } from '@stores/useSettingsStore'; -import type { KeyPosition } from '@src/types/key/keys'; -import type { StatItemPosition } from '@src/types/key/statItems'; -import type { GraphItemPosition } from '@src/types/key/graphItems'; -import type { KnobItemPosition } from '@src/types/key/knobs'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import OverlayScene from '@components/shared/OverlayScene'; import { computeLayout } from '@hooks/shared/useLayoutComputation'; @@ -39,8 +35,9 @@ type KeyDelayTimerEntry = { timers: Set> }; const MAX_EVENT_AGE_MS = 250; export default function App() { + const isBootstrapped = useKeyStore((state) => state.isBootstrapped); useCustomCssInjection(); - useCustomJsInjection(); + useCustomJsInjection(isBootstrapped); useAppBootstrap(); useBuiltinStatsSubscription(); useBlockBrowserShortcuts(); @@ -82,66 +79,6 @@ export default function App() { }; }, []); - // 메인에서 bridge를 통한 positions 동기화 수신 - useEffect(() => { - const unsubscribe = window.api.bridge.on<{ - positions: Record; - }>('positions:sync', (data) => { - if (data?.positions) { - useKeyStore.setState((state) => ({ - ...state, - positions: data.positions, - })); - } - }); - return () => unsubscribe(); - }, []); - - // 메인에서 bridge를 통한 statPositions 동기화 수신 - useEffect(() => { - const unsubscribe = window.api.bridge.on<{ - positions: Record; - }>('statPositions:sync', (data) => { - if (data?.positions) { - useStatItemStore.setState((state) => ({ - ...state, - positions: data.positions, - })); - } - }); - return () => unsubscribe(); - }, []); - - // 메인에서 bridge를 통한 graphPositions 동기화 수신 - useEffect(() => { - const unsubscribe = window.api.bridge.on<{ - positions: Record; - }>('graphPositions:sync', (data) => { - if (data?.positions) { - useGraphItemStore.setState((state) => ({ - ...state, - positions: data.positions, - })); - } - }); - return () => unsubscribe(); - }, []); - - // 메인에서 bridge를 통한 knobPositions 동기화 수신 - useEffect(() => { - const unsubscribe = window.api.bridge.on<{ - positions: Record; - }>('knobPositions:sync', (data) => { - if (data?.positions) { - useKnobItemStore.setState((state) => ({ - ...state, - positions: data.positions, - })); - } - }); - return () => unsubscribe(); - }, []); - const selectedKeyType = useKeyStore((state) => state.selectedKeyType); const keyMappings = useKeyStore((state) => state.keyMappings); const positions = useKeyStore((state) => state.positions); diff --git a/src/renderer/windows/overlay/index.tsx b/src/renderer/windows/overlay/index.tsx index 1c16414f..86404bd6 100644 --- a/src/renderer/windows/overlay/index.tsx +++ b/src/renderer/windows/overlay/index.tsx @@ -1,7 +1,3 @@ -import '@api/dmnoteApi'; -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { I18nProvider } from '@contexts/I18nContext'; import '@styles/tokens.css'; import '@styles/global.css'; @@ -10,7 +6,13 @@ window.__dmn_runtime = 'tauri'; async function bootstrap() { try { - const { default: App } = await import('./App'); + await import('@api/dmnoteApi'); + const [{ createRoot }, { I18nProvider }, { default: App }] = + await Promise.all([ + import('react-dom/client'), + import('@contexts/I18nContext'), + import('./App'), + ]); const container = document.getElementById('root')!; const root = createRoot(container); root.render( @@ -25,4 +27,4 @@ async function bootstrap() { } } -bootstrap(); +void bootstrap(); diff --git a/src/types/app.ts b/src/types/app.ts index 73c7b373..880ec1d9 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -35,4 +35,5 @@ export interface BootstrapPayload { layerGroups: LayerGroups; tabNoteOverrides: TabNoteOverrides; tabCssOverrides: TabCssOverrides; + editorRevision: number; } diff --git a/src/types/editor.ts b/src/types/editor.ts new file mode 100644 index 00000000..9751a770 --- /dev/null +++ b/src/types/editor.ts @@ -0,0 +1,479 @@ +import type { GraphItemPositions } from '@src/types/key/graphItems'; +import { + keyPositionSchema, + type CustomTab, + type KeyCounters, + type KeyMappings, + type KeyPositions, +} from '@src/types/key/keys'; +import type { KnobItemPositions } from '@src/types/key/knobs'; +import { + STAT_ITEM_TYPES, + type StatItemPositions, +} from '@src/types/key/statItems'; +import type { LayerGroups } from '@src/types/layerGroups'; +import type { SettingsPatchInput } from '@src/types/settings/settings'; +import type { TabNoteOverrides } from '@src/types/settings/noteSettings'; + +export const EDITOR_SCHEMA_VERSION = 1 as const; + +export const EDITOR_FIELDS = [ + 'keys', + 'keyPositions', + 'statPositions', + 'graphPositions', + 'knobPositions', + 'layerGroups', +] as const; + +export type EditorField = (typeof EDITOR_FIELDS)[number]; + +export interface EditorDocumentV1 { + schemaVersion: typeof EDITOR_SCHEMA_VERSION; + keys: KeyMappings; + keyPositions: KeyPositions; + statPositions: StatItemPositions; + graphPositions: GraphItemPositions; + knobPositions: KnobItemPositions; + layerGroups: LayerGroups; +} + +export type EditorPatchV1 = { + schemaVersion: typeof EDITOR_SCHEMA_VERSION; +} & Partial>; + +export interface EditorCommitRequest { + baseRevision: number; + mutationId: string; + changes: EditorPatchV1; +} + +export interface EditorCommitResult { + revision: number; + changedFields: EditorField[]; +} + +export interface EditorGetResult { + revision: number; + document: EditorDocumentV1; +} + +export interface EditorHistoryRestoreRequest { + baseRevision: number; + document: EditorDocumentV1; + customTabs: CustomTab[]; + selectedKeyType: string; + keyCounters?: KeyCounters; + settingsPatch?: SettingsPatchInput; + tabNoteOverrides?: TabNoteOverrides; +} + +export interface EditorCommittedV1 { + schemaVersion: typeof EDITOR_SCHEMA_VERSION; + revision: number; + mutationId: string; + origin?: string; + changedFields: EditorField[]; + patch: EditorPatchV1; +} + +export type EditorCommitErrorCode = + | 'REVISION_CONFLICT' + | 'VALIDATION_FAILED' + | 'PAIRED_UPDATE_REQUIRED' + | 'MUTATION_ID_REUSED' + | 'IO_ERROR'; + +export interface EditorCommitErrorDetails { + currentRevision?: number; + validationCode?: string; + field?: string; +} + +export interface EditorCommitError { + errorCode: EditorCommitErrorCode; + message: string; + details?: EditorCommitErrorDetails; + retryable: boolean; +} + +const EDITOR_ERROR_CODES = new Set([ + 'REVISION_CONFLICT', + 'VALIDATION_FAILED', + 'PAIRED_UPDATE_REQUIRED', + 'MUTATION_ID_REUSED', + 'IO_ERROR', +]); + +const EDITOR_FIELD_SET = new Set(EDITOR_FIELDS); +const EDITOR_PATCH_KEYS = new Set(['schemaVersion', ...EDITOR_FIELDS]); +const STAT_TYPES = new Set(STAT_ITEM_TYPES); +const GRAPH_TYPES = new Set(['line', 'bar']); + +// Rust Option 필드는 IPC에서 null로 직렬화되므로 검증할 때 "값 없음"으로 취급 +const NULLABLE_POSITION_FIELDS = new Set([ + 'activeImage', + 'inactiveImage', + 'soundEnabled', + 'soundPath', + 'soundVolume', + 'noteOpacityTop', + 'noteOpacityBottom', + 'noteBorderRadius', + 'noteWidth', + 'noteGlowOpacityTop', + 'noteGlowOpacityBottom', + 'noteGlowColor', + 'noteOffsetX', + 'noteOffsetY', + 'noteBorderWidth', + 'noteBorderColor', + 'noteBorderSide', + 'className', + 'zIndex', + 'backgroundColor', + 'activeBackgroundColor', + 'borderColor', + 'activeBorderColor', + 'borderWidth', + 'borderRadius', + 'fontSize', + 'fontColor', + 'activeFontColor', + 'graphAnimationEnabled', + 'fontFamily', + 'imageFit', + 'idleImageFit', + 'activeImageFit', + 'useInlineStyles', + 'displayText', + 'fontWeight', + 'fontItalic', + 'fontUnderline', + 'fontStrikethrough', + 'layerName', + 'groupId', +]); + +export class EditorProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = 'EditorProtocolError'; + } +} + +export function assertSafeEditorRevision( + value: unknown, + label = 'editor revision', +): asserts value is number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new EditorProtocolError( + `${label} must be a non-negative safe integer`, + ); + } +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +function assertModeRecord( + value: unknown, + label: string, + validateItem: (item: unknown, itemLabel: string) => void, +): asserts value is Record { + if (!isRecord(value)) { + throw new EditorProtocolError(`${label} must be an object`); + } + Object.entries(value).forEach(([mode, items]) => { + if (mode.length === 0 || !Array.isArray(items)) { + throw new EditorProtocolError(`${label}.${mode} must be an array`); + } + items.forEach((item, index) => + validateItem(item, `${label}.${mode}[${index}]`), + ); + }); +} + +const assertKeyMappings = (value: unknown, label: string): void => { + assertModeRecord(value, label, (item, itemLabel) => { + if (typeof item !== 'string') { + throw new EditorProtocolError(`${itemLabel} must be a string`); + } + }); +}; + +const normalizePositionForValidation = (value: unknown): unknown => { + if (!isRecord(value)) return value; + + return Object.fromEntries( + Object.entries(value).filter( + ([field, fieldValue]) => + fieldValue !== null || !NULLABLE_POSITION_FIELDS.has(field), + ), + ); +}; + +const assertPosition = (value: unknown, label: string): void => { + if ( + !keyPositionSchema.safeParse(normalizePositionForValidation(value)).success + ) { + throw new EditorProtocolError(`${label} is not a valid position`); + } +}; + +const assertStatPosition = (value: unknown, label: string): void => { + assertPosition(value, label); + if ( + !isRecord(value) || + typeof value.statType !== 'string' || + !STAT_TYPES.has(value.statType) + ) { + throw new EditorProtocolError(`${label}.statType is invalid`); + } +}; + +const assertGraphPosition = (value: unknown, label: string): void => { + assertStatPosition(value, label); + if ( + !isRecord(value) || + typeof value.graphType !== 'string' || + !GRAPH_TYPES.has(value.graphType) || + typeof value.graphColor !== 'string' || + typeof value.graphSpeed !== 'number' || + !Number.isSafeInteger(value.graphSpeed) || + value.graphSpeed < 0 || + value.graphSpeed > 4_294_967_295 + ) { + throw new EditorProtocolError(`${label} has invalid graph fields`); + } +}; + +const assertKnobPosition = (value: unknown, label: string): void => { + assertPosition(value, label); + if ( + !isRecord(value) || + typeof value.axisId !== 'string' || + typeof value.reverse !== 'boolean' || + typeof value.sensitivity !== 'number' || + !Number.isFinite(value.sensitivity) + ) { + throw new EditorProtocolError(`${label} has invalid knob fields`); + } +}; + +const assertLayerGroups = (value: unknown, label: string): void => { + assertModeRecord(value, label, (item, itemLabel) => { + if ( + !isRecord(item) || + typeof item.id !== 'string' || + item.id.length === 0 || + typeof item.name !== 'string' + ) { + throw new EditorProtocolError(`${itemLabel} is not a valid layer group`); + } + }); +}; + +const assertEditorCollections = ( + value: Record, + label: string, + partial: boolean, +): void => { + const validators: Record< + EditorField, + (value: unknown, label: string) => void + > = { + keys: assertKeyMappings, + keyPositions: (collection, collectionLabel) => + assertModeRecord(collection, collectionLabel, assertPosition), + statPositions: (collection, collectionLabel) => + assertModeRecord(collection, collectionLabel, assertStatPosition), + graphPositions: (collection, collectionLabel) => + assertModeRecord(collection, collectionLabel, assertGraphPosition), + knobPositions: (collection, collectionLabel) => + assertModeRecord(collection, collectionLabel, assertKnobPosition), + layerGroups: assertLayerGroups, + }; + + EDITOR_FIELDS.forEach((field) => { + if (value[field] === undefined) { + if (!partial) { + throw new EditorProtocolError(`${label}.${field} is missing`); + } + return; + } + validators[field](value[field], `${label}.${field}`); + }); +}; + +const assertPairedCollections = ( + value: Record, + label: string, +): void => { + const keys = value.keys as Record; + const positions = value.keyPositions as Record; + const keyModes = Object.keys(keys).sort(); + const positionModes = Object.keys(positions).sort(); + if ( + keyModes.length !== positionModes.length || + keyModes.some((mode, index) => mode !== positionModes[index]) + ) { + throw new EditorProtocolError( + `${label}.keys and ${label}.keyPositions must contain the same modes`, + ); + } + keyModes.forEach((mode) => { + if (keys[mode].length !== positions[mode].length) { + throw new EditorProtocolError( + `${label}.keys.${mode} and ${label}.keyPositions.${mode} must have the same length`, + ); + } + }); +}; + +const assertLayerGroupReferences = ( + value: Record, + label: string, +): void => { + const groupIdsByMode = new Map>(); + Object.entries(value.layerGroups as Record).forEach( + ([mode, groups]) => { + const ids = new Set(); + groups.forEach((group) => { + const id = (group as Record).id as string; + if (ids.has(id)) { + throw new EditorProtocolError( + `${label}.layerGroups.${mode} contains duplicate id '${id}'`, + ); + } + ids.add(id); + }); + groupIdsByMode.set(mode, ids); + }, + ); + + ( + [ + 'keyPositions', + 'statPositions', + 'graphPositions', + 'knobPositions', + ] as const + ).forEach((field) => { + Object.entries(value[field] as Record).forEach( + ([mode, positions]) => { + positions.forEach((position, index) => { + const groupId = (position as Record).groupId; + if (groupId === undefined || groupId === null) return; + if ( + typeof groupId !== 'string' || + groupId.length === 0 || + !groupIdsByMode.get(mode)?.has(groupId) + ) { + throw new EditorProtocolError( + `${label}.${field}.${mode}[${index}] references an unknown layer group`, + ); + } + }); + }, + ); + }); +}; + +export function assertEditorDocument( + value: unknown, + label = 'editor document', +): asserts value is EditorDocumentV1 { + if (!isRecord(value) || value.schemaVersion !== EDITOR_SCHEMA_VERSION) { + throw new EditorProtocolError(`${label} has an unsupported schema version`); + } + assertEditorCollections(value, label, false); + assertPairedCollections(value, label); + assertLayerGroupReferences(value, label); +} + +export function assertEditorPatch( + value: unknown, + label = 'editor patch', +): asserts value is EditorPatchV1 { + if (!isRecord(value) || value.schemaVersion !== EDITOR_SCHEMA_VERSION) { + throw new EditorProtocolError(`${label} has an unsupported schema version`); + } + const unknownKey = Object.keys(value).find( + (key) => !EDITOR_PATCH_KEYS.has(key), + ); + if (unknownKey) { + throw new EditorProtocolError(`${label}.${unknownKey} is not supported`); + } + assertEditorCollections(value, label, true); +} + +export function assertEditorFields( + value: unknown, + label = 'changedFields', +): asserts value is EditorField[] { + if ( + !Array.isArray(value) || + value.some( + (field) => typeof field !== 'string' || !EDITOR_FIELD_SET.has(field), + ) || + new Set(value).size !== value.length + ) { + throw new EditorProtocolError(`${label} contains an unknown editor field`); + } +} + +export function assertEditorGetResult(value: EditorGetResult): void { + if (!value) { + throw new EditorProtocolError('editor_get returned an empty result'); + } + assertSafeEditorRevision(value.revision, 'editor_get revision'); + assertEditorDocument(value.document, 'editor_get document'); +} + +export function assertEditorCommitResult(value: EditorCommitResult): void { + if (!value) { + throw new EditorProtocolError('editor_commit returned an empty result'); + } + assertSafeEditorRevision(value.revision, 'editor_commit revision'); + assertEditorFields(value.changedFields, 'editor_commit changedFields'); +} + +export function assertEditorCommittedEvent(value: EditorCommittedV1): void { + if (!value || value.schemaVersion !== EDITOR_SCHEMA_VERSION) { + throw new EditorProtocolError( + 'editor:committed has an unsupported schema version', + ); + } + assertSafeEditorRevision(value.revision, 'editor:committed revision'); + if (typeof value.mutationId !== 'string' || value.mutationId.length === 0) { + throw new EditorProtocolError('editor:committed mutationId is invalid'); + } + assertEditorFields(value.changedFields, 'editor:committed changedFields'); + assertEditorPatch(value.patch, 'editor:committed patch'); + const patchFields = EDITOR_FIELDS.filter( + (field) => value.patch[field] !== undefined, + ); + if ( + patchFields.length !== value.changedFields.length || + patchFields.some((field) => !value.changedFields.includes(field)) + ) { + throw new EditorProtocolError( + 'editor:committed patch does not match changedFields', + ); + } +} + +export function isEditorCommitError( + value: unknown, +): value is EditorCommitError { + if (!value || typeof value !== 'object') return false; + + const candidate = value as Partial; + return ( + typeof candidate.errorCode === 'string' && + EDITOR_ERROR_CODES.has(candidate.errorCode as EditorCommitErrorCode) && + typeof candidate.message === 'string' && + typeof candidate.retryable === 'boolean' + ); +} diff --git a/src/types/plugin/api.ts b/src/types/plugin/api.ts index 589e54a1..033761f5 100644 --- a/src/types/plugin/api.ts +++ b/src/types/plugin/api.ts @@ -12,6 +12,12 @@ import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; import type { LayerGroups } from '@src/types/layerGroups'; +import type { + EditorCommitRequest, + EditorCommitResult, + EditorCommittedV1, + EditorGetResult, +} from '@src/types/editor'; import { SettingsDiff, SettingsPatchInput, @@ -781,6 +787,11 @@ export interface DMNoteAPI { update(patch: SettingsPatchInput): Promise; onChanged(listener: (diff: SettingsDiff) => void): Unsubscribe; }; + editor: { + get(): Promise; + commit(request: EditorCommitRequest): Promise; + onCommitted(listener: (event: EditorCommittedV1) => void): ReadyUnsubscribe; + }; keys: { get(): Promise; getCounters(): Promise; From 5d37b14707f09381d009acb2c4a3a99d5a4d3b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 17:21:16 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=EB=8B=A4=ED=81=AC=20=ED=8C=94?= =?UTF-8?q?=EB=A0=88=ED=8A=B8=20=EC=8B=AC=ED=99=94=EC=99=80=20=EC=BA=94?= =?UTF-8?q?=EB=B2=84=EC=8A=A4=C2=B7=ED=81=AC=EB=A1=AC=20=EB=A7=88=EA=B0=90?= =?UTF-8?q?=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/tauri.conf.json | 2 +- .../Grid/PropertiesPanel/PropertyInputs.tsx | 2 +- .../main/Grid/core/GridBackground.tsx | 10 ++-- .../components/main/Modal/FloatingPopup.tsx | 2 +- .../content/pickers/CommonListPickerPage.tsx | 2 +- src/renderer/styles/main.css | 18 ++++-- src/renderer/styles/tokens.css | 59 ++++++++++--------- src/renderer/windows/main/index.html | 2 +- 8 files changed, 51 insertions(+), 46 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b340fdee..51c46d2f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,7 +32,7 @@ "shadow": true, "url": "main/index.html", "visible": false, - "backgroundColor": "#1A191E" + "backgroundColor": "#0B0B0D" } ], "security": { diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx index 5cbfaa3e..9853e222 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx @@ -901,7 +901,7 @@ export const FontStyleToggle: React.FC = ({ `w-[24px] h-[21px] flex items-center justify-center transition-colors duration-fast ${ active ? 'bg-fill-active text-fg' - : 'bg-inset text-fg-faint hover:bg-surface-hover hover:text-fg-muted' + : 'text-fg-faint hover:bg-surface-hover hover:text-fg-muted' }`; return ( diff --git a/src/renderer/components/main/Grid/core/GridBackground.tsx b/src/renderer/components/main/Grid/core/GridBackground.tsx index c31ea576..4b4fbb41 100644 --- a/src/renderer/components/main/Grid/core/GridBackground.tsx +++ b/src/renderer/components/main/Grid/core/GridBackground.tsx @@ -6,7 +6,6 @@ interface GridBackgroundProps { zoom?: number; panX?: number; panY?: number; - color?: string; lineColor?: string; } @@ -19,8 +18,7 @@ export const GridBackground: React.FC = ({ zoom = 1, panX = 0, panY = 0, - color: _color = '#141417', - lineColor = 'rgba(255, 255, 255, 0.05)', + lineColor = 'var(--ui-line-faint)', }) => { // 실제 화면에 그려질 그리드 크기 (줌 적용) const scaledGridSize = gridSize * zoom; @@ -56,13 +54,13 @@ export const GridBackground: React.FC = ({ x={offsetX} y={offsetY} > - {/* 수직선 */} + {/* 수직선 — CSS 변수 지원을 위해 stroke는 style로 지정 */} {/* 수평선 */} @@ -71,7 +69,7 @@ export const GridBackground: React.FC = ({ y1="0" x2={scaledGridSize} y2="0" - stroke={lineColor} + style={{ stroke: lineColor }} strokeWidth="1" /> diff --git a/src/renderer/components/main/Modal/FloatingPopup.tsx b/src/renderer/components/main/Modal/FloatingPopup.tsx index 3ad6923f..0f462719 100644 --- a/src/renderer/components/main/Modal/FloatingPopup.tsx +++ b/src/renderer/components/main/Modal/FloatingPopup.tsx @@ -454,7 +454,7 @@ const FloatingPopup = ({ left, top, }} - className={`${className}${animate ? ' tooltip-fade-in' : ''}`} + className={`${className}${animate ? ' animate-popup-fade' : ''}`} role={role} ariaLabel={ariaLabel} onMenuTab={onMenuTab} diff --git a/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx b/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx index 764cb117..b8c31bde 100644 --- a/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx +++ b/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx @@ -131,7 +131,7 @@ export default function CommonListPickerPage({ />
- {/* 리스트 웰 — 리세스드 테이블. 빈 공간도 테이블의 빈 영역으로 읽힘 */} + {/* 리스트 컨테이너 — 배경보다 한 단계 밝은 필 테이블. 빈 공간도 테이블의 빈 영역으로 읽힘 */}
* { + animation: tooltipFadeIn var(--ui-duration-fast) var(--ui-ease-out); +} + /* 스크림 틴트만 보간 — 종료 상태는 클래스 자연값 backdrop-filter 보간 금지: 애니메이션 내내 풀스크린 재필터 (Windows 렉) */ @keyframes modalScrim { diff --git a/src/renderer/styles/tokens.css b/src/renderer/styles/tokens.css index 2f803262..2b9bdead 100644 --- a/src/renderer/styles/tokens.css +++ b/src/renderer/styles/tokens.css @@ -3,53 +3,54 @@ * UI 색·모션·그림자의 단일 소스. 컴포넌트에서 hex 직접 사용 금지 */ :root { - /* 배경 — 어두운 순서: inset < app < panel < surface */ + /* 배경 — 어두운 순서: app < panel < surface. 다크에서 고도는 밝기로 표현되므로 단계 폭이 구분감을 결정 */ --ui-bg-app: #0b0b0d; - --ui-bg-panel: #141417; - --ui-bg-surface: #1d1d22; - --ui-bg-surface-hover: #24242a; - --ui-bg-surface-active: #2b2b32; - --ui-bg-elevated: #1f1f24; - /* 웰(파인 홈) — 반투명 블랙이라 글래스 위에서도 재질이 통과함. 불투명 표면 위 합성값은 기존 #101013와 거의 동일 */ - --ui-bg-inset: rgba(0, 0, 0, 0.3); + --ui-bg-panel: #17171b; + --ui-bg-surface: #222228; + --ui-bg-surface-hover: #2a2a31; + --ui-bg-surface-active: #313139; + --ui-bg-elevated: #24242b; + /* 컨트롤 필 — 반투명 화이트라 어느 표면 위서든 한 단계 밝게 뜸 + 근흑 배경에선 어두운 홈이 배경과 구분되지 않아 인풋·트랙은 밝은 필이 기준 */ + --ui-bg-inset: rgba(255, 255, 255, 0.08); /* 불투명 웰 — 코드 에디터처럼 배경이 흔들리면 안 되는 표면 전용 */ --ui-bg-inset-solid: #101013; - /* 글래스 표면 — 팝업·모달·패널 모두 동일한 스모크 재질(0.78), 캔버스 크롬만 더 투명(0.6) - 분리는 그림자 토큰의 인셋 링+상단 하이라이트가 담당, 보더 금지 */ - --ui-glass: rgba(14, 14, 17, 0.78); + /* 글래스 표면 — 스모크 재질. 팝업·패널 0.72, 모달은 가독 우선 0.78, 캔버스 크롬 0.6 + 알파 ↓ = 배경 투과 ↑ (글래스 체감의 1차 다이얼). 분리는 그림자 토큰의 인셋 링+상단 하이라이트가 담당, 보더 금지 */ + --ui-glass: rgba(14, 14, 17, 0.72); --ui-glass-heavy: rgba(14, 14, 17, 0.78); /* 캔버스 상주 크롬용 — 배경보다 한 톤 어두운 스모크 */ --ui-glass-dim: rgba(12, 12, 14, 0.6); /* 사이드 패널용 */ - --ui-glass-panel: rgba(14, 14, 17, 0.78); + --ui-glass-panel: rgba(14, 14, 17, 0.72); /* 상주 크롬 솔리드 — 사이드 패널·미니맵처럼 상시 떠 있는 표면은 라이브 블러 대신 고알파 틴트로 근사 (Windows에서 backdrop-filter가 매 프레임 면적 비례 재필터) 알파가 유일한 다이얼: 낮출수록 배경 투과 ↑, 글래스 질감은 인셋 링+하이라이트가 유지 */ --ui-glass-panel-solid: rgba(14, 14, 17, 0.96); --ui-glass-dim-solid: rgba(12, 12, 14, 0.94); /* 글래스 백드롭 클램프 — 틴트 전에 배경 밝기를 누르고, 죽은 채도는 보상 - dim ↓ = 밝은 배경 투과 억제 (어두운 배경에선 변화 미미) / sat = 색기운 유지량 */ - --ui-glass-backdrop-dim: 0.3; - --ui-glass-backdrop-sat: 1.15; + dim ↓ = 밝은 배경 투과 억제. 과하게 누르면 투과 변화 자체가 죽어 글래스가 안 읽힘 / sat = 색기운 유지량 */ + --ui-glass-backdrop-dim: 0.55; + --ui-glass-backdrop-sat: 1.5; /* 루미노시티 캡 — 투과 결과의 휘도를 어두운 쪽으로 강제 (색조는 유지) 0 = 끔, 1 = 완전 캡. 필터와 독립 동작하는 2차 안전망 */ - --ui-glass-backdrop-lum: 0.5; + --ui-glass-backdrop-lum: 0.35; /* 캡의 기준 휘도 — 글래스 틴트와 같은 근흑색 */ --ui-glass-backdrop-lum-tint: #0e0e11; /* 경계선 — 장식용 금지, 기능적 구획(바/플로팅 크롬/폼 컨트롤)에만 사용 */ - --ui-line: rgba(255, 255, 255, 0.08); + --ui-line: rgba(255, 255, 255, 0.1); --ui-line-strong: rgba(255, 255, 255, 0.14); /* 캔버스/스테이지 보조 격자선 — 메이저(--ui-line)보다 한 톤 흐림 */ - --ui-line-faint: rgba(255, 255, 255, 0.045); + --ui-line-faint: rgba(255, 255, 255, 0.06); /* 크롬 필 — 반투명 화이트 오버레이 상태 래더 (idle → hover → active) */ - --ui-fill: rgba(255, 255, 255, 0.06); - --ui-fill-hover: rgba(255, 255, 255, 0.09); - --ui-fill-active: rgba(255, 255, 255, 0.1); + --ui-fill: rgba(255, 255, 255, 0.08); + --ui-fill-hover: rgba(255, 255, 255, 0.11); + --ui-fill-active: rgba(255, 255, 255, 0.13); /* 정적 그룹 컨테이너용 — 인터랙션 없는 은은한 면 */ - --ui-fill-faint: rgba(255, 255, 255, 0.04); + --ui-fill-faint: rgba(255, 255, 255, 0.06); /* 텍스트 위계 */ --ui-fg: #edeef2; @@ -117,20 +118,20 @@ /* 그림자 — 보더 없는 표면 분리의 핵심. 2/3은 인셋 링+상단 하이라이트로 재질 립 포함 */ --ui-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35); - --ui-shadow-2: inset 0 0 0 1px rgba(255, 255, 255, 0.05), - inset 0 1px 0 0 rgba(255, 255, 255, 0.08), 0 8px 24px rgba(0, 0, 0, 0.45), + --ui-shadow-2: inset 0 0 0 1px rgba(255, 255, 255, 0.07), + inset 0 1px 0 0 rgba(255, 255, 255, 0.1), 0 8px 24px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.3); - --ui-shadow-3: inset 0 0 0 1px rgba(255, 255, 255, 0.05), - inset 0 1px 0 0 rgba(255, 255, 255, 0.08), 0 24px 64px rgba(0, 0, 0, 0.6), + --ui-shadow-3: inset 0 0 0 1px rgba(255, 255, 255, 0.07), + inset 0 1px 0 0 rgba(255, 255, 255, 0.1), 0 24px 64px rgba(0, 0, 0, 0.6), 0 8px 24px rgba(0, 0, 0, 0.35); /* 사이드 패널 — 도킹 경계인 좌측 엣지만 헤어라인, 좌측 방향성 드롭으로 캔버스와 분리 */ - --ui-shadow-panel: inset 1px 0 0 0 rgba(255, 255, 255, 0.06), + --ui-shadow-panel: inset 1px 0 0 0 rgba(255, 255, 255, 0.08), -12px 0 32px rgba(0, 0, 0, 0.35); /* 상주 크롬 — 인셋 링 + 상단 하이라이트가 보더·재질 담당, 드롭섀도는 흔적만 */ - --ui-shadow-chrome: inset 0 0 0 1px rgba(255, 255, 255, 0.05), - inset 0 1px 0 0 rgba(255, 255, 255, 0.08), 0 1px 3px rgba(0, 0, 0, 0.2); + --ui-shadow-chrome: inset 0 0 0 1px rgba(255, 255, 255, 0.07), + inset 0 1px 0 0 rgba(255, 255, 255, 0.1), 0 1px 3px rgba(0, 0, 0, 0.2); /* 포커스 링 — 보더 대신 그림자 링 */ --ui-focus-ring: 0 0 0 1.5px rgba(139, 92, 246, 0.55); diff --git a/src/renderer/windows/main/index.html b/src/renderer/windows/main/index.html index da642eb4..d7befa33 100644 --- a/src/renderer/windows/main/index.html +++ b/src/renderer/windows/main/index.html @@ -4,7 +4,7 @@ - +
From da5a99f7e84da9c2c4c87f2d7c9c9b2c02d4531f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 17:21:16 +0900 Subject: [PATCH 05/12] =?UTF-8?q?docs:=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=20API=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EC=99=80=20=ED=91=9C=EA=B8=B0=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/_meta.js | 1 + docs/content/en/api-reference/app/page.mdx | 15 +- docs/content/en/api-reference/editor/page.mdx | 240 ++++++++++++++++++ docs/content/en/api-reference/index/page.mdx | 1 + docs/content/en/api-reference/keys/page.mdx | 102 ++++++-- docs/content/en/api-reference/knobs/page.mdx | 12 + docs/content/ko/api-reference/_meta.js | 1 + docs/content/ko/api-reference/app/page.mdx | 15 +- docs/content/ko/api-reference/editor/page.mdx | 237 +++++++++++++++++ docs/content/ko/api-reference/index/page.mdx | 1 + docs/content/ko/api-reference/keys/page.mdx | 34 ++- docs/content/ko/api-reference/knobs/page.mdx | 12 + 12 files changed, 632 insertions(+), 39 deletions(-) create mode 100644 docs/content/en/api-reference/editor/page.mdx create mode 100644 docs/content/ko/api-reference/editor/page.mdx diff --git a/docs/content/en/api-reference/_meta.js b/docs/content/en/api-reference/_meta.js index 7b2712b2..6bf03ec7 100644 --- a/docs/content/en/api-reference/_meta.js +++ b/docs/content/en/api-reference/_meta.js @@ -2,6 +2,7 @@ export default { index: "Overview", app: "App", keys: "Keys", + editor: "Editor", knobs: "Knobs", settings: "Settings", overlay: "Overlay", diff --git a/docs/content/en/api-reference/app/page.mdx b/docs/content/en/api-reference/app/page.mdx index 05c2bf51..6f515764 100644 --- a/docs/content/en/api-reference/app/page.mdx +++ b/docs/content/en/api-reference/app/page.mdx @@ -9,6 +9,11 @@ description: dmn.app, dmn.window API reference App-level control including boot, restart, and external URLs. + + Automatic update is main-window only. Restart and quit wait for pending editor + writes in every native Tauri window before the process exits. + + ### `dmn.app.bootstrap(): Promise` Returns app bootstrap data (settings, keys, presets, etc.) @@ -31,12 +36,12 @@ Restarts the app. Settings are preserved. await dmn.app.restart(); ``` -### `dmn.app.openExternalUrl(url: string): Promise` +### `dmn.app.openExternal(url: string): Promise` Opens a URL in the system default browser. ```javascript -await dmn.app.openExternalUrl("https://dmstudio.app"); +await dmn.app.openExternal('https://dmstudio.app'); ``` --- @@ -50,7 +55,7 @@ Window management API. Returns the current window type. ```typescript -type WindowType = "main" | "overlay"; +type WindowType = 'main' | 'overlay'; console.log(dmn.window.type); // 'main' or 'overlay' ``` @@ -86,13 +91,13 @@ await dmn.window.openDevtoolsAll(); ```javascript // App info display dmn.plugin.defineElement({ - name: "App Info", + name: 'App Info', maxInstances: 1, template: (state, settings, { html }) => html`
Window: ${dmn.window.type}
-
Mode: ${state.mode ?? "loading..."}
+
Mode: ${state.mode ?? 'loading...'}
`, diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx new file mode 100644 index 00000000..d3c58871 --- /dev/null +++ b/docs/content/en/api-reference/editor/page.mdx @@ -0,0 +1,240 @@ +--- +title: Editor API +description: Atomic editor document reads, commits, and change events +--- + +# Editor API + + + OBS is read-only. Native Tauri main and overlay windows can write through the + revision coordinator; OBS can only read and subscribe to the canonical + document. + + +`dmn.editor` reads and updates the six collections that make up the editor +layout as one revisioned document. Use it when one user action changes more +than one collection, or when a consumer needs a single ordered change stream. + +## Document Types + +```typescript +type EditorField = + | 'keys' + | 'keyPositions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions' + | 'layerGroups'; + +interface EditorDocumentV1 { + schemaVersion: 1; + keys: KeyMappings; + keyPositions: KeyPositions; + statPositions: StatItemPositions; + graphPositions: GraphItemPositions; + knobPositions: KnobItemPositions; + layerGroups: LayerGroups; +} + +type EditorPatchV1 = { + schemaVersion: 1; +} & Partial>; +``` + +Each field included in an `EditorPatchV1` is the complete canonical value of +that top-level collection. It is not an item-level diff. + +## Read the Current Document + +### `dmn.editor.get(): Promise` + +Returns the current revision and its matching full document in one snapshot. + +```typescript +interface EditorGetResult { + revision: number; + document: EditorDocumentV1; +} + +const { revision, document } = await dmn.editor.get(); +console.log(revision, document.keyPositions); +``` + +Keep the returned `revision`. A later commit uses it as `baseRevision` so that +an older snapshot cannot silently overwrite a newer edit. + +## Commit Changes Atomically + +### `dmn.editor.commit(request): Promise` + +Merges `changes` into the document at `baseRevision`, validates the completed +document, and persists all included collections in one atomic replacement. A +rejected commit does not partially apply its changes. DM Note requests the +strongest file and directory durability barriers available on each supported +platform, while no application can guarantee recovery from hardware or +firmware that violates those barriers. + + + The app's Undo/Redo path also restores the six editor collections, custom-tab + metadata, selected mode, counters, preset settings, and per-tab note settings + in one backend store transaction. This internal command is intentionally not + part of the public plugin API. + + +```typescript +interface EditorCommitRequest { + baseRevision: number; + mutationId: string; + changes: EditorPatchV1; +} + +interface EditorCommitResult { + revision: number; + changedFields: EditorField[]; +} + +const snapshot = await dmn.editor.get(); +const result = await dmn.editor.commit({ + baseRevision: snapshot.revision, + mutationId: crypto.randomUUID(), + changes: { + schemaVersion: 1, + statPositions: nextStatPositions, + layerGroups: nextLayerGroups, + }, +}); + +console.log('Committed revision:', result.revision); +``` + +`mutationId` must be a UUID string no longer than 64 bytes. A recent request +retried with the same ID in the current app process is deduplicated. This is +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. + +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 +event once; retrying the same `mutationId` does not project it again. + +### Paired key structure changes + +`keys[mode][i]` and `keyPositions[mode][i]` describe the same item by index. +When an edit adds or removes a mode, or changes an array length, submit both +collections in the same commit. Their mode sets and lengths must match. + +```typescript +await dmn.editor.commit({ + baseRevision, + mutationId: crypto.randomUUID(), + changes: { + schemaVersion: 1, + keys: nextKeys, + keyPositions: nextKeyPositions, + }, +}); +``` + +A same-shape edit, such as changing a key label or a position property, may +update only its own collection. A shape-changing `keys`-only or +`keyPositions`-only request is rejected with `PAIRED_UPDATE_REQUIRED`. + +When using the compatibility `dmn.keys` API, call +`dmn.keys.updateWithPositions(keys, keyPositions)` for these structural +changes. Do not split them across `update()` and `updatePositions()`. + +## Commit Errors + +`commit()` rejects with this structured error. Branch on `errorCode`, not on +the human-readable `message`. + +```typescript +type EditorCommitErrorCode = + | 'REVISION_CONFLICT' + | 'VALIDATION_FAILED' + | 'PAIRED_UPDATE_REQUIRED' + | 'MUTATION_ID_REUSED' + | 'IO_ERROR'; + +interface EditorCommitError { + errorCode: EditorCommitErrorCode; + message: string; + details?: { + currentRevision?: number; + validationCode?: string; + field?: string; + }; + retryable: boolean; +} +``` + +| 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` | + +`details.currentRevision`, `details.validationCode`, or `details.field` is +included when it applies to the error. + +## Subscribe to Committed Changes + +### `dmn.editor.onCommitted(callback): ReadyUnsubscribe` + +Subscribes to the canonical `editor:committed` stream. One event represents +one successful atomic editor commit. + +```typescript +interface EditorCommittedV1 { + schemaVersion: 1; + revision: number; + mutationId: string; + origin?: string; + changedFields: EditorField[]; + patch: EditorPatchV1; +} + +const unsubscribe = dmn.editor.onCommitted((event) => { + console.log(event.revision, event.changedFields); + applyEditorPatch(event.patch); +}); + +await unsubscribe.ready; + +dmn.plugin.registerCleanup(() => { + unsubscribe(); +}); +``` + +The returned unsubscribe function has a `ready: Promise` property. Await +it when no event may be missed before taking a snapshot. + +Events can arrive before the matching `commit()` promise resolves. Use +`mutationId` and `revision` to avoid applying the same change twice. If an +observed revision skips one or more values, call `dmn.editor.get()` and replace +the local snapshot. Ignore unknown `origin` values and future unknown fields. + +## Legacy Change Events + + + The per-collection events below remain available with their existing payloads + for plugin compatibility, but they are deprecated for new editor state + synchronization. Use `dmn.editor.onCommitted()` so one multi-collection edit + is observed as one ordered change. + + +| Compatibility event | Replacement | +| ------------------------------------- | -------------------------- | +| `dmn.keys.onChanged()` | `dmn.editor.onCommitted()` | +| `dmn.keys.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.statItems.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.graphItems.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.knobItems.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.layerGroups.onChanged()` | `dmn.editor.onCommitted()` | + +Existing plugins do not need to migrate immediately. New synchronization code +should subscribe to only the canonical event stream instead of applying both +the canonical and compatibility events. diff --git a/docs/content/en/api-reference/index/page.mdx b/docs/content/en/api-reference/index/page.mdx index 9f7d76d7..2d723daf 100644 --- a/docs/content/en/api-reference/index/page.mdx +++ b/docs/content/en/api-reference/index/page.mdx @@ -15,6 +15,7 @@ All plugins access DM Note features through the `dmn` global object. | [`dmn.app`](/docs/api-reference/app) | App boot, restart, external URL | | [`dmn.window`](/docs/api-reference/app#window) | Window type, minimize, close | | [`dmn.keys`](/docs/api-reference/keys) | Key mapping, events, custom tabs | +| [`dmn.editor`](/docs/api-reference/editor) | Atomic editor state commits | | [`dmn.knobItems`](/docs/api-reference/knobs) | HID knob elements | | [`dmn.settings`](/docs/api-reference/settings) | App settings get/set | | [`dmn.overlay`](/docs/api-reference/overlay) | Overlay control | diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 030eaf03..93167d59 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -7,6 +7,11 @@ description: dmn.keys API reference for key events and mappings Key event subscription, mapping, and custom tab management. + + OBS is read-only. Native Tauri main and overlay windows can write through the + revision coordinator and subscribe to the same canonical events. + + ## Key State Events ### `dmn.keys.onKeyState(callback): ReadyUnsubscribe` @@ -117,40 +122,59 @@ const unsub = dmn.keys.onModeChanged(({ mode }) => { ## Key Mapping -### `dmn.keys.get(): Promise` +### `dmn.keys.get(): Promise` -Returns current key mapping and counter state. +Returns the key mappings for all modes. ```typescript -interface KeysState { - keyboard: KeyboardMapping; - mouse: MouseMapping; - counter: Record; -} +type KeyMappings = Record; -const keys = await dmn.keys.get(); -console.log(keys.keyboard); -console.log(keys.counter); +const mappings = await dmn.keys.get(); +console.log(mappings['4key']); ``` -### `dmn.keys.set(keys: Partial): Promise` +### `dmn.keys.update(mappings: KeyMappings): Promise` -Updates key mapping. +Replaces the key mappings and returns the committed value. -```javascript -await dmn.keys.set({ - keyboard: { - /* new mapping */ - }, -}); +```typescript +const mappings = await dmn.keys.get(); +mappings['4key'][0] = 'KeyS'; +const committed = await dmn.keys.update(mappings); +``` + +### `dmn.keys.getPositions(): Promise` + +Returns the key positions for all modes. + +```typescript +const positions = await dmn.keys.getPositions(); +console.log(positions['4key']); +``` + +### `dmn.keys.updatePositions(positions: KeyPositions): Promise` + +Replaces the key positions and returns the committed value. + +```typescript +const positions = await dmn.keys.getPositions(); +positions['4key'][0].dx = 100; +const committed = await dmn.keys.updatePositions(positions); ``` + + `keys[mode][i]` and `keyPositions[mode][i]` are coupled by index. A standalone + `update()` or `updatePositions()` may fail with `PAIRED_UPDATE_REQUIRED` if it + changes a mode set or an array length. Use `updateWithPositions()` for key + additions, removals, and reordering. + + ### `dmn.keys.updateWithPositions(mappings, positions): Promise` -Updates key mappings and their index-coupled positions in one durable commit. Use -this method whenever keys are added, removed, or reordered. The committed values -are returned, and both `keys:changed` and `positions:changed` are emitted after -the commit succeeds. +Updates key mappings and their index-coupled positions in one atomic commit. +The two values must have matching mode sets and array lengths. The committed +values are returned, and both `keys:changed` and `positions:changed` are emitted +after the commit succeeds. ```typescript interface KeysWithPositionsResult { @@ -161,6 +185,33 @@ interface KeysWithPositionsResult { const result = await dmn.keys.updateWithPositions(mappings, positions); ``` +### `dmn.keys.onChanged(callback): Unsubscribe` + +Subscribes to key mapping changes (`keys:changed`). + +```typescript +const unsubscribe = dmn.keys.onChanged((mappings) => { + console.log('key mappings changed', mappings); +}); +``` + +### `dmn.keys.onPositionsChanged(callback): Unsubscribe` + +Subscribes to key position changes (`positions:changed`). + +```typescript +const unsubscribe = dmn.keys.onPositionsChanged((positions) => { + console.log('key positions changed', positions); +}); +``` + + + These two per-collection events remain available for compatibility, but are + deprecated for new editor state synchronization. Use + [`dmn.editor.onCommitted()`](/docs/api-reference/editor#subscribe-to-committed-changes) + to observe atomic multi-collection edits. + + --- ## Custom Tab @@ -192,11 +243,14 @@ const unsub = dmn.keys.registerCustomTab({ ### `dmn.keys.customTabs.restore(customTabs, selectedKeyType): Promise` -Restores custom tabs and selected mode atomically. Syncs backend state, overlay, and main window. +Restores the names/order of existing custom tabs and the selected mode +atomically. Every tab ID must already have paired `keys` and `keyPositions`; +use `create`, `delete`, and `updateWithPositions` for topology changes. Invalid +or orphaned tab metadata is rejected without changing stored data. ```javascript await dmn.keys.customTabs.restore( - [{ id: 'custom-123', name: 'My Keys', keys: ['KeyA', 'KeyS'] }], + [{ id: 'custom-123', name: 'My Keys' }], 'custom-123', ); ``` diff --git a/docs/content/en/api-reference/knobs/page.mdx b/docs/content/en/api-reference/knobs/page.mdx index 6d2eb93d..5baeda52 100644 --- a/docs/content/en/api-reference/knobs/page.mdx +++ b/docs/content/en/api-reference/knobs/page.mdx @@ -5,6 +5,11 @@ description: dmn.knobItems API reference for HID knob elements # Knobs API + + OBS is read-only. Native Tauri main and overlay windows can write knob layout + changes through the revision coordinator. + + Manage knob elements — rotary visualization elements bound to HID device axes (e.g., rhythm game controller knobs). While HID device **buttons** are mapped and visualized exactly like keyboard keys, **axes** are visualized with @@ -83,6 +88,13 @@ await dmn.knobItems.updatePositions(positions); Subscribes to knob layout changes (`knobPositions:changed`). + + This event remains available for compatibility, but is deprecated for new + editor state synchronization. Use + [`dmn.editor.onCommitted()`](/docs/api-reference/editor#subscribe-to-committed-changes) + for the canonical atomic change stream. + + ```typescript const unsub = dmn.knobItems.onPositionsChanged((positions) => { console.log('knob layout changed', positions); diff --git a/docs/content/ko/api-reference/_meta.js b/docs/content/ko/api-reference/_meta.js index bcffa889..dcab18a2 100644 --- a/docs/content/ko/api-reference/_meta.js +++ b/docs/content/ko/api-reference/_meta.js @@ -2,6 +2,7 @@ export default { index: "개요", app: "앱 (app)", keys: "키 (keys)", + editor: "에디터 (editor)", knobs: "노브 (knobs)", settings: "설정 (settings)", overlay: "오버레이 (overlay)", diff --git a/docs/content/ko/api-reference/app/page.mdx b/docs/content/ko/api-reference/app/page.mdx index e0b43deb..d936082e 100644 --- a/docs/content/ko/api-reference/app/page.mdx +++ b/docs/content/ko/api-reference/app/page.mdx @@ -7,6 +7,11 @@ description: 앱 부팅, 재시작, 윈도우 제어 API ## dmn.app + + 자동 업데이트는 메인 창에서만 사용할 수 있습니다. 재시작과 종료는 모든 + 네이티브 Tauri 창의 편집 저장이 끝난 뒤 프로세스를 닫습니다. + + ### bootstrap() 앱 초기화 시 필요한 모든 데이터를 한 번에 가져옵니다. @@ -30,8 +35,8 @@ interface BootstrapPayload { ```javascript const bootstrap = await dmn.app.bootstrap(); -console.log("현재 모드:", bootstrap.selectedKeyType); -console.log("4key 매핑:", bootstrap.keys["4key"]); +console.log('현재 모드:', bootstrap.selectedKeyType); +console.log('4key 매핑:', bootstrap.keys['4key']); ``` ### openExternal(url) @@ -39,7 +44,7 @@ console.log("4key 매핑:", bootstrap.keys["4key"]); 외부 URL을 기본 브라우저에서 엽니다. ```javascript -await dmn.app.openExternal("https://github.com"); +await dmn.app.openExternal('https://github.com'); ``` ### restart() @@ -62,11 +67,11 @@ await dmn.app.restart(); | `"overlay"` | 오버레이 윈도우 (키 시각화) | ```javascript -if (dmn.window.type === "overlay") { +if (dmn.window.type === 'overlay') { // 오버레이 전용 코드 } -if (dmn.window.type === "main") { +if (dmn.window.type === 'main') { // 메인 윈도우 전용 코드 } ``` diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx new file mode 100644 index 00000000..8e1c447e --- /dev/null +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -0,0 +1,237 @@ +--- +title: 에디터 API +description: 에디터 문서의 원자적 조회, 커밋, 변경 이벤트 API +--- + +# 에디터 API + + + OBS는 읽기 전용입니다. 네이티브 Tauri 메인·오버레이 창은 revision + coordinator를 거쳐 쓸 수 있고, OBS는 canonical 문서를 조회·구독만 할 수 + 있습니다. + + +`dmn.editor`는 에디터 레이아웃을 이루는 여섯 컬렉션을 revision이 있는 문서 +하나로 조회하고 갱신합니다. 사용자 동작 하나가 여러 컬렉션을 함께 바꾸거나, +소비자가 순서가 보장된 변경 흐름 하나를 받아야 할 때 사용하세요. + +## 문서 타입 + +```typescript +type EditorField = + | 'keys' + | 'keyPositions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions' + | 'layerGroups'; + +interface EditorDocumentV1 { + schemaVersion: 1; + keys: KeyMappings; + keyPositions: KeyPositions; + statPositions: StatItemPositions; + graphPositions: GraphItemPositions; + knobPositions: KnobItemPositions; + layerGroups: LayerGroups; +} + +type EditorPatchV1 = { + schemaVersion: 1; +} & Partial>; +``` + +`EditorPatchV1`에 포함한 각 필드는 해당 최상위 컬렉션의 canonical 전체 +값입니다. 항목 하나만 표현하는 diff가 아닙니다. + +## 현재 문서 조회 + +### `dmn.editor.get(): Promise` + +현재 revision과 그 revision에 정확히 대응하는 전체 문서를 한 스냅샷으로 +반환합니다. + +```typescript +interface EditorGetResult { + revision: number; + document: EditorDocumentV1; +} + +const { revision, document } = await dmn.editor.get(); +console.log(revision, document.keyPositions); +``` + +반환된 `revision`을 보관하세요. 이후 커밋의 `baseRevision`으로 사용하여 오래된 +스냅샷이 최신 편집을 조용히 덮어쓰지 못하게 합니다. + +## 변경 사항 원자적 커밋 + +### `dmn.editor.commit(request): Promise` + +`baseRevision`의 문서에 `changes`를 합치고, 완성된 문서를 검증한 다음, 포함된 +모든 컬렉션을 한 번의 원자적 교체로 함께 저장합니다. 커밋이 거절되면 일부만 +적용되지 않습니다. DM Note는 지원 플랫폼마다 가능한 가장 강한 파일·디렉터리 +내구성 barrier를 요청합니다. 다만 하드웨어나 펌웨어가 이 barrier를 지키지 않는 +고장까지 애플리케이션이 절대 보장할 수는 없습니다. + + + 앱의 Undo/Redo도 편집 컬렉션 6개, 커스텀 탭 정보, 선택 모드, 카운터, + 프리셋 설정, 탭별 노트 설정을 백엔드 store 트랜잭션 한 번으로 함께 복원합니다. + 이 내부 커맨드는 공개 플러그인 API로 노출하지 않습니다. + + +```typescript +interface EditorCommitRequest { + baseRevision: number; + mutationId: string; + changes: EditorPatchV1; +} + +interface EditorCommitResult { + revision: number; + changedFields: EditorField[]; +} + +const snapshot = await dmn.editor.get(); +const result = await dmn.editor.commit({ + baseRevision: snapshot.revision, + mutationId: crypto.randomUUID(), + changes: { + schemaVersion: 1, + statPositions: nextStatPositions, + layerGroups: nextLayerGroups, + }, +}); + +console.log('커밋된 revision:', result.revision); +``` + +`mutationId`는 64바이트 이하의 UUID 문자열이어야 합니다. 현재 앱 프로세스에서 +최근 요청을 같은 ID로 재시도하면 중복 적용되지 않습니다. 이 메모리 내 중복 +방지는 즉시 IPC 재시도용이며 앱을 재시작하면 유지되지 않습니다. 아직 보관 중인 +ID를 다른 요청에 재사용하면 거절됩니다. + +제출한 값이 이미 현재 값과 같으면 현재 revision과 빈 `changedFields`를 +반환하고 canonical committed 이벤트는 발행하지 않습니다. 호환 wrapper는 예전 +필드별 refresh 이벤트를 한 번 투영할 수 있지만, 같은 `mutationId` 재시도에서는 +다시 투영하지 않습니다. + +### 키 구조는 반드시 함께 변경 + +`keys[mode][i]`와 `keyPositions[mode][i]`는 같은 인덱스로 하나의 항목을 +나타냅니다. 모드를 추가·삭제하거나 배열 길이를 바꿀 때는 두 컬렉션을 같은 +커밋에 함께 넣으세요. 두 컬렉션의 모드 집합과 모드별 길이는 같아야 합니다. + +```typescript +await dmn.editor.commit({ + baseRevision, + mutationId: crypto.randomUUID(), + changes: { + schemaVersion: 1, + keys: nextKeys, + keyPositions: nextKeyPositions, + }, +}); +``` + +키 이름이나 위치 속성처럼 구조와 길이가 그대로인 변경은 해당 컬렉션 하나만 +갱신해도 됩니다. 구조를 바꾸면서 `keys` 또는 `keyPositions` 하나만 제출하면 +`PAIRED_UPDATE_REQUIRED`로 거절됩니다. + +호환용 `dmn.keys` API를 사용할 때 이런 구조 변경은 +`dmn.keys.updateWithPositions(keys, keyPositions)`로 제출하세요. `update()`와 +`updatePositions()` 두 호출로 나누면 안 됩니다. + +## 커밋 오류 + +`commit()`은 아래 구조의 오류로 reject됩니다. 사람이 읽는 `message`가 아니라 +`errorCode`로 분기하세요. + +```typescript +type EditorCommitErrorCode = + | 'REVISION_CONFLICT' + | 'VALIDATION_FAILED' + | 'PAIRED_UPDATE_REQUIRED' + | 'MUTATION_ID_REUSED' + | 'IO_ERROR'; + +interface EditorCommitError { + errorCode: EditorCommitErrorCode; + message: string; + details?: { + currentRevision?: number; + validationCode?: string; + field?: string; + }; + retryable: boolean; +} +``` + +| 코드 | 의미 | `retryable` | +| ------------------------ | ------------------------------------------------- | ----------- | +| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` | +| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` | +| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` | +| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` | +| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` | + +오류에 해당할 때 `details.currentRevision`, `details.validationCode`, +`details.field`가 함께 제공됩니다. + +## 커밋된 변경 구독 + +### `dmn.editor.onCommitted(callback): ReadyUnsubscribe` + +canonical `editor:committed` 흐름을 구독합니다. 이벤트 하나가 성공한 원자적 +에디터 커밋 하나를 나타냅니다. + +```typescript +interface EditorCommittedV1 { + schemaVersion: 1; + revision: number; + mutationId: string; + origin?: string; + changedFields: EditorField[]; + patch: EditorPatchV1; +} + +const unsubscribe = dmn.editor.onCommitted((event) => { + console.log(event.revision, event.changedFields); + applyEditorPatch(event.patch); +}); + +await unsubscribe.ready; + +dmn.plugin.registerCleanup(() => { + unsubscribe(); +}); +``` + +반환된 구독 해제 함수에는 `ready: Promise` 속성이 있습니다. 스냅샷을 +조회하기 전에 이벤트가 하나도 빠지면 안 되는 경우 await 하세요. + +이벤트는 대응하는 `commit()` Promise가 resolve되기 전에 도착할 수 있습니다. +`mutationId`와 `revision`으로 같은 변경을 두 번 적용하지 마세요. 수신한 +revision이 하나 이상 건너뛰면 `dmn.editor.get()`으로 전체 스냅샷을 다시 +맞추세요. 모르는 `origin` 값과 앞으로 추가될 모르는 필드는 무시하세요. + +## 기존 변경 이벤트 + + + 아래 컬렉션별 이벤트는 기존 플러그인 호환을 위해 같은 페이로드로 계속 + 제공되지만, 신규 에디터 상태 동기화 용도로는 deprecated입니다. 여러 컬렉션을 + 바꾼 편집 하나를 순서가 있는 변경 하나로 받으려면 `dmn.editor.onCommitted()`를 + 사용하세요. + + +| 호환 이벤트 | 대체 API | +| ------------------------------------- | -------------------------- | +| `dmn.keys.onChanged()` | `dmn.editor.onCommitted()` | +| `dmn.keys.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.statItems.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.graphItems.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.knobItems.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.layerGroups.onChanged()` | `dmn.editor.onCommitted()` | + +기존 플러그인은 당장 마이그레이션하지 않아도 됩니다. 신규 동기화 코드는 canonical +이벤트와 호환 이벤트를 둘 다 적용하지 말고 canonical 이벤트만 구독하세요. diff --git a/docs/content/ko/api-reference/index/page.mdx b/docs/content/ko/api-reference/index/page.mdx index ffb3017b..31b98f24 100644 --- a/docs/content/ko/api-reference/index/page.mdx +++ b/docs/content/ko/api-reference/index/page.mdx @@ -15,6 +15,7 @@ asIndexPage: true | [`dmn.app`](/docs/api-reference/app) | 앱 부팅, 재시작, 외부 URL | | [`dmn.window`](/docs/api-reference/app#window) | 윈도우 타입, 최소화, 닫기 | | [`dmn.keys`](/docs/api-reference/keys) | 키 매핑, 이벤트, 커스텀 탭 | +| [`dmn.editor`](/docs/api-reference/editor) | 에디터 상태 원자적 커밋 | | [`dmn.knobItems`](/docs/api-reference/knobs) | HID 노브 요소 | | [`dmn.settings`](/docs/api-reference/settings) | 앱 설정 조회/변경 | | [`dmn.overlay`](/docs/api-reference/overlay) | 오버레이 제어 | diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index d1f6cc74..ecdd581a 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -5,8 +5,20 @@ description: 키 매핑, 이벤트, 커스텀 탭 API # 키 (keys) + + OBS는 읽기 전용입니다. 네이티브 Tauri 메인·오버레이 창은 revision + coordinator를 거쳐 쓰고 같은 canonical 이벤트를 구독할 수 있습니다. + + ## 키 매핑 조회/수정 + + `keys[mode][i]`와 `keyPositions[mode][i]`는 인덱스로 결합되어 있습니다. 모드 + 집합이나 배열 길이를 바꾸는 `update()` 또는 `updatePositions()` 단독 호출은 + `PAIRED_UPDATE_REQUIRED`로 실패할 수 있습니다. 키 추가·삭제·재정렬은 + `updateWithPositions()`를 사용하세요. + + ### get() 모든 키 모드의 키 매핑을 조회합니다. @@ -77,9 +89,10 @@ await dmn.keys.updatePositions(current); ### updateWithPositions(mappings, positions) -키 매핑과 인덱스로 결합된 위치 정보를 한 번의 영구 저장으로 갱신합니다. -키를 추가·삭제·재정렬할 때 사용하세요. 커밋된 값이 반환되며, 저장 성공 후 -`keys:changed`와 `positions:changed` 이벤트가 모두 방출됩니다. +키 매핑과 인덱스로 결합된 위치 정보를 한 번의 원자적 커밋으로 갱신합니다. +두 값의 모드 집합과 모드별 배열 길이는 같아야 합니다. 키를 추가·삭제·재정렬할 +때 사용하세요. 커밋된 값이 반환되며, 저장 성공 후 `keys:changed`와 +`positions:changed` 이벤트가 모두 방출됩니다. ```typescript interface KeysWithPositionsResult { @@ -224,6 +237,14 @@ HID 기기 버튼(Windows)은 `device: 'gamepad'`와 구독자가 없으면 백엔드에서 이벤트를 emit하지 않아 성능 오버헤드가 없습니다. + + 아래 `onChanged()`와 `onPositionsChanged()`는 기존 플러그인 호환을 위해 계속 + 제공되지만, 신규 에디터 상태 동기화 용도로는 deprecated입니다. 여러 컬렉션의 + 원자적 변경은 + [`dmn.editor.onCommitted()`](/docs/api-reference/editor#커밋된-변경-구독)로 + 구독하세요. + + ### onChanged(listener) 키 매핑 변경 이벤트를 구독합니다. @@ -315,11 +336,14 @@ await dmn.keys.customTabs.select('custom-123'); ### restore(customTabs, selectedKeyType) -커스텀 탭 목록과 선택된 모드를 일괄 복원합니다. 백엔드 상태, 오버레이, 메인 윈도우가 모두 동기화됩니다. +이미 존재하는 커스텀 탭의 이름·순서와 선택 모드를 원자적으로 복원합니다. 모든 +탭 ID에는 짝이 맞는 `keys`와 `keyPositions`가 이미 있어야 합니다. 탭 추가·삭제나 +키 구조 변경에는 `create`, `delete`, `updateWithPositions`를 사용하세요. 고아 탭 +정보나 잘못된 입력은 저장 데이터를 바꾸지 않고 거절됩니다. ```javascript await dmn.keys.customTabs.restore( - [{ id: 'custom-123', name: 'My Keys', keys: ['KeyA', 'KeyS'] }], + [{ id: 'custom-123', name: 'My Keys' }], 'custom-123', ); ``` diff --git a/docs/content/ko/api-reference/knobs/page.mdx b/docs/content/ko/api-reference/knobs/page.mdx index 4c088a6a..4624d2b7 100644 --- a/docs/content/ko/api-reference/knobs/page.mdx +++ b/docs/content/ko/api-reference/knobs/page.mdx @@ -5,6 +5,11 @@ description: HID 노브 요소를 위한 dmn.knobItems API 레퍼런스 # 노브 API + + OBS는 읽기 전용입니다. 네이티브 Tauri 메인·오버레이 창은 revision + coordinator를 거쳐 노브 레이아웃을 쓸 수 있습니다. + + 노브 요소 — HID 기기 축(예: 리듬게임 컨트롤러의 노브)에 바인딩되어 물리 노브와 함께 회전하는 시각화 요소 — 를 관리합니다. HID 기기의 **버튼**은 키보드 키와 완전히 동일하게 매핑·시각화되고, **축**은 전용 노브 요소로 @@ -81,6 +86,13 @@ await dmn.knobItems.updatePositions(positions); 노브 레이아웃 변경(`knobPositions:changed`)을 구독합니다. + + 이 이벤트는 기존 플러그인 호환을 위해 계속 제공되지만, 신규 에디터 상태 동기화 + 용도로는 deprecated입니다. canonical 원자적 변경 흐름은 + [`dmn.editor.onCommitted()`](/docs/api-reference/editor#커밋된-변경-구독)로 + 구독하세요. + + ```typescript const unsub = dmn.knobItems.onPositionsChanged((positions) => { console.log('노브 레이아웃 변경', positions); From a14712c6091ef76b677cd24c1a15d4aff8ec8c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 17:21:16 +0900 Subject: [PATCH 06/12] =?UTF-8?q?docs:=20=EC=A0=80=EC=9E=A5=20=EC=95=88?= =?UTF-8?q?=EC=A0=84=20=EA=B7=9C=EC=B9=99=EC=97=90=20editor=5Fcommit=20?= =?UTF-8?q?=EC=A7=80=EC=B9=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 ++- CLAUDE.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 72dac628..807c8ded 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,6 +136,8 @@ src-tauri/src/ - **sweep 불변식**: 자산 정리는 즉시 삭제가 아니라 `trash/<세션>/` 30일 격리 — 이를 우회하는 직접 `remove_file` 정리 경로 추가 금지. store 복구가 발생한 세션은 sweep이 자동 스킵됨(`skip_asset_sweep`) - **store에 사용자 생성 컬렉션 필드를 추가할 때**: `migration.rs`의 `recover_collection_field`에 항목 단위 복구 등록 검토 (범용 헬퍼 재사용, 한 줄). 미등록 시 그 필드만 "손상 시 통째 초기화"로 폴백 - **`keys[mode][i]` ↔ `keyPositions[mode][i]`는 인덱스 결합** — 복구·마이그레이션에서 배열 요소 제거 금지, 제자리 대체(`""` / default)만 허용 +- **편집 결합 컬렉션을 추가할 때**: 전용 세분 저장 커맨드를 새로 만들지 말고 `EditorDocumentV1` 필드와 `editor_commit` patch·검증·이벤트에 함께 추가 +- **editor_commit 오류 코드를 추가할 때**: 백엔드 오류 정의와 프론트 `EDITOR_ERROR_CODES`(`src/types/editor.ts`)에 반드시 함께 추가 — 프론트 목록에 없는 코드는 `retryable` 값과 무관하게 "이름표 없는 오류"로 취급되어 미저장 편집이 즉시 폐기됨 ## API 문서 동기화 @@ -157,4 +159,3 @@ src-tauri/src/ 2. **린트**: `cd src-tauri && cargo clippy` 3. **포맷팅**: `cd src-tauri && cargo fmt` 4. **permissions 확인**: 커맨드 추가/삭제 시 빌드 후 `permissions/dmnote-allow-all.json` 자동 갱신 확인 - diff --git a/CLAUDE.md b/CLAUDE.md index 2af06913..e54e6985 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,8 @@ src-tauri/src/ - **sweep 불변식**: 자산 정리는 즉시 삭제가 아니라 `trash/<세션>/` 30일 격리 — 이를 우회하는 직접 `remove_file` 정리 경로 추가 금지. store 복구가 발생한 세션은 sweep이 자동 스킵됨(`skip_asset_sweep`) - **store에 사용자 생성 컬렉션 필드를 추가할 때**: `migration.rs`의 `recover_collection_field`에 항목 단위 복구 등록 검토 (범용 헬퍼 재사용, 한 줄). 미등록 시 그 필드만 "손상 시 통째 초기화"로 폴백 - **`keys[mode][i]` ↔ `keyPositions[mode][i]`는 인덱스 결합** — 복구·마이그레이션에서 배열 요소 제거 금지, 제자리 대체(`""` / default)만 허용 +- **편집 결합 컬렉션을 추가할 때**: 전용 세분 저장 커맨드를 새로 만들지 말고 `EditorDocumentV1` 필드와 `editor_commit` patch·검증·이벤트에 함께 추가 +- **editor_commit 오류 코드를 추가할 때**: 백엔드 오류 정의와 프론트 `EDITOR_ERROR_CODES`(`src/types/editor.ts`)에 반드시 함께 추가 — 프론트 목록에 없는 코드는 `retryable` 값과 무관하게 "이름표 없는 오류"로 취급되어 미저장 편집이 즉시 폐기됨 ## API 문서 동기화 From 357c6637135de133bbad1e28682d024c9a7e41b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 19:23:36 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=ED=81=AC=EB=A1=9C=EC=8A=A4=20?= =?UTF-8?q?=ED=94=8C=EB=9E=AB=ED=8F=BC=20=EC=9E=90=EC=82=B0=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=EB=A5=BC=20=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20=EC=9E=AC?= =?UTF-8?q?=EA=B7=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 10 + src-tauri/Cargo.toml | 1 + src-tauri/src/state/migration.rs | 529 ++++++++++++++++++++++++++++++- src-tauri/src/state/store.rs | 267 ++++++++++++++-- 4 files changed, 767 insertions(+), 40 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 03b389bb..d291c359 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1193,6 +1193,7 @@ dependencies = [ "thread-priority", "tokio", "tokio-tungstenite", + "unicode-normalization", "url", "uuid", "walkdir", @@ -5637,6 +5638,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ca4ad383..c2500583 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ rfd = "0.14" dirs-next = "2.0" uuid = { version = "1", features = ["v4"] } url = "2" +unicode-normalization = "0.1" zip = "0.6" gif = "0.13.3" gif-dispose = "5.0.1" diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index c74d2582..201a729b 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -9,8 +9,11 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use dirs_next::config_dir; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{Map, Value}; +use unicode_normalization::UnicodeNormalization; use uuid::Uuid; +use super::local_asset_path::{file_url_to_path, path_identity_key, FileUrlPath}; + use crate::{ defaults::{default_keys, default_positions}, models::{ @@ -24,6 +27,33 @@ use crate::{ const LEGACY_OVERLAY_WIDTH: f64 = 860.0; const LEGACY_OVERLAY_HEIGHT: f64 = 320.0; +const APP_DATA_MARKER: &str = "com.dmnote.desktop"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AssetCategory { + Sounds, + Fonts, + Images, +} + +impl AssetCategory { + fn directory_name(self) -> &'static str { + match self { + Self::Sounds => "sounds", + Self::Fonts => "fonts", + Self::Images => "images", + } + } + + fn from_directory_name(value: &str) -> Option { + match value { + "sounds" => Some(Self::Sounds), + "fonts" => Some(Self::Fonts), + "images" => Some(Self::Images), + _ => None, + } + } +} pub(crate) struct LoadedStore { pub(crate) data: AppStoreData, @@ -164,10 +194,11 @@ fn repair_semantic_identities(data: &mut AppStoreData) -> bool { continue; } - let invalid_path = font - .local_path - .as_ref() - .is_some_and(|path| path.trim().is_empty() || !Path::new(path.trim()).is_absolute()); + let invalid_path = font.local_path.as_ref().is_some_and(|path| { + path.trim().is_empty() + || (!Path::new(path.trim()).is_absolute() + && parse_portable_asset_reference(path).is_none()) + }); if invalid_path { font.local_path = None; changed = true; @@ -301,6 +332,11 @@ pub(crate) fn migrate_local_fonts_to_app_data( continue; } + // 외래 기기 자산 참조는 파일이 나중에 복사되면 재귀화로 치유됨 — 비활성화 보류 + if is_foreign_portable_asset_reference(app_data_dir, local_path) { + continue; + } + // 복구 가능한 원본과 data URI가 모두 없을 때 비활성화만 수행 // css_content는 보존 — 미지 mime 등 디코더 개선 시 재복구 여지 유지 if font.enabled { @@ -401,6 +437,233 @@ pub(crate) fn migrate_key_images_to_app_data(app_data_dir: &Path, data: &mut App changed } +/// 다른 기기의 appData 자산 참조를 현재 기기의 실존 파일로 재연결 +pub(crate) fn rehome_foreign_asset_references( + app_data_dir: &Path, + data: &mut AppStoreData, +) -> bool { + let mut changed = false; + + for positions in data.key_positions.values_mut() { + for position in positions { + changed |= rehome_position_asset_references(app_data_dir, position); + } + } + for positions in data.stat_positions.values_mut() { + for position in positions { + changed |= rehome_position_asset_references(app_data_dir, &mut position.position); + } + } + for positions in data.graph_positions.values_mut() { + for position in positions { + changed |= rehome_position_asset_references(app_data_dir, &mut position.position); + } + } + for positions in data.knob_positions.values_mut() { + for position in positions { + changed |= rehome_position_asset_references(app_data_dir, &mut position.position); + } + } + + for font in data + .font_settings + .custom_fonts + .iter_mut() + .filter(|font| font.font_type == FontType::Local) + { + changed |= rehome_optional_asset_reference(app_data_dir, &mut font.local_path); + } + + let mut replacements: HashMap> = HashMap::new(); + for (foreign_key, entry) in &data.sound_library { + let Some(local_key) = rehomed_asset_reference(app_data_dir, foreign_key) else { + continue; + }; + replacements + .entry(local_key) + .or_default() + .push((foreign_key.clone(), entry.clone())); + } + for (local_key, candidates) in replacements { + let Some((_, candidate_entry)) = candidates.first() else { + continue; + }; + let has_conflict = data + .sound_library + .get(&local_key) + .is_some_and(|existing| existing != candidate_entry) + || candidates.iter().any(|(_, entry)| entry != candidate_entry); + if has_conflict { + continue; + } + + data.sound_library + .entry(local_key) + .or_insert_with(|| candidate_entry.clone()); + for (foreign_key, _) in candidates { + data.sound_library.remove(&foreign_key); + } + changed = true; + } + + changed +} + +fn rehome_position_asset_references(app_data_dir: &Path, position: &mut KeyPosition) -> bool { + rehome_optional_asset_reference(app_data_dir, &mut position.sound_path) + | rehome_optional_asset_reference(app_data_dir, &mut position.active_image) + | rehome_optional_asset_reference(app_data_dir, &mut position.inactive_image) +} + +fn rehome_optional_asset_reference(app_data_dir: &Path, raw: &mut Option) -> bool { + let Some(current) = raw.as_ref() else { + return false; + }; + let Some(rehomed) = rehomed_asset_reference(app_data_dir, current) else { + return false; + }; + if *current == rehomed { + return false; + } + *raw = Some(rehomed); + true +} + +fn rehomed_asset_reference(app_data_dir: &Path, raw: &str) -> Option { + if local_asset_reference_exists(raw) { + return None; + } + let (category, file_name) = parse_portable_asset_reference(raw)?; + let category_dir = app_data_dir.join(category.directory_name()); + let exact = category_dir.join(&file_name); + let resolved = if exact.is_file() { + exact + } else { + find_unique_normalized_file(&category_dir, &file_name)? + }; + Some(resolved.to_string_lossy().into_owned()) +} + +fn local_asset_reference_exists(raw: &str) -> bool { + let trimmed = raw.trim(); + match file_url_to_path(trimmed) { + FileUrlPath::Path(path) => path.is_file(), + FileUrlPath::Invalid => false, + FileUrlPath::NotFileUrl => Path::new(trimmed).is_file(), + } +} + +fn find_unique_normalized_file(directory: &Path, expected_name: &str) -> Option { + let expected = expected_name.nfc().collect::(); + let mut matches = fs::read_dir(directory) + .ok()? + .filter_map(Result::ok) + .filter_map(|entry| { + let file_type = entry.file_type().ok()?; + if !file_type.is_file() { + return None; + } + let name = entry.file_name().into_string().ok()?; + (name.nfc().collect::() == expected).then_some(entry.path()) + }); + let first = matches.next()?; + matches.next().is_none().then_some(first) +} + +fn parse_portable_asset_reference(raw: &str) -> Option<(AssetCategory, String)> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("data:") + || lower.starts_with("blob:") + || lower.starts_with("asset:") + || lower.starts_with("tauri:") + { + return None; + } + + let portable = match file_url_to_path(trimmed) { + FileUrlPath::Path(path) => path.to_string_lossy().into_owned(), + FileUrlPath::Invalid => return None, + FileUrlPath::NotFileUrl => { + let bytes = trimmed.as_bytes(); + let windows_drive = bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/'); + let verbatim_drive = lower.starts_with(r"\\?\") + && bytes.get(4).is_some_and(u8::is_ascii_alphabetic) + && bytes.get(5) == Some(&b':') + && bytes + .get(6) + .is_some_and(|byte| matches!(byte, b'\\' | b'/')); + let network = trimmed.starts_with(r"\\") || trimmed.starts_with("//"); + if !trimmed.starts_with('/') && !windows_drive && !verbatim_drive && !network { + return None; + } + trimmed.to_string() + } + }; + + let components = portable + .split(['/', '\\']) + .filter(|component| !component.is_empty()) + .collect::>(); + if components + .iter() + .any(|component| *component == "." || *component == "..") + { + return None; + } + let marker_index = components + .iter() + .rposition(|component| component.eq_ignore_ascii_case(APP_DATA_MARKER))?; + if components.len() != marker_index + 3 { + return None; + } + let category = AssetCategory::from_directory_name(components[marker_index + 1])?; + let file_name = components[marker_index + 2]; + if file_name.is_empty() { + return None; + } + Some((category, file_name.to_string())) +} + +/// 외래 기기의 appData 자산 참조인지 — 현재 appData 루트 하위면 로컬 참조로 간주 +pub(crate) fn is_foreign_portable_asset_reference(app_data_dir: &Path, raw: &str) -> bool { + if parse_portable_asset_reference(raw).is_none() { + return false; + } + let trimmed = raw.trim(); + let resolved = match file_url_to_path(trimmed) { + FileUrlPath::Path(path) => path, + FileUrlPath::Invalid => return false, + FileUrlPath::NotFileUrl => PathBuf::from(trimmed), + }; + !path_is_under_root(&resolved, app_data_dir) +} + +fn path_is_under_root(path: &Path, root: &Path) -> bool { + if path_ancestor_matches_identity(path, root) { + return true; + } + // 루트가 심링크 경유(/var ↔ /private/var)면 정규화 후 재비교 + fs::canonicalize(root) + .map(|canonical| path_ancestor_matches_identity(path, &canonical)) + .unwrap_or(false) +} + +// Windows 대소문자·verbatim 표기 차이를 identity 키로 흡수해 조상 비교 +fn path_ancestor_matches_identity(path: &Path, root: &Path) -> bool { + let root_key = path_identity_key(root); + path.ancestors() + .any(|ancestor| path_identity_key(ancestor) == root_key) +} + /// 개별 이미지 참조를 앱 데이터 디렉터리로 마이그레이션 fn migrate_image_reference_to_app_data(images_dir: &Path, image_ref: &mut Option) -> bool { let Some(raw_value) = image_ref.clone() else { @@ -1467,7 +1730,8 @@ struct LegacyOverlayPosition { mod tests { use super::{ load_store_from_path, migrate_local_fonts_to_app_data, migrate_sound_library_enabled, - normalize_state, rgba_to_hex, LEGACY_OVERLAY_HEIGHT, LEGACY_OVERLAY_WIDTH, + normalize_state, parse_portable_asset_reference, rehome_foreign_asset_references, + rgba_to_hex, AssetCategory, LEGACY_OVERLAY_HEIGHT, LEGACY_OVERLAY_WIDTH, }; use crate::{ defaults::{default_keys, default_positions}, @@ -1479,6 +1743,18 @@ mod tests { }; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use serde::{Deserialize, Serialize}; + use unicode_normalization::UnicodeNormalization; + + fn rehome_test_directory(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("dmnote-rehome-{label}-{}", uuid::Uuid::new_v4())) + } + + fn data_with_one_position() -> AppStoreData { + AppStoreData { + key_positions: default_positions().clone(), + ..AppStoreData::default() + } + } fn tauri_store_fixture_base() -> serde_json::Value { serde_json::json!({ @@ -3173,4 +3449,247 @@ mod tests { let _ = std::fs::remove_dir_all(app_data_dir); } + + #[test] + fn portable_asset_reference_parser_accepts_cross_platform_absolute_forms() { + let cases = [ + r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\key.wav", + r"\\?\C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\key.wav", + r"\\server\share\com.dmnote.desktop\sounds\key.wav", + r"\\?\UNC\server\share\com.dmnote.desktop\sounds\key.wav", + "/Users/me/Library/Application Support/com.dmnote.desktop/sounds/key.wav", + "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/sounds/key.wav", + ]; + for raw in cases { + assert_eq!( + parse_portable_asset_reference(raw), + Some((AssetCategory::Sounds, "key.wav".to_string())), + "failed to parse {raw}" + ); + } + } + + #[test] + fn cross_platform_asset_forms_rehome_to_existing_local_files() { + let dir = rehome_test_directory("absolute-forms"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let forms = [ + r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\normal.wav", + r"\\?\C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\verbatim.wav", + r"\\server\share\com.dmnote.desktop\sounds\unc.wav", + "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/sounds/url.wav", + ]; + for name in ["normal.wav", "verbatim.wav", "unc.wav", "url.wav"] { + std::fs::write(sounds_dir.join(name), b"sound").unwrap(); + } + + for (raw, name) in + forms + .into_iter() + .zip(["normal.wav", "verbatim.wav", "unc.wav", "url.wav"]) + { + let mut data = data_with_one_position(); + data.key_positions.get_mut("4key").unwrap()[0].sound_path = Some(raw.to_string()); + assert!(rehome_foreign_asset_references(&dir, &mut data)); + assert_eq!( + data.key_positions["4key"][0].sound_path.as_deref(), + Some(sounds_dir.join(name).to_string_lossy().as_ref()) + ); + } + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_external_and_traversal_asset_references_remain_unchanged() { + let dir = rehome_test_directory("rejected"); + std::fs::create_dir_all(dir.join("sounds")).unwrap(); + std::fs::write(dir.join("sounds").join("evil.wav"), b"sound").unwrap(); + for raw in [ + r"C:\Users\me\com.dmnote.desktop\sounds\missing.wav", + r"D:\music\evil.wav", + r"C:\Users\me\com.dmnote.desktop\sounds\..\evil.wav", + r"C:\Users\me\com.dmnote.desktop\sounds\..", + ] { + let mut data = data_with_one_position(); + data.key_positions.get_mut("4key").unwrap()[0].sound_path = Some(raw.to_string()); + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + assert_eq!( + data.key_positions["4key"][0].sound_path.as_deref(), + Some(raw) + ); + } + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn normalized_unicode_file_name_rehomes_only_on_unique_match() { + let dir = rehome_test_directory("unicode"); + let images_dir = dir.join("images"); + std::fs::create_dir_all(&images_dir).unwrap(); + let nfc_name = "키음.wav"; + let nfd_name = nfc_name.nfd().collect::(); + let actual_path = images_dir.join(&nfd_name); + std::fs::write(&actual_path, b"image").unwrap(); + let mut data = data_with_one_position(); + data.key_positions.get_mut("4key").unwrap()[0].active_image = + Some(format!(r"C:\Users\me\com.dmnote.desktop\images\{nfc_name}")); + + assert!(rehome_foreign_asset_references(&dir, &mut data)); + let rehomed = + std::path::PathBuf::from(data.key_positions["4key"][0].active_image.as_ref().unwrap()); + assert!(rehomed.is_file()); + assert_eq!( + rehomed + .file_name() + .unwrap() + .to_string_lossy() + .nfc() + .collect::(), + nfc_name + ); + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn foreign_font_reference_stays_enabled_and_heals_after_files_arrive() { + let dir = rehome_test_directory("font-heal"); + std::fs::create_dir_all(dir.join("fonts")).unwrap(); + let foreign = r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\fonts\portable.ttf"; + let mut data = AppStoreData::default(); + data.font_settings.custom_fonts.push(CustomFont { + id: "font".to_string(), + font_type: FontType::Local, + name: "Font".to_string(), + display_name: "Font".to_string(), + enabled: true, + local_path: Some(foreign.to_string()), + css_content: None, + }); + + // store만 복사된 상태 — 로드 체인 순서(재귀화 → 폰트 마이그레이션) 재현 + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + migrate_local_fonts_to_app_data(&dir, &mut data); + assert!(data.font_settings.custom_fonts[0].enabled); + assert_eq!( + data.font_settings.custom_fonts[0].local_path.as_deref(), + Some(foreign) + ); + + // 파일이 뒤늦게 복사되면 다음 로드에서 치유 + let local = dir.join("fonts").join("portable.ttf"); + std::fs::write(&local, b"font").unwrap(); + assert!(rehome_foreign_asset_references(&dir, &mut data)); + migrate_local_fonts_to_app_data(&dir, &mut data); + assert!(data.font_settings.custom_fonts[0].enabled); + assert_eq!( + data.font_settings.custom_fonts[0].local_path.as_deref(), + Some(local.to_string_lossy().as_ref()) + ); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn dangling_local_font_reference_is_still_disabled() { + // 현재 appData 루트가 마커명을 포함하는 실제 구조 재현 + let root = rehome_test_directory("font-dangling"); + let dir = root.join("com.dmnote.desktop"); + std::fs::create_dir_all(dir.join("fonts")).unwrap(); + let missing_local = dir.join("fonts").join("gone.ttf"); + let mut data = AppStoreData::default(); + data.font_settings.custom_fonts.push(CustomFont { + id: "font".to_string(), + font_type: FontType::Local, + name: "Font".to_string(), + display_name: "Font".to_string(), + enabled: true, + local_path: Some(missing_local.to_string_lossy().into_owned()), + css_content: None, + }); + + // 마커가 있어도 현재 루트 하위의 단순 누락 참조는 기존대로 비활성화 + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + assert!(migrate_local_fonts_to_app_data(&dir, &mut data)); + assert!(!data.font_settings.custom_fonts[0].enabled); + + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_identity_variants_of_current_app_data_are_not_foreign() { + let root = rehome_test_directory("win-identity").join("com.dmnote.desktop"); + std::fs::create_dir_all(root.join("fonts")).unwrap(); + let missing = root.join("fonts").join("gone.ttf"); + let plain = missing.to_string_lossy().into_owned(); + + // 대소문자·verbatim·file URL 표기 차이는 전부 "현재 appData"로 인식되어야 함 + let lowercase = plain.to_ascii_lowercase(); + let verbatim = format!(r"\\?\{plain}"); + let url = url::Url::from_file_path(&missing).unwrap().to_string(); + for raw in [lowercase, verbatim, url] { + assert!( + !super::is_foreign_portable_asset_reference(&root, &raw), + "현재 appData 표기 변형이 외래로 오판됨: {raw}" + ); + } + + // 다른 사용자 경로는 외래 판정 유지 + let other = r"C:\Users\dmnote-other\AppData\Roaming\com.dmnote.desktop\fonts\gone.ttf"; + assert!(super::is_foreign_portable_asset_reference(&root, other)); + + let _ = std::fs::remove_dir_all(root.parent().unwrap()); + } + + #[test] + fn sound_library_rekey_preserves_metadata_and_conflicts() { + let dir = rehome_test_directory("sound-library"); + let local_path = dir.join("sounds").join("library.wav"); + std::fs::create_dir_all(local_path.parent().unwrap()).unwrap(); + std::fs::write(&local_path, b"sound").unwrap(); + let foreign = r"C:\Users\me\com.dmnote.desktop\sounds\library.wav".to_string(); + let mut metadata = SoundLibraryEntry { + display_name: Some("보존 이름".to_string()), + trim_start_ratio: Some(0.25), + ..SoundLibraryEntry::default() + }; + let local_key = local_path.to_string_lossy().into_owned(); + + let mut data = AppStoreData::default(); + data.sound_library.insert(foreign.clone(), metadata.clone()); + assert!(rehome_foreign_asset_references(&dir, &mut data)); + assert_eq!(data.sound_library[&local_key], metadata); + assert!(!data.sound_library.contains_key(&foreign)); + + data.sound_library.insert(foreign.clone(), metadata.clone()); + assert!(rehome_foreign_asset_references(&dir, &mut data)); + assert_eq!(data.sound_library.len(), 1); + + metadata.display_name = Some("다른 이름".to_string()); + data.sound_library.insert(foreign.clone(), metadata.clone()); + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + assert_eq!(data.sound_library[&foreign], metadata); + assert_ne!(data.sound_library[&local_key], metadata); + + let alternate_foreign = r"\\server\share\com.dmnote.desktop\sounds\library.wav".to_string(); + let mut candidates_only = AppStoreData::default(); + candidates_only + .sound_library + .insert(foreign.clone(), SoundLibraryEntry::default()); + candidates_only + .sound_library + .insert(alternate_foreign.clone(), metadata.clone()); + assert!(!rehome_foreign_asset_references(&dir, &mut candidates_only)); + assert!(candidates_only.sound_library.contains_key(&foreign)); + assert!(candidates_only + .sound_library + .contains_key(&alternate_foreign)); + assert!(!candidates_only.sound_library.contains_key(&local_key)); + + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 33a766b1..89bff6cd 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -30,8 +30,9 @@ use super::editor::{ }; use super::local_asset_path::{file_url_to_path, path_identity_key, FileUrlPath}; use super::migration::{ - find_legacy_store_file, load_store_from_path, migrate_key_images_to_app_data, - migrate_local_fonts_to_app_data, normalize_state, + find_legacy_store_file, is_foreign_portable_asset_reference, load_store_from_path, + migrate_key_images_to_app_data, migrate_local_fonts_to_app_data, normalize_state, + rehome_foreign_asset_references, }; const TRASH_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60); @@ -145,6 +146,10 @@ impl AppStore { (default_path, initialize_default_state(), true, false) }; + // 외래 기기 자산 참조를 먼저 재연결 — 이후 마이그레이션이 실존 파일 기준으로 판단 + if rehome_foreign_asset_references(dir, &mut state) { + needs_persist = true; + } // 마이그레이션: 로컬 폰트 base64 cssContent → 앱 데이터 경로 기반 파일로 변환 if migrate_local_fonts_to_app_data(dir, &mut state) { needs_persist = true; @@ -157,7 +162,7 @@ impl AppStore { needs_persist = true; } - warn_unresolved_asset_references(&state); + warn_unresolved_asset_references(dir, &state); if needs_persist && had_existing_default_store && !skip_asset_sweep { if let Some(backup_path) = preserve_pre_migration_store(&path).with_context(|| { @@ -722,9 +727,9 @@ impl AppStore { } let snapshot = self.state.read().data.clone(); - let referenced_fonts = collect_local_font_paths(&snapshot); - let referenced_images = collect_local_image_paths(&snapshot); - let mut referenced_sounds = collect_local_sound_paths(&snapshot); + let referenced_fonts = collect_local_font_paths(app_data_dir, &snapshot); + let referenced_images = collect_local_image_paths(app_data_dir, &snapshot); + let mut referenced_sounds = collect_local_sound_paths(app_data_dir, &snapshot); referenced_sounds.keys.extend(recovered_sound_keys); referenced_sounds.complete &= sound_recovery_complete; let mut trash_session = TrashSession::new(trash_dir, SystemTime::now())?; @@ -1217,11 +1222,11 @@ impl AssetReferencePaths { } } - fn collect(&mut self, value: Option<&String>) { + fn collect(&mut self, app_data_dir: &Path, value: Option<&String>) { let Some(path) = value else { return; }; - match resolve_local_asset_path(path) { + match resolve_local_asset_path(app_data_dir, path) { LocalAssetPathResolution::Path(path) => { self.keys.insert(path_identity_key(&path)); } @@ -1234,11 +1239,20 @@ impl AssetReferencePaths { } } -fn warn_unresolved_asset_references(data: &AppStoreData) { +fn warn_unresolved_asset_references(app_data_dir: &Path, data: &AppStoreData) { for (category, count) in [ - ("fonts", collect_local_font_paths(data).unresolved_count), - ("images", collect_local_image_paths(data).unresolved_count), - ("sounds", collect_local_sound_paths(data).unresolved_count), + ( + "fonts", + collect_local_font_paths(app_data_dir, data).unresolved_count, + ), + ( + "images", + collect_local_image_paths(app_data_dir, data).unresolved_count, + ), + ( + "sounds", + collect_local_sound_paths(app_data_dir, data).unresolved_count, + ), ] { if count > 0 { log::warn!("[Assets] {category} sweep 보류: 해석 불가 참조 {count}건"); @@ -1246,7 +1260,7 @@ fn warn_unresolved_asset_references(data: &AppStoreData) { } } -fn collect_local_font_paths(data: &AppStoreData) -> AssetReferencePaths { +fn collect_local_font_paths(app_data_dir: &Path, data: &AppStoreData) -> AssetReferencePaths { let mut paths = AssetReferencePaths::new(); for font in data @@ -1255,46 +1269,46 @@ fn collect_local_font_paths(data: &AppStoreData) -> AssetReferencePaths { .iter() .filter(|font| font.font_type == FontType::Local) { - paths.collect(font.local_path.as_ref()); + paths.collect(app_data_dir, font.local_path.as_ref()); } paths } -fn collect_local_image_paths(data: &AppStoreData) -> AssetReferencePaths { +fn collect_local_image_paths(app_data_dir: &Path, data: &AppStoreData) -> AssetReferencePaths { let mut paths = AssetReferencePaths::new(); for position in iter_all_positions(data) { - paths.collect(position.active_image.as_ref()); - paths.collect(position.inactive_image.as_ref()); + paths.collect(app_data_dir, position.active_image.as_ref()); + paths.collect(app_data_dir, position.inactive_image.as_ref()); } paths } #[cfg(test)] -fn collect_local_image_path_keys(data: &AppStoreData) -> HashSet { - collect_local_image_paths(data).keys +fn collect_local_image_path_keys(app_data_dir: &Path, data: &AppStoreData) -> HashSet { + collect_local_image_paths(app_data_dir, data).keys } -fn collect_local_sound_paths(data: &AppStoreData) -> AssetReferencePaths { +fn collect_local_sound_paths(app_data_dir: &Path, data: &AppStoreData) -> AssetReferencePaths { let mut paths = AssetReferencePaths::new(); for position in iter_all_positions(data) { - paths.collect(position.sound_path.as_ref()); + paths.collect(app_data_dir, position.sound_path.as_ref()); } // 사운드 라이브러리에 등록된 파일도 보호 (키에 할당 안 되어도 유지) for key in data.sound_library.keys() { - paths.collect(Some(key)); + paths.collect(app_data_dir, Some(key)); } paths } #[cfg(test)] -fn collect_local_sound_path_keys(data: &AppStoreData) -> HashSet { - collect_local_sound_paths(data).keys +fn collect_local_sound_path_keys(app_data_dir: &Path, data: &AppStoreData) -> HashSet { + collect_local_sound_paths(app_data_dir, data).keys } fn iter_all_positions(data: &AppStoreData) -> impl Iterator { @@ -1327,7 +1341,7 @@ enum LocalAssetPathResolution { Ignored, } -fn resolve_local_asset_path(path: &str) -> LocalAssetPathResolution { +fn resolve_local_asset_path(app_data_dir: &Path, path: &str) -> LocalAssetPathResolution { let trimmed = path.trim(); if trimmed.is_empty() { return LocalAssetPathResolution::Ignored; @@ -1345,12 +1359,12 @@ fn resolve_local_asset_path(path: &str) -> LocalAssetPathResolution { } match file_url_to_path(trimmed) { - FileUrlPath::Path(path) => LocalAssetPathResolution::Path(path), + FileUrlPath::Path(path) => classify_absolute_asset_path(app_data_dir, trimmed, path), FileUrlPath::Invalid => LocalAssetPathResolution::Unresolved, FileUrlPath::NotFileUrl => { let path = PathBuf::from(trimmed); if path.is_absolute() { - LocalAssetPathResolution::Path(path) + classify_absolute_asset_path(app_data_dir, trimmed, path) } else if looks_like_unresolved_local_path(trimmed) { LocalAssetPathResolution::Unresolved } else { @@ -1360,6 +1374,18 @@ fn resolve_local_asset_path(path: &str) -> LocalAssetPathResolution { } } +// 실존하지 않는 외래 참조는 해석 실패로 취급 — sweep 보류 fail-safe 유도 +fn classify_absolute_asset_path( + app_data_dir: &Path, + raw: &str, + path: PathBuf, +) -> LocalAssetPathResolution { + if !path.exists() && is_foreign_portable_asset_reference(app_data_dir, raw) { + return LocalAssetPathResolution::Unresolved; + } + LocalAssetPathResolution::Path(path) +} + fn looks_like_unresolved_local_path(value: &str) -> bool { let bytes = value.as_bytes(); let has_windows_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; @@ -1987,7 +2013,9 @@ fn collect_sound_deletion_reference_keys( data: &AppStoreData, sounds_dir: &Path, ) -> HashSet { - let mut keys = collect_local_sound_paths(data).keys; + // sounds_dir는 항상 /sounds — 분류 루트는 부모로 유도 + let app_data_dir = sounds_dir.parent().unwrap_or(sounds_dir); + let mut keys = collect_local_sound_paths(app_data_dir, data).keys; for entry in data.sound_library.values() { let Some(original_path) = entry.original_path.as_deref() else { continue; @@ -3735,8 +3763,8 @@ mod tests { }], ); - let image_paths = collect_local_image_path_keys(&data); - let sound_paths = collect_local_sound_path_keys(&data); + let image_paths = collect_local_image_path_keys(&root, &data); + let sound_paths = collect_local_sound_path_keys(&root, &data); for kind in ["key", "stat", "graph", "knob"] { assert!(image_paths.contains(&path_identity_key(&root.join(format!("{kind}.png"))))); assert!(sound_paths.contains(&path_identity_key(&root.join(format!("{kind}.wav"))))); @@ -3753,9 +3781,10 @@ mod tests { data.key_positions .insert("4key".to_string(), vec![position]); - assert!(collect_local_image_path_keys(&data) + let classify_root = Path::new("/nonexistent-app-data"); + assert!(collect_local_image_path_keys(classify_root, &data) .contains(&path_identity_key(Path::new("/tmp/dmnote-file-url.png")))); - assert!(collect_local_sound_path_keys(&data) + assert!(collect_local_sound_path_keys(classify_root, &data) .contains(&path_identity_key(Path::new("/tmp/dmnote-file-url.wav")))); } @@ -3897,9 +3926,10 @@ mod tests { SoundLibraryEntry::default(), ); - let fonts = collect_local_font_paths(&data); - let images = collect_local_image_paths(&data); - let sounds = collect_local_sound_paths(&data); + let classify_root = Path::new("/nonexistent-app-data"); + let fonts = collect_local_font_paths(classify_root, &data); + let images = collect_local_image_paths(classify_root, &data); + let sounds = collect_local_sound_paths(classify_root, &data); assert!(!fonts.complete); assert!(!images.complete); @@ -3909,6 +3939,173 @@ mod tests { assert_eq!(sounds.unresolved_count, 2); } + #[test] + fn missing_foreign_file_url_reference_marks_sweep_unresolved() { + let dir = test_directory("foreign-file-url-unresolved-test"); + std::fs::create_dir_all(dir.join("images")).unwrap(); + let mut data = AppStoreData { + key_positions: default_positions().clone(), + ..AppStoreData::default() + }; + data.key_positions.get_mut("4key").unwrap()[0].active_image = Some( + "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/images/missing.png".to_string(), + ); + + // 외래 file URL이 이 기기에서 실존하지 않으면 sweep을 보류시켜야 함 + assert_eq!(collect_local_image_paths(&dir, &data).unresolved_count, 1); + + // 현재 기기 appData의 단순 누락 참조는 기존대로 보류 대상이 아님 + data.key_positions.get_mut("4key").unwrap()[0].inactive_image = Some( + dir.join("images") + .join("gone.png") + .to_string_lossy() + .into_owned(), + ); + assert_eq!(collect_local_image_paths(&dir, &data).unresolved_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_foreign_file_url_holds_sweep_and_keeps_unreferenced_image() { + let dir = test_directory("foreign-file-url-sweep-hold-test"); + let kept_image = dir.join("images").join("kept.png"); + std::fs::create_dir_all(kept_image.parent().unwrap()).unwrap(); + std::fs::write(&kept_image, b"image").unwrap(); + + let mut data = AppStoreData { + keys: crate::defaults::default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }; + data.key_positions.get_mut("4key").unwrap()[0].active_image = Some( + "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/images/missing.png".to_string(), + ); + std::fs::write( + dir.join("store.json"), + serde_json::to_vec_pretty(&data).unwrap(), + ) + .unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store.cleanup_orphan_assets_now().unwrap(); + + // 미해석 외래 참조가 있으면 sweep 보류 — 미참조 로컬 이미지도 격리 금지 + assert!(kept_image.exists()); + assert!(!dir.join("trash").exists()); + + store.flush_cleanup_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn foreign_asset_references_rehome_before_persist_and_sweep() { + let dir = test_directory("foreign-asset-rehome-integration-test"); + let font_path = dir.join("fonts").join("portable.ttf"); + let image_path = dir.join("images").join("portable.png"); + let sound_path = dir.join("sounds").join("portable.wav"); + for path in [&font_path, &image_path, &sound_path] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"portable-asset").unwrap(); + } + + let foreign_root = r"C:\Users\Exporter\AppData\Roaming\com.dmnote.desktop"; + let foreign_font = format!(r"{foreign_root}\fonts\portable.ttf"); + let foreign_image = format!(r"{foreign_root}\images\portable.png"); + let foreign_sound = format!(r"{foreign_root}\sounds\portable.wav"); + let mut data = AppStoreData { + keys: crate::defaults::default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }; + let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; + position.active_image = Some(foreign_image.clone()); + position.sound_path = Some(foreign_sound.clone()); + data.font_settings.custom_fonts.push(CustomFont { + id: "portable-font".to_string(), + font_type: FontType::Local, + name: "Portable Font".to_string(), + display_name: "Portable Font".to_string(), + enabled: true, + local_path: Some(foreign_font.clone()), + css_content: None, + }); + data.sound_library.insert( + foreign_sound.clone(), + SoundLibraryEntry { + display_name: Some("Portable sound".to_string()), + ..SoundLibraryEntry::default() + }, + ); + + let original = serde_json::to_vec_pretty(&data).unwrap(); + let store_path = dir.join("store.json"); + std::fs::write(&store_path, &original).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let snapshot = store.snapshot(); + let local_font = font_path.to_string_lossy().into_owned(); + let local_image = image_path.to_string_lossy().into_owned(); + let local_sound = sound_path.to_string_lossy().into_owned(); + + assert_eq!( + snapshot.font_settings.custom_fonts[0].local_path.as_deref(), + Some(local_font.as_str()) + ); + // 이전된 폰트는 경로 재귀화와 함께 활성 상태도 유지되어야 함 + assert!(snapshot.font_settings.custom_fonts[0].enabled); + assert_eq!( + snapshot.key_positions["4key"][0].active_image.as_deref(), + Some(local_image.as_str()) + ); + assert_eq!( + snapshot.key_positions["4key"][0].sound_path.as_deref(), + Some(local_sound.as_str()) + ); + assert!(snapshot.sound_library.contains_key(&local_sound)); + assert!(!snapshot.sound_library.contains_key(&foreign_sound)); + assert_eq!( + collect_local_font_paths(&dir, &snapshot).unresolved_count, + 0 + ); + assert_eq!( + collect_local_image_paths(&dir, &snapshot).unresolved_count, + 0 + ); + assert_eq!( + collect_local_sound_paths(&dir, &snapshot).unresolved_count, + 0 + ); + assert_eq!( + std::fs::read(dir.join("store.json.pre-migration.bak")).unwrap(), + original + ); + + store.cleanup_orphan_assets_now().unwrap(); + assert!(font_path.exists()); + assert!(image_path.exists()); + assert!(sound_path.exists()); + store.flush_cleanup_and_shutdown().unwrap(); + drop(store); + + let reloaded = crate::state::migration::load_store_from_path(&store_path).unwrap(); + assert!(!reloaded.needs_persist); + assert_eq!( + collect_local_font_paths(&dir, &reloaded.data).unresolved_count, + 0 + ); + assert_eq!( + collect_local_image_paths(&dir, &reloaded.data).unresolved_count, + 0 + ); + assert_eq!( + collect_local_sound_paths(&dir, &reloaded.data).unresolved_count, + 0 + ); + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn tauri_1_6_1_literal_fixture_preserves_collections_and_assets_through_shutdown() { let dir = test_directory("tauri-1-6-1-store-fixture-test"); From e1b7b5aad391c81745838761ef5e8b020a0ebb24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 20:14:58 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=EC=99=B8=EB=9E=98=20=EC=9E=90?= =?UTF-8?q?=EC=82=B0=20=EA=B2=BD=EB=A1=9C=EC=9D=98=20=EC=86=90=EC=83=81=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EB=B3=B4=EC=A1=B4=EA=B3=BC=20Windows=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/build.rs | 12 ++++ src-tauri/src/commands/preset/mod.rs | 5 +- src-tauri/src/state/migration.rs | 99 +++++++++++++++++++++++----- 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 0837cdb3..312a2049 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -6,9 +6,21 @@ fn main() { maybe_embed_webview2_fixed_runtime(); #[cfg(target_os = "macos")] maybe_build_macos_dock_helper(); + link_test_common_controls_manifest(); build_tauri(); } +// Windows 테스트 exe는 tauri-build 매니페스트가 없어 구버전 comctl32가 로드됨 +// (rfd의 TaskDialogIndirect가 v6 전용 — 없으면 STATUS_ENTRYPOINT_NOT_FOUND) +fn link_test_common_controls_manifest() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + println!( + "cargo:rustc-link-arg-tests=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'" + ); +} + /// commands/ 디렉토리의 `#[tauri::command]` 함수명을 스캔하여 /// permissions/dmnote-allow-all.json 자동 생성 fn generate_permissions() { diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index f5368ca1..a4fe9261 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -239,7 +239,10 @@ pub(crate) fn option_has_non_empty_text(value: &Option) -> bool { #[cfg(test)] mod tests { - use super::{local_source_path_from_image_ref, PresetFile}; + use super::PresetFile; + // file URL 경로 테스트가 유닉스 전용이라 Windows에선 미사용 경고 방지 + #[cfg(not(target_os = "windows"))] + use super::local_source_path_from_image_ref; use crate::models::NoteColor; use serde_json::json; diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 201a729b..7420ff22 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -1305,9 +1305,15 @@ fn recover_sound_library_entries(value: &Value) -> Option { } } - if Path::new(key).is_absolute() { + // 외래 기기의 관리 사운드 경로 키도 재건 대상 — 삭제하면 재귀화 치유 기회가 사라짐 + let rebuildable_key = Path::new(key).is_absolute() + || matches!( + parse_portable_asset_reference(key), + Some((AssetCategory::Sounds, _)) + ); + if rebuildable_key { log::warn!( - "[Store] Rebuilding invalid soundLibrary entry '{key}' from its absolute path during recovery" + "[Store] Rebuilding invalid soundLibrary entry '{key}' from its asset path during recovery" ); recovered.insert(key.clone(), default_entry.clone()); } else { @@ -1563,7 +1569,13 @@ fn recover_local_font_enabled(value: &Value) -> Option { return None; } let local_path = source.get("localPath").and_then(Value::as_str)?; - if local_path.trim().is_empty() || !Path::new(local_path).is_absolute() { + let trimmed_path = local_path.trim(); + // 외래 기기의 관리 폰트 경로도 유효한 복구 정체성 — 항목 삭제 대신 비활성 복구 + let portable_font_path = matches!( + parse_portable_asset_reference(trimmed_path), + Some((AssetCategory::Fonts, _)) + ); + if trimmed_path.is_empty() || (!Path::new(trimmed_path).is_absolute() && !portable_font_path) { return None; } @@ -1749,6 +1761,15 @@ mod tests { std::env::temp_dir().join(format!("dmnote-rehome-{label}-{}", uuid::Uuid::new_v4())) } + // Windows에선 /tmp가 절대 경로가 아니므로 픽스처는 플랫폼별 절대 경로 사용 + fn absolute_fixture_path(name: &str) -> String { + if cfg!(target_os = "windows") { + format!(r"C:\tmp\{name}") + } else { + format!("/tmp/{name}") + } + } + fn data_with_one_position() -> AppStoreData { AppStoreData { key_positions: default_positions().clone(), @@ -3146,17 +3167,22 @@ mod tests { json!({ "key": "F12", "ctrl": true, "shift": false, "alt": false, "meta": false }), ); - fields.insert( - "soundLibrary".to_string(), + let repaired_key = absolute_fixture_path("repaired.wav"); + let path_only_key = absolute_fixture_path("path-only.wav"); + let mut sound_library = serde_json::Map::new(); + sound_library.insert( + repaired_key.clone(), json!({ - "/tmp/repaired.wav": { - "source": 42, - "displayName": "Recovered sound", - "trimStartRatio": 0.2 - }, - "/tmp/path-only.wav": 42 + "source": 42, + "displayName": "Recovered sound", + "trimStartRatio": 0.2 }), ); + sound_library.insert(path_only_key.clone(), json!(42)); + fields.insert( + "soundLibrary".to_string(), + serde_json::Value::Object(sound_library), + ); std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); let loaded = load_store_from_path(&path).unwrap(); @@ -3237,21 +3263,21 @@ mod tests { AppStoreData::default().shortcuts.toggle_overlay ); assert_eq!( - loaded.data.sound_library["/tmp/repaired.wav"] + loaded.data.sound_library[repaired_key.as_str()] .display_name .as_deref(), Some("Recovered sound") ); assert_eq!( - loaded.data.sound_library["/tmp/repaired.wav"].trim_start_ratio, + loaded.data.sound_library[repaired_key.as_str()].trim_start_ratio, Some(0.2) ); assert_eq!( - loaded.data.sound_library["/tmp/repaired.wav"].source, + loaded.data.sound_library[repaired_key.as_str()].source, crate::models::SoundSource::Local ); assert_eq!( - loaded.data.sound_library["/tmp/path-only.wav"], + loaded.data.sound_library[path_only_key.as_str()], SoundLibraryEntry::default() ); } @@ -3299,7 +3325,7 @@ mod tests { name: "Font Sentinel".to_string(), display_name: "Font Sentinel".to_string(), enabled: true, - local_path: Some("/tmp/font-sentinel.ttf".to_string()), + local_path: Some(absolute_fixture_path("font-sentinel.ttf")), css_content: None, }; @@ -3645,6 +3671,47 @@ mod tests { let _ = std::fs::remove_dir_all(root.parent().unwrap()); } + #[test] + fn corrupt_entries_with_foreign_managed_paths_survive_recovery() { + // 사운드: 외래 관리 경로 키 + 손상 값 → 삭제 대신 기본 메타데이터로 재건 + let foreign_sound = r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\key.wav"; + let posix_sound = "/Users/me/Library/Application Support/com.dmnote.desktop/sounds/k2.wav"; + let mut entries = serde_json::Map::new(); + entries.insert(foreign_sound.to_string(), json!(42)); + entries.insert(posix_sound.to_string(), json!(42)); + entries.insert("not-a-path".to_string(), json!(42)); + + let recovered = + super::recover_sound_library_entries(&serde_json::Value::Object(entries)).unwrap(); + let recovered = recovered.as_object().unwrap(); + assert!(recovered.contains_key(foreign_sound)); + assert!(recovered.contains_key(posix_sound)); + assert!(!recovered.contains_key("not-a-path")); + + // 폰트: 외래 관리 경로 + enabled 손상 → 항목 삭제 대신 비활성 복구 + let mut font = serde_json::to_value(CustomFont { + id: "font".to_string(), + font_type: FontType::Local, + name: "Font".to_string(), + display_name: "Font".to_string(), + enabled: true, + local_path: Some( + r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\fonts\p.ttf".to_string(), + ), + css_content: None, + }) + .unwrap(); + font.as_object_mut() + .unwrap() + .insert("enabled".to_string(), json!(42)); + + let candidate = super::recover_local_font_enabled(&font).unwrap(); + assert_eq!( + candidate.as_object().unwrap().get("enabled"), + Some(&serde_json::Value::Bool(false)) + ); + } + #[test] fn sound_library_rekey_preserves_metadata_and_conflicts() { let dir = rehome_test_directory("sound-library"); From d4a9304d2c56f212dd06c3b23113d82f0987f8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 20:33:45 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20Windows=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A7=81=ED=81=AC=EB=A5=BC=20comctl32=20=EC=A7=80?= =?UTF-8?q?=EC=97=B0=20=EB=A1=9C=EB=93=9C=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/build.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 312a2049..2d82cad4 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -6,19 +6,22 @@ fn main() { maybe_embed_webview2_fixed_runtime(); #[cfg(target_os = "macos")] maybe_build_macos_dock_helper(); - link_test_common_controls_manifest(); + delay_load_comctl32_for_windows(); build_tauri(); } -// Windows 테스트 exe는 tauri-build 매니페스트가 없어 구버전 comctl32가 로드됨 +// Windows 테스트 exe는 tauri-build 매니페스트가 없어 로드 시점에 구버전 comctl32가 붙음 // (rfd의 TaskDialogIndirect가 v6 전용 — 없으면 STATUS_ENTRYPOINT_NOT_FOUND) -fn link_test_common_controls_manifest() { - if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { +// comctl32를 지연 로드로 바꿔 로드 시점 바인딩 자체를 제거 — 본체는 첫 호출 때 매니페스트로 v6 해석 +// rustc-link-arg-tests는 통합 테스트 타깃 전용이라 유닛 테스트 크레이트에선 빌드가 깨짐 (Windows 실측) +fn delay_load_comctl32_for_windows() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS"); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV"); + if target_os.as_deref() != Ok("windows") || target_env.as_deref() != Ok("msvc") { return; } - println!( - "cargo:rustc-link-arg-tests=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'" - ); + println!("cargo:rustc-link-arg=/DELAYLOAD:comctl32.dll"); + println!("cargo:rustc-link-arg=delayimp.lib"); } /// commands/ 디렉토리의 `#[tauri::command]` 함수명을 스캔하여 From fe45f4a6e247bf2c79eb7c7c38a743e621885e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 21:21:22 +0900 Subject: [PATCH 10/12] =?UTF-8?q?chore:=20=ED=94=84=EB=A6=AC=EC=85=8B=20?= =?UTF-8?q?=EC=9E=90=EC=82=B0=20=EC=9E=84=EB=B2=A0=EB=93=9C=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=EA=B3=BC=20=EC=B0=B8=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=EC=97=90=20=EC=A7=84=EB=8B=A8=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/preset/load.rs | 8 ++++++++ src-tauri/src/commands/preset/save.rs | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 8fa56f0d..a818874c 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -817,6 +817,10 @@ fn restore_preset_local_fonts_in_dir( .unwrap_or(false); if !has_existing_valid_path { + log::warn!( + "[Preset] Disabling font '{}' — no embedded payload and its file is missing on this machine", + font.name + ); font.local_path = None; font.enabled = false; } @@ -1027,6 +1031,9 @@ fn restore_position_image_reference( } // 다른 기기에서 import된 Preset: 해석 불가한 절대 경로는 정상 fallback 처리 + log::warn!( + "[Preset] Clearing image reference to a file missing on this machine: {trimmed}" + ); *image_ref = None; return Ok(()); } @@ -1205,6 +1212,7 @@ fn restore_position_sound_reference( } // 다른 기기에서 임포트된 프리셋: 경로를 해석할 수 없으면 초기화. + log::warn!("[Preset] Clearing sound reference to a file missing on this machine: {trimmed}"); *sound_ref = None; Ok(()) } diff --git a/src-tauri/src/commands/preset/save.rs b/src-tauri/src/commands/preset/save.rs index 0c07294a..d1e2e348 100644 --- a/src-tauri/src/commands/preset/save.rs +++ b/src-tauri/src/commands/preset/save.rs @@ -496,6 +496,11 @@ fn rewrite_position_image_reference( return Ok(()); }; if !source_path.exists() { + // 실물 없는 참조는 임베드 불가 — 진단용 흔적만 남김 + log::warn!( + "[Preset] Skipping image embed for a file missing on this machine: {}", + source_path.display() + ); return Ok(()); } @@ -611,6 +616,8 @@ fn rewrite_position_sound_reference( let source_path = PathBuf::from(trimmed); if !source_path.is_absolute() || !source_path.exists() { + // 실물 없는 참조는 임베드 불가 — 진단용 흔적만 남김 + log::warn!("[Preset] Skipping sound embed for a missing or non-absolute path: {trimmed}"); return Ok(()); } From 2d8a1e75c87512d4c67dbbb08284793b17b2c652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 23:39:40 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20=EB=8B=A4=ED=81=AC=20=ED=8C=94?= =?UTF-8?q?=EB=A0=88=ED=8A=B8=20=ED=95=98=EC=9C=84=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=8C=80=EB=B9=84=EC=99=80=20=EC=9D=B8=EC=85=8B=20?= =?UTF-8?q?=EB=B0=9D=EA=B8=B0=20=EC=9E=AC=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/styles/tokens.css | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/renderer/styles/tokens.css b/src/renderer/styles/tokens.css index 2b9bdead..5483469c 100644 --- a/src/renderer/styles/tokens.css +++ b/src/renderer/styles/tokens.css @@ -11,8 +11,9 @@ --ui-bg-surface-active: #313139; --ui-bg-elevated: #24242b; /* 컨트롤 필 — 반투명 화이트라 어느 표면 위서든 한 단계 밝게 뜸 - 근흑 배경에선 어두운 홈이 배경과 구분되지 않아 인풋·트랙은 밝은 필이 기준 */ - --ui-bg-inset: rgba(255, 255, 255, 0.08); + 근흑 배경에선 어두운 홈이 배경과 구분되지 않아 인풋·트랙은 밝은 필이 기준 + fill(0.08)보다 한 단계 아래 유지 — 정적 홈과 인터랙티브 필의 밝기 구분 */ + --ui-bg-inset: rgba(255, 255, 255, 0.05); /* 불투명 웰 — 코드 에디터처럼 배경이 흔들리면 안 되는 표면 전용 */ --ui-bg-inset-solid: #101013; @@ -52,11 +53,11 @@ /* 정적 그룹 컨테이너용 — 인터랙션 없는 은은한 면 */ --ui-fill-faint: rgba(255, 255, 255, 0.06); - /* 텍스트 위계 */ + /* 텍스트 위계 — faint는 가장 밝은 표면(웰) 위에서도 4.5:1 유지가 기준 */ --ui-fg: #edeef2; --ui-fg-muted: #a6a8b2; - --ui-fg-faint: #64666f; - --ui-fg-disabled: #45464e; + --ui-fg-faint: #8b8d97; + --ui-fg-disabled: #5c5e66; /* 액센트 — 브랜드 퍼플. 인디케이터·포커스 등 작은 지점용 */ --ui-accent: #8b5cf6; From 6443c4d1635a48b5073cbd1df8817e5c11db9c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 23:43:35 +0900 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=EC=9B=B9=ED=8F=B0=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=A0=84=ED=99=98=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=EC=99=80=20=ED=94=BC=EC=BB=A4=20=EB=AA=A9=EB=A1=9D=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EB=A1=9C=20=EB=A1=9C=EB=94=A9=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pickers/CounterAnimationPicker.tsx | 37 +++++++++++--- .../main/Modal/content/pickers/FontPicker.tsx | 48 +++++++------------ .../Modal/content/pickers/SoundPicker.tsx | 31 ++++++++++-- 3 files changed, 75 insertions(+), 41 deletions(-) diff --git a/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx b/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx index 2b470d52..3f497e05 100644 --- a/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import type { KeyCounterAnimationSettings, KeyCounterSettings, @@ -80,6 +80,8 @@ const EMPTY_LIBRARY: CounterAnimationListResponse = { userPresets: [], }; +let counterAnimationLibraryCache: CounterAnimationListResponse | null = null; + const CounterAnimationPicker = ({ open, animation, @@ -92,33 +94,56 @@ const CounterAnimationPicker = ({ }: CounterAnimationPickerProps) => { const [searchQuery, setSearchQuery] = useState(''); const [filterType, setFilterType] = useState('all'); - const [library, setLibrary] = - useState(EMPTY_LIBRARY); + const [library, setLibrary] = useState( + () => counterAnimationLibraryCache ?? EMPTY_LIBRARY, + ); const [isLoading, setIsLoading] = useState(false); const [errorText, setErrorText] = useState(''); const [editorState, setEditorState] = useState(null); + const loadRequestRef = useRef(0); + const isOpenRef = useRef(open); const menu = usePickerItemMenu(); const loadLibrary = async () => { - setIsLoading(true); + const requestId = ++loadRequestRef.current; + const cachedLibrary = counterAnimationLibraryCache; + if (cachedLibrary) { + setLibrary(cachedLibrary); + setIsLoading(false); + } else { + setIsLoading(true); + } setErrorText(''); try { const response = await window.api.counterAnimation.list(); - setLibrary(normalizeCounterAnimationLibrary(response)); + const nextLibrary = normalizeCounterAnimationLibrary(response); + if (requestId !== loadRequestRef.current) return; + counterAnimationLibraryCache = nextLibrary; + if (!isOpenRef.current) return; + setLibrary(nextLibrary); } catch (error) { + if (requestId !== loadRequestRef.current || !isOpenRef.current) return; console.error('Failed to load counter animation presets', error); setErrorText( t('counterSetting.loadAnimationFailed') || '애니메이션 목록을 불러오지 못했습니다.', ); } finally { - setIsLoading(false); + if (requestId === loadRequestRef.current && isOpenRef.current) { + setIsLoading(false); + } } }; useEffect(() => { + isOpenRef.current = open; if (!open) return; void loadLibrary(); + + return () => { + isOpenRef.current = false; + loadRequestRef.current += 1; + }; }, [open]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { diff --git a/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx b/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx index ce1af122..10244f0f 100644 --- a/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx @@ -1,4 +1,11 @@ -import React, { Suspense, lazy, useEffect, useRef, useState } from 'react'; +import { + Suspense, + lazy, + startTransition, + useEffect, + useRef, + useState, +} from 'react'; import { useTranslation } from '@contexts/useTranslation'; import { useFontStore } from '@stores/useFontStore'; import type { CustomFont } from '@src/types/settings/fonts'; @@ -7,8 +14,6 @@ import { buildDraftPreviewCss, } from '@src/types/settings/fonts'; import { convertFileSrc } from '@tauri-apps/api/core'; -import Modal from '@components/main/Modal/Modal'; -import { FULL_SURFACE_MATERIAL_CLASS } from '@components/main/Modal/FullSurfaceModalLayout'; import ListPopup, { type ListItem } from '@components/main/Modal/ListPopup'; import CommonListPickerPage from './CommonListPickerPage'; import { @@ -169,12 +174,7 @@ const FontPicker = ({ // 피커가 열려 있는 동안 WebFontInputModal 코드를 미리 로드 useEffect(() => { if (!open) return; - - const timer = window.setTimeout(() => { - void preloadWebFontInputModal(); - }, 120); - - return () => window.clearTimeout(timer); + void preloadWebFontInputModal(); }, [open]); // 필터링된 폰트 목록 (비활성 폰트도 노출 — 행에서 흐리게 표시) @@ -287,7 +287,9 @@ const FontPicker = ({ const openWebFontModal = (editingId: string | null) => { void preloadWebFontInputModal(); - setWebFontModal({ editingId }); + startTransition(() => { + setWebFontModal({ editingId }); + }); }; const handleWebFontSubmit = (css: string, displayName: string) => { @@ -473,26 +475,8 @@ const FontPicker = ({ /> )} - {/* 웹폰트 CSS 입력 모달 */} - {webFontModal ? ( - setWebFontModal(null)} - ariaLabel={t('propertiesPanel.loading')} - > -
event.stopPropagation()} - > -

- {t('propertiesPanel.loading')} -

-
- - } - > + + {webFontModal ? ( setWebFontModal(null)} @@ -506,8 +490,8 @@ const FontPicker = ({ } t={t} /> - - ) : null} + ) : null} +
); }; diff --git a/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx b/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx index 94fe620f..3914dd28 100644 --- a/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx @@ -26,6 +26,8 @@ type TrimState = | { mode: 'create'; file: File } | { mode: 'edit'; item: SoundListItem }; +let soundListCache: SoundListItem[] | null = null; + const SoundPicker = ({ open, selectedSound, @@ -39,7 +41,9 @@ const SoundPicker = ({ const [filterType, setFilterType] = useState<'all' | 'local' | 'hidden'>( 'all', ); - const [sounds, setSounds] = useState([]); + const [sounds, setSounds] = useState( + () => soundListCache ?? [], + ); const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(''); const [trimState, setTrimState] = useState(null); @@ -48,27 +52,48 @@ const SoundPicker = ({ const renameInputRef = useRef(null); const renameCancelledRef = useRef(false); const addFileInputRef = useRef(null); + const loadRequestRef = useRef(0); + const isOpenRef = useRef(open); const menu = usePickerItemMenu(); const normalizedSelectedSound = (selectedSound || '').trim(); const loadSounds = async () => { - setIsLoading(true); + const requestId = ++loadRequestRef.current; + const cachedSounds = soundListCache; + if (cachedSounds) { + setSounds(cachedSounds); + setIsLoading(false); + } else { + setIsLoading(true); + } setLoadError(''); try { const nextSounds = await window.api.sound.list(); + if (requestId !== loadRequestRef.current) return; + soundListCache = nextSounds; + if (!isOpenRef.current) return; setSounds(nextSounds); } catch (error) { + if (requestId !== loadRequestRef.current || !isOpenRef.current) return; console.error('Failed to load sound list', error); setLoadError(t('soundPicker.loadFailed') || '사운드 목록 로드 실패'); } finally { - setIsLoading(false); + if (requestId === loadRequestRef.current && isOpenRef.current) { + setIsLoading(false); + } } }; useEffect(() => { + isOpenRef.current = open; if (!open) return; void loadSounds(); + + return () => { + isOpenRef.current = false; + loadRequestRef.current += 1; + }; }, [open]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => {