From 3927936208b150b328b75163c640758eefb14f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 12 Jul 2026 21:29:09 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix:=20=EC=A0=80=EC=9E=A5=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B3=B4=EC=A1=B4=EA=B3=BC=20OBS=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 손상된 store 복구와 백업, 단일 writer 저장 순서, 구버전 프리셋 및 로컬 자산 복원을 보강했습니다. OBS 브릿지 인증·allowlist·Host/Origin·미디어 경계를 강화하고 미사용 Game Bar 프로토타입을 제거했습니다. 사운드 API 호환성, KPS 플러그인 구분자, 속성 패널 상태 및 라이선스 고지를 함께 정리했습니다. --- .gitignore | 15 - AGENTS.md | 2 +- CLAUDE.md | 2 +- GameBarOverlay/App.xaml | 9 - GameBarOverlay/App.xaml.cs | 127 --- .../Assets/LockScreenLogo.scale-200.png | Bin 1430 -> 0 bytes .../Assets/SplashScreen.scale-200.png | Bin 15461 -> 0 bytes .../Assets/Square150x150Logo.scale-200.png | Bin 14133 -> 0 bytes .../Assets/Square44x44Logo.scale-200.png | Bin 2780 -> 0 bytes ...x44Logo.targetsize-24_altform-unplated.png | Bin 705 -> 0 bytes GameBarOverlay/Assets/StoreLogo.png | Bin 1547 -> 0 bytes .../Assets/Wide310x150Logo.scale-200.png | Bin 10511 -> 0 bytes GameBarOverlay/GameBar/README.txt | 1 - GameBarOverlay/GameBarOverlay.csproj | 195 ---- GameBarOverlay/GameBarOverlay.slnx | 13 - GameBarOverlay/MainPage.xaml | 35 - GameBarOverlay/MainPage.xaml.cs | 249 ----- GameBarOverlay/Package.appxmanifest | 124 --- GameBarOverlay/Properties/AssemblyInfo.cs | 14 - GameBarOverlay/Properties/Default.rd.xml | 5 - .../PublishProfiles/win-arm64.pubxml | 13 - .../Properties/PublishProfiles/win-x64.pubxml | 13 - .../Properties/PublishProfiles/win-x86.pubxml | 13 - GameBarOverlay/Properties/launchSettings.json | 7 - THIRD_PARTY_NOTICES.txt | 110 ++ docs/obs-mode-design.md | 171 +-- plugin/kps-builtin.js | 2 +- plugin/kps.js | 2 +- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/app/obs.rs | 38 +- src-tauri/src/commands/keys/sound.rs | 119 ++- src-tauri/src/commands/preset/load.rs | 302 +++++- src-tauri/src/commands/preset/mod.rs | 39 +- src-tauri/src/commands/preset/save.rs | 132 ++- src-tauri/src/models/mod.rs | 31 + src-tauri/src/models/obs.rs | 25 +- src-tauri/src/services/obs_bridge.rs | 980 +++++++++++++++--- src-tauri/src/state/app_state.rs | 31 +- src-tauri/src/state/migration.rs | 733 +++++++++---- src-tauri/src/state/store.rs | 916 ++++++++++++---- src/renderer/api/ipcShim.ts | 37 +- .../components/main/Grid/PropertiesPanel.tsx | 27 +- src/renderer/hooks/Grid/useGridSelection.ts | 6 +- .../stores/grid/usePropertiesPanelStore.ts | 9 + src/types/obs.ts | 2 +- 45 files changed, 3005 insertions(+), 1545 deletions(-) delete mode 100644 GameBarOverlay/App.xaml delete mode 100644 GameBarOverlay/App.xaml.cs delete mode 100644 GameBarOverlay/Assets/LockScreenLogo.scale-200.png delete mode 100644 GameBarOverlay/Assets/SplashScreen.scale-200.png delete mode 100644 GameBarOverlay/Assets/Square150x150Logo.scale-200.png delete mode 100644 GameBarOverlay/Assets/Square44x44Logo.scale-200.png delete mode 100644 GameBarOverlay/Assets/Square44x44Logo.targetsize-24_altform-unplated.png delete mode 100644 GameBarOverlay/Assets/StoreLogo.png delete mode 100644 GameBarOverlay/Assets/Wide310x150Logo.scale-200.png delete mode 100644 GameBarOverlay/GameBar/README.txt delete mode 100644 GameBarOverlay/GameBarOverlay.csproj delete mode 100644 GameBarOverlay/GameBarOverlay.slnx delete mode 100644 GameBarOverlay/MainPage.xaml delete mode 100644 GameBarOverlay/MainPage.xaml.cs delete mode 100644 GameBarOverlay/Package.appxmanifest delete mode 100644 GameBarOverlay/Properties/AssemblyInfo.cs delete mode 100644 GameBarOverlay/Properties/Default.rd.xml delete mode 100644 GameBarOverlay/Properties/PublishProfiles/win-arm64.pubxml delete mode 100644 GameBarOverlay/Properties/PublishProfiles/win-x64.pubxml delete mode 100644 GameBarOverlay/Properties/PublishProfiles/win-x86.pubxml delete mode 100644 GameBarOverlay/Properties/launchSettings.json diff --git a/.gitignore b/.gitignore index f1798bbc..78c7c665 100644 --- a/.gitignore +++ b/.gitignore @@ -16,19 +16,4 @@ mcp.json tasks/ -# --- C# / Visual Studio (UWP) --- -[Bb]in/ -[Oo]bj/ -.vs/ -*.user -*.suo -*.dbmdl -*.jfm -AppPackages/ -BundleArtifacts/ -*.appxupload -*.msixupload -*.appxbundle -*.msixbundle *.pfx -Package.StoreAssociation.xml diff --git a/AGENTS.md b/AGENTS.md index 0fa95104..489f9f8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ src-tauri/src/ ### OBS 모드 (WebSocket 브릿지) - **이벤트 포워딩**: 새 Tauri 이벤트(`app.emit(...)`)를 추가할 때, OBS 오버레이에도 전달되어야 하면 `src-tauri/src/services/obs_bridge.rs`의 `register_event_forwarding()` 이벤트 목록에 등록 -- **deny 리스트**: OBS 클라이언트에서 실행 불가능한 커맨드는 `obs_bridge.rs`의 `DENIED_WS_COMMANDS`에 등록 (백엔드가 유일한 source of truth) +- **allowlist**: OBS 클라이언트에서 실행 가능한 커맨드만 `obs_bridge.rs`의 `ALLOWED_WS_COMMANDS`에 등록 (정확 일치, fail-closed — 목록에 없으면 차단, 백엔드가 유일한 source of truth). 신규 커맨드를 OBS에 노출하려면 검토 후 명시적으로 추가 - **IPC shim**: `src/renderer/api/ipcShim.ts`는 generic 설계 — 커맨드/이벤트별 분기 없음. 이벤트나 커맨드 추가 시 수정 불필요 ### 주석 diff --git a/CLAUDE.md b/CLAUDE.md index 4156b55a..3e6499b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ src-tauri/src/ ### OBS 모드 (WebSocket 브릿지) - **이벤트 포워딩**: 새 Tauri 이벤트(`app.emit(...)`)를 추가할 때, OBS 오버레이에도 전달되어야 하면 `src-tauri/src/services/obs_bridge.rs`의 `register_event_forwarding()` 이벤트 목록에 등록 -- **deny 리스트**: OBS 클라이언트에서 실행 불가능한 커맨드는 `obs_bridge.rs`의 `DENIED_WS_COMMANDS`에 등록 (백엔드가 유일한 source of truth) +- **allowlist**: OBS 클라이언트에서 실행 가능한 커맨드만 `obs_bridge.rs`의 `ALLOWED_WS_COMMANDS`에 등록 (정확 일치, fail-closed — 목록에 없으면 차단, 백엔드가 유일한 source of truth). 신규 커맨드를 OBS에 노출하려면 검토 후 명시적으로 추가 - **IPC shim**: `src/renderer/api/ipcShim.ts`는 generic 설계 — 커맨드/이벤트별 분기 없음. 이벤트나 커맨드 추가 시 수정 불필요 ### 주석 diff --git a/GameBarOverlay/App.xaml b/GameBarOverlay/App.xaml deleted file mode 100644 index a0e62b51..00000000 --- a/GameBarOverlay/App.xaml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/GameBarOverlay/App.xaml.cs b/GameBarOverlay/App.xaml.cs deleted file mode 100644 index ff5520a2..00000000 --- a/GameBarOverlay/App.xaml.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using Windows.ApplicationModel; -using Windows.ApplicationModel.Activation; -using Windows.UI.Xaml; -using Windows.UI.Xaml.Controls; -using Windows.UI.Xaml.Navigation; -using Microsoft.Gaming.XboxGameBar; - -namespace GameBarOverlay -{ - public sealed partial class App : Application - { - private XboxGameBarWidget widget; - - public App() - { - Environment.SetEnvironmentVariable("WEBVIEW2_DEFAULT_BACKGROUND_COLOR", "00FFFFFF"); - InitializeComponent(); - Suspending += OnSuspending; - } - - protected override void OnLaunched(LaunchActivatedEventArgs e) - { - if (e.PrelaunchActivated) - { - return; - } - - var rootFrame = EnsureRootFrame(); - if (rootFrame.Content == null) - { - rootFrame.Navigate(typeof(MainPage)); - } - - Window.Current.Activate(); - } - - protected override void OnActivated(IActivatedEventArgs e) - { - XboxGameBarWidgetActivatedEventArgs widgetArgs = null; - if (e.Kind == ActivationKind.Protocol) - { - var protocolArgs = e as IProtocolActivatedEventArgs; - if ( - protocolArgs != null - && protocolArgs.Uri != null - && string.Equals( - protocolArgs.Uri.Scheme, - "ms-gamebarwidget", - StringComparison.OrdinalIgnoreCase - ) - ) - { - widgetArgs = e as XboxGameBarWidgetActivatedEventArgs; - } - } - - if (widgetArgs == null) - { - base.OnActivated(e); - return; - } - - NavigateToWidgetShell(widgetArgs); - } - - private void NavigateToWidgetShell(XboxGameBarWidgetActivatedEventArgs widgetArgs) - { - var rootFrame = EnsureRootFrame(); - if (widgetArgs.IsLaunchActivation || widget == null) - { - widget = new XboxGameBarWidget(widgetArgs, Window.Current.CoreWindow, rootFrame); - Window.Current.Closed -= OnWidgetWindowClosed; - Window.Current.Closed += OnWidgetWindowClosed; - } - - var page = rootFrame.Content as MainPage; - if (page == null) - { - rootFrame.Navigate(typeof(MainPage), widget); - } - else - { - page.AttachWidget(widget); - } - - page = rootFrame.Content as MainPage; - if (page != null) - { - page.HandleActivation(); - } - - Window.Current.Activate(); - } - - private Frame EnsureRootFrame() - { - if (Window.Current.Content is Frame rootFrame) - { - return rootFrame; - } - - rootFrame = new Frame(); - rootFrame.NavigationFailed += OnNavigationFailed; - Window.Current.Content = rootFrame; - return rootFrame; - } - - private void OnWidgetWindowClosed(object sender, Windows.UI.Core.CoreWindowEventArgs e) - { - widget = null; - Window.Current.Closed -= OnWidgetWindowClosed; - } - - private void OnNavigationFailed(object sender, NavigationFailedEventArgs e) - { - throw new Exception($"Failed to load page '{e.SourcePageType.FullName}'."); - } - - private void OnSuspending(object sender, SuspendingEventArgs e) - { - var deferral = e.SuspendingOperation.GetDeferral(); - widget = null; - deferral.Complete(); - } - } -} diff --git a/GameBarOverlay/Assets/LockScreenLogo.scale-200.png b/GameBarOverlay/Assets/LockScreenLogo.scale-200.png deleted file mode 100644 index 735f57adb5dfc01886d137b4e493d7e97cf13af3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1430 zcmaJ>TTC2P7~aKltDttVHYH6u8Io4i*}3fO&d$gd*bA_<3j~&e7%8(eXJLfhS!M@! zKrliY>>6yT4+Kr95$!DoD(Qn-5TP|{V_KS`k~E6(LGS@#`v$hQo&^^BKsw3HIsZBT z_y6C2n`lK@apunKojRQ^(_P}Mgewt$(^BBKCTZ;*xa?J3wQ7~@S0lUvbcLeq1Bg4o zH-bvQi|wt~L7q$~a-gDFP!{&TQfc3fX*6=uHv* zT&1&U(-)L%Xp^djI2?~eBF2cxC@YOP$+9d?P&h?lPy-9M2UT9fg5jKm1t$m#iWE{M zIf%q9@;fyT?0UP>tcw-bLkz;s2LlKl2qeP0w zECS7Ate+Awk|KQ+DOk;fl}Xsy4o^CY=pwq%QAAKKl628_yNPsK>?A>%D8fQG6IgdJ ztnxttBz#NI_a@fk7SU`WtrpsfZsNs9^0(2a z@C3#YO3>k~w7?2hipBf{#b6`}Xw1hlG$yi?;1dDs7k~xDAw@jiI*+tc;t2Lflg&bM)0!Y;0_@=w%`LW^8DsYpS#-bLOklX9r?Ei}TScw|4DbpW%+7 zFgAI)f51s}{y-eWb|vrU-Ya!GuYKP)J7z#*V_k^Xo>4!1Yqj*m)x&0L^tg3GJbVAJ zJ-Pl$R=NAabouV=^z_t;^K*0AvFs!vYU>_<|I^#c?>>CR<(T?=%{;U=aI*SbZADLH z&(f2wz_Y0??Tf|g;?|1Znw6}6U43Q#qNRwv1vp9uFn1)V#*4p&%$mP9x&15^OaBiDS(XppT|z^>;B{PLVEbS3IFYV yGvCsSX*m diff --git a/GameBarOverlay/Assets/SplashScreen.scale-200.png b/GameBarOverlay/Assets/SplashScreen.scale-200.png deleted file mode 100644 index 88bf4a7e8bac527f43d993754d1e3ff96dca00f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15461 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 ws3%ZuQ6*fV{`cp<68NtK{?8Px# diff --git a/GameBarOverlay/Assets/Square150x150Logo.scale-200.png b/GameBarOverlay/Assets/Square150x150Logo.scale-200.png deleted file mode 100644 index 72555b3b872993b121e05742710dd868e37909c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14133 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
c=_O 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 diff --git a/GameBarOverlay/Assets/StoreLogo.png b/GameBarOverlay/Assets/StoreLogo.png deleted file mode 100644 index 5772d97096ad1ee05846c7190eb4c8d849c410ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1547 zcmV+m2K4!fP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1)xbpK~!i%POOyYn0lN4^ojv&8486pG^BrYPPMilX%?isoZX8)Hlx%`nFBtmre_ z87tW?=9P3fot`a<;{8fhz2tJ0lB$*vu8l=W{Mm}){hBfNBVwK_24n75Rn_~K%Y_NF ztN?OBPO|LXFXp>sIGvvQ$XoCxO!j{RIe`#fw3zpzP!w&ms(Sy)MiMbZw;gh<5c7`* ziYuG8({hNcisz!3KM%${k41_q@y2Wfa%If@xX1&maT2v5-jJ<`?wt2T9$1k}nOKox z$xf1F$5u%Azf=%e5$*CK5>Qb^@eHTC$SoEJ9cgHy#^x5X*_>%56Uo)%S5~=;QH$Fr@e~1FXTU1`Yg={uCVVqtdSH`q)Ns>$=k0My7lsirnIwAi=-U*Yo~O|CJhil*AhTIY7^fG85s`{)bh_g+nhf8j ziO?){^?X5Aiz97yVh9mgc9Gj%O1wzIe(4kP= zKi?Q5pMQ#mM#A*VaFD7V-NrkaI9?A7A%bmHQ?r}KCT2K+L0^z}u04C6r@}&O!gxI} zgb2D*UHw7ctprCfa%DXS0O!-B8Y*CSoY*v}iai`rZWg<{)h#hnS$)h`= zu%E2iO?7n#>1g9IPU44aL*(;^<3w=%<4+^>(Z`+C)Z9Y#`(C4+yPl%W6$ZmHgJXb_YM4$t(9naM6p#bSJb~)C9IuaB7Yxeq(4NP6XHJUcsr` z5s|z(N8=N}(e=S`y3qGMb)4y@#-?}Z+2>xMO`FQeYF$}#bQ38qwsQjr?*!Wsp8k_> z7Z5;;jdZ6-(#Zn%fbeKwCOYRl|95yaOwDh#lErfWvSf4Vi(g1 zG11i#q@j@z`NyXz5Sopy6z07}t!?d@A%Y2GLI(o~@6^@vC0A^afKJ4G72fL7TPs8p zFp<;q@fR^P5~TjC1JwQbd1^a;l3sr009962k$n~0ubaq!WMnEZ7jr$pK8EdpVnghp z6L^#8PAF`wRE!8n3|t?j&QH%#+qA zcz7q+jthNPx!6G``mYS|?vzu1SfK+!Ks!F^=9&@+&Qj0W^GUVUWMK#q0Nn}R304N( z3u2e9LI(^Z0v8Jd82KDD&lTIK&p~PZKxeZv?qXp8;hicftN7Lu$LYnAL^Acs6cz?9 z7Pe(I?{ue#UCG7jMf4+f{~{6yMbS=W;#dI$(oF)09xJ>r^2o`9 z9vq#EJa{mseVao^ZA&K|DwRrZ+0I~my*+|9T4+gGE~*IKTCfw z@VBZaNg@`q?Aod*TJOT2QAC&3KrH+Tht@00YL%F0(&2P^ci_YqjR2wvH8lc}XL&9{ x7Gzm_Ld+|rfQm}e1`}*SX0gGPpxAdy{0rS1X&SCS1mgez002ovPDHLkV1mMh(Q*I) diff --git a/GameBarOverlay/Assets/Wide310x150Logo.scale-200.png b/GameBarOverlay/Assets/Wide310x150Logo.scale-200.png deleted file mode 100644 index 73d756e5a5f45f5a72f349ecac741a87e0be3ca0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10511 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 diff --git a/GameBarOverlay/GameBar/README.txt b/GameBarOverlay/GameBar/README.txt deleted file mode 100644 index 074630d2..00000000 --- a/GameBarOverlay/GameBar/README.txt +++ /dev/null @@ -1 +0,0 @@ -Game Bar public folder placeholder. diff --git a/GameBarOverlay/GameBarOverlay.csproj b/GameBarOverlay/GameBarOverlay.csproj deleted file mode 100644 index d807306e..00000000 --- a/GameBarOverlay/GameBarOverlay.csproj +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - Debug - x86 - {591B01BF-71B2-4E7A-9AD2-97BF8EC4C490} - AppContainerExe - Properties - GameBarOverlay - GameBarOverlay - ko-KR - UAP - 10.0.26100.0 - 10.0.18362.0 - 14 - 512 - {A5A43C5B-DE2A-4C0C-9213-0A381AF9435A};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - true - PackageReference - False - false - false - false - true - Always - x86|x64|arm64 - 0 - - - - true - bin\x86\Debug\ - DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP - ;2008 - full - x86 - false - prompt - true - - - - bin\x86\Release\ - TRACE;NETFX_CORE;WINDOWS_UWP - true - ;2008 - pdbonly - x86 - false - prompt - true - true - - - - true - bin\x64\Debug\ - DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP - ;2008 - full - x64 - false - prompt - - - - bin\x64\Release\ - TRACE;NETFX_CORE;WINDOWS_UWP - true - ;2008 - pdbonly - x64 - false - prompt - true - - - - true - bin\ARM64\Debug\ - DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP - ;2008 - full - ARM64 - false - prompt - true - - - - bin\ARM64\Release\ - TRACE;NETFX_CORE;WINDOWS_UWP - true - ;2008 - pdbonly - ARM64 - false - prompt - true - true - - - - - App.xaml - - - MainPage.xaml - Code - - - - - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - - - - Designer - - - - - - - - - - - - - - - - - - - - - - $(NuGetPackageRoot)microsoft.gaming.xboxgamebar\7.3.2511061\lib\uap10.0\Microsoft.Gaming.XboxGameBar.winmd - true - - - $(NuGetPackageRoot)microsoft.ui.xaml\2.8.7\lib\uap10.0\Microsoft.UI.Xaml.winmd - true - - - $(NuGetPackageRoot)microsoft.web.webview2\1.0.2849.39\lib\Microsoft.Web.WebView2.Core.winmd - true - - - - - win32 - $(Platform) - - - - 18.0 - C:\Program Files (x86)\Microsoft SDKs\UWPNuGetPackages\microsoft.netcore.universalwindowsplatform\6.2.14\ref\uap10.0.15138 - $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)UwpReferenceAssemblies\')) - $(UwpReferenceAssemblyCacheRoot) - true - <_TargetFrameworkDirectories Condition="Exists('$(UwpReferenceAssemblySource)')">$(UwpReferenceAssemblyCacheRoot).NETCore\v5.0\ - <_FullFrameworkReferenceAssemblyPaths Condition="Exists('$(UwpReferenceAssemblySource)')">$(UwpReferenceAssemblyCacheRoot).NETCore\v5.0\ - $(UwpReferenceAssemblyCacheRoot).NETCore\v5.0\ - $(MSBuildExtensionsPath)\Microsoft\WindowsXaml\v$(VisualStudioVersion)\Microsoft.Windows.UI.Xaml.CSharp.targets - C:\Program Files\Microsoft Visual Studio\18\Enterprise\MSBuild\Microsoft\WindowsXaml\v18.0\Microsoft.Windows.UI.Xaml.CSharp.Targets - - - - - - - - <_UwpReferenceAssembly Include="$(UwpReferenceAssemblySource)\*.dll" /> - - - - - - - - diff --git a/GameBarOverlay/GameBarOverlay.slnx b/GameBarOverlay/GameBarOverlay.slnx deleted file mode 100644 index f42e857f..00000000 --- a/GameBarOverlay/GameBarOverlay.slnx +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/GameBarOverlay/MainPage.xaml b/GameBarOverlay/MainPage.xaml deleted file mode 100644 index 6ecfb0e1..00000000 --- a/GameBarOverlay/MainPage.xaml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - diff --git a/GameBarOverlay/MainPage.xaml.cs b/GameBarOverlay/MainPage.xaml.cs deleted file mode 100644 index a5523cc8..00000000 --- a/GameBarOverlay/MainPage.xaml.cs +++ /dev/null @@ -1,249 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Gaming.XboxGameBar; -using Microsoft.UI.Xaml.Controls; -using Microsoft.Web.WebView2.Core; -using Windows.Foundation; -using Windows.Data.Json; -using Windows.UI.Xaml; -using Windows.UI.Xaml.Controls; -using Windows.UI.Xaml.Navigation; -using Windows.Web.Http; - -namespace GameBarOverlay -{ - public sealed partial class MainPage : Page - { - private const ushort DefaultBridgePort = 34891; - private const int BridgePortScanCount = 10; - - private readonly DispatcherTimer reconnectTimer = new DispatcherTimer(); - private readonly HttpClient httpClient = new HttpClient(); - - private bool connected; - private bool isConnecting; - private bool disposed; - private XboxGameBarWidget widget; - - public MainPage() - { - InitializeComponent(); - - Loaded += OnLoaded; - Unloaded += OnUnloaded; - OverlayWebView.NavigationCompleted += OnNavigationCompleted; - - reconnectTimer.Interval = TimeSpan.FromSeconds(5); - reconnectTimer.Tick += OnReconnectTick; - } - - protected override void OnNavigatedTo(NavigationEventArgs e) - { - base.OnNavigatedTo(e); - AttachWidget(e.Parameter as XboxGameBarWidget); - } - - public void AttachWidget(XboxGameBarWidget gameBarWidget) - { - if (object.ReferenceEquals(widget, gameBarWidget)) - { - return; - } - - DetachWidget(); - widget = gameBarWidget; - if (widget == null) - { - ApplyWidgetState(); - return; - } - - widget.MinWindowSize = new Size(360, 220); - widget.MaxWindowSize = new Size(1920, 1080); - widget.PinningSupported = true; - widget.SettingsSupported = false; - widget.VerticalResizeSupported = true; - widget.RequestedThemeChanged += OnWidgetAppearanceChanged; - widget.RequestedOpacityChanged += OnWidgetAppearanceChanged; - widget.WindowStateChanged += OnWidgetWindowStateChanged; - ApplyWidgetState(); - } - - public void HandleActivation() - { - _ = EnsureBridgeConnectedAsync(); - } - - private async void OnLoaded(object sender, RoutedEventArgs e) - { - reconnectTimer.Start(); - await EnsureBridgeConnectedAsync(); - } - - private void OnUnloaded(object sender, RoutedEventArgs e) - { - if (disposed) - { - return; - } - - disposed = true; - reconnectTimer.Stop(); - OverlayWebView.NavigationCompleted -= OnNavigationCompleted; - OverlayWebView.Close(); - httpClient.Dispose(); - DetachWidget(); - } - - private async void OnReconnectTick(object sender, object e) - { - if (!connected) - { - await EnsureBridgeConnectedAsync(); - } - } - - private async Task EnsureBridgeConnectedAsync() - { - if (disposed || connected || isConnecting) - { - return; - } - - isConnecting = true; - connected = false; - StatusPanel.Visibility = Visibility.Visible; - StatusText.Text = "로컬 브리지를 탐색하는 중"; - - try - { - var bootstrap = await FindBootstrapAsync(); - if (bootstrap == null) - { - connected = false; - StatusText.Text = - "DmNote OBS 브리지를 찾지 못했습니다. Tauri 앱에서 OBS 모드를 먼저 시작하세요."; - return; - } - - await OverlayWebView.EnsureCoreWebView2Async(); - OverlayWebView.Source = new Uri(bootstrap.Url); - StatusText.Text = "브리지에 연결했습니다. 오버레이를 로드하는 중"; - } - catch (Exception ex) - { - connected = false; - StatusText.Text = $"브리지 연결 실패: {ex.Message}"; - } - finally - { - isConnecting = false; - } - } - - private void ApplyWidgetState() - { - if (widget == null) - { - RequestedTheme = ElementTheme.Default; - RootGrid.Opacity = 1.0; - return; - } - - RequestedTheme = widget.RequestedTheme; - RootGrid.Opacity = Math.Max(0.2, widget.RequestedOpacity); - } - - private void DetachWidget() - { - if (widget == null) - { - return; - } - - widget.RequestedThemeChanged -= OnWidgetAppearanceChanged; - widget.RequestedOpacityChanged -= OnWidgetAppearanceChanged; - widget.WindowStateChanged -= OnWidgetWindowStateChanged; - widget = null; - } - - private void OnWidgetAppearanceChanged(XboxGameBarWidget sender, object args) - { - ApplyWidgetState(); - } - - private void OnWidgetWindowStateChanged(XboxGameBarWidget sender, object args) - { - ApplyWidgetState(); - } - - private void OnNavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e) - { - connected = e.IsSuccess; - if (connected) - { - StatusPanel.Visibility = Visibility.Collapsed; - return; - } - - StatusPanel.Visibility = Visibility.Visible; - StatusText.Text = $"오버레이 로드 실패: {e.WebErrorStatus}"; - } - - private async Task FindBootstrapAsync() - { - for (var port = DefaultBridgePort; port < DefaultBridgePort + BridgePortScanCount; port++) - { - var bootstrap = await TryGetBootstrapAsync(port); - if (bootstrap != null) - { - return bootstrap; - } - } - - return null; - } - - private async Task TryGetBootstrapAsync(int port) - { - using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1))) - { - try - { - var response = await httpClient - .GetAsync(new Uri($"http://127.0.0.1:{port}/gamebar/bootstrap.json")) - .AsTask(cts.Token); - if (!response.IsSuccessStatusCode) - { - return null; - } - - var json = await response.Content.ReadAsStringAsync().AsTask(cts.Token); - var obj = JsonObject.Parse(json); - IJsonValue urlValue; - if (!obj.TryGetValue("url", out urlValue)) - { - return null; - } - - return new GameBarBootstrap(urlValue.GetString()); - } - catch - { - return null; - } - } - } - - private sealed class GameBarBootstrap - { - public GameBarBootstrap(string url) - { - Url = url; - } - - public string Url { get; } - } - } -} diff --git a/GameBarOverlay/Package.appxmanifest b/GameBarOverlay/Package.appxmanifest deleted file mode 100644 index 4e2a539f..00000000 --- a/GameBarOverlay/Package.appxmanifest +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - GameBarOverlay - esihunc - Assets\StoreLogo.png - - - - - - - - - - - - - - - - - - - - - - true - true - false - false - - true - - 360 - 480 - 220 - 360 - 1080 - 1920 - - - true - true - - - - - - - - - - - - - - Microsoft.Gaming.XboxGameBar.winmd - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/GameBarOverlay/Properties/AssemblyInfo.cs b/GameBarOverlay/Properties/AssemblyInfo.cs deleted file mode 100644 index 9f66d963..00000000 --- a/GameBarOverlay/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("GameBarOverlay")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("GameBarOverlay")] -[assembly: AssemblyCopyright("Copyright © 2026")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] -[assembly: ComVisible(false)] diff --git a/GameBarOverlay/Properties/Default.rd.xml b/GameBarOverlay/Properties/Default.rd.xml deleted file mode 100644 index fa467633..00000000 --- a/GameBarOverlay/Properties/Default.rd.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/GameBarOverlay/Properties/PublishProfiles/win-arm64.pubxml b/GameBarOverlay/Properties/PublishProfiles/win-arm64.pubxml deleted file mode 100644 index 3481de2a..00000000 --- a/GameBarOverlay/Properties/PublishProfiles/win-arm64.pubxml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - FileSystem - ARM64 - win-arm64 - bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ - true - - \ No newline at end of file diff --git a/GameBarOverlay/Properties/PublishProfiles/win-x64.pubxml b/GameBarOverlay/Properties/PublishProfiles/win-x64.pubxml deleted file mode 100644 index 4463ecc0..00000000 --- a/GameBarOverlay/Properties/PublishProfiles/win-x64.pubxml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - FileSystem - x64 - win-x64 - bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ - true - - \ No newline at end of file diff --git a/GameBarOverlay/Properties/PublishProfiles/win-x86.pubxml b/GameBarOverlay/Properties/PublishProfiles/win-x86.pubxml deleted file mode 100644 index 31c51d68..00000000 --- a/GameBarOverlay/Properties/PublishProfiles/win-x86.pubxml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - FileSystem - x86 - win-x86 - bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ - true - - \ No newline at end of file diff --git a/GameBarOverlay/Properties/launchSettings.json b/GameBarOverlay/Properties/launchSettings.json deleted file mode 100644 index aef8d0d3..00000000 --- a/GameBarOverlay/Properties/launchSettings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "profiles": { - "GameBarOverlay": { - "commandName": "MsixPackage" - } - } -} \ No newline at end of file diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index 0980234e..4876e177 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -50,3 +50,113 @@ redistribution in binary form: LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Pretendard +---------- + +DM Note bundles the Pretendard variable font +(src/renderer/assets/fonts/PretendardVariable.woff2), redistributed +unmodified under the SIL Open Font License, Version 1.1. + +Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard), +with Reserved Font Name 'Pretendard'. + +Copyright 2014-2021 Adobe (http://www.adobe.com/), +with Reserved Font Name 'Source'. +Source is a trademark of Adobe in the United States and/or other countries. + +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter), +with Reserved Font Name 'Inter'. + +Copyright 2021 The M+ FONTS Project Authors (https://github.com/coz-m/MPLUS_FONTS), +with Reserved Font Name 'M PLUS 1'. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to any +document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may include +source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components +as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer +or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or in +the appropriate machine-readable metadata fields within text or binary +files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any Modified +Version, except to acknowledge the contribution(s) of the Copyright +Holder(s) and the Author(s) or with their explicit written permission. + +5) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this license, and must not be distributed +under any other license. The requirement for fonts to remain under this +license does not apply to any document created using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index ddd82f51..db4cff7b 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -29,7 +29,7 @@ → [AppState (단일 상태 허브)] ├─ [기존 overlay window] ← OBS 모드 OFF ├─ [ObsBridgeService] ← OBS 모드 ON - │ └─ WebSocket 서버 (localhost:PORT) + │ └─ WebSocket 서버 (0.0.0.0:PORT — 같은 네트워크 다른 PC 접속 지원, 토큰 인증) │ ├─ HTTP: OBS 페이지 정적 파일 서빙 ✅ │ └─ WS: 키 이벤트 / 설정 / 레이아웃 브로드캐스트 └─ [Main window] @@ -74,7 +74,7 @@ | 방향 | 타입 | 용도 | 빈도 | 상태 | |------|------|------|------|------| | C→S | `hello` | 최초 접속 핸드셰이크 | 1회 | ✅ | -| S→C | `hello_ack` | 프로토콜 승인 + deny list | 1회 | ✅ | +| S→C | `hello_ack` | 프로토콜 승인 + allow list | 1회 | ✅ | | S→C | `snapshot` | 전체 상태 동기화 | 접속 시 + resync | ✅ | | S→C | `tauri_event` | 범용 Tauri 이벤트 포워딩 | 빈번 | ✅ (v4: 기존 key_event/settings_diff/counter_update 통합) | | C→S | `invoke_request` | 커맨드 실행 요청 (WS RPC) | 초기 + 간헐 | ✅ | @@ -87,7 +87,7 @@ ``` 1. OBS 페이지 접속 (WS 직접 연결) ← v1: HTTP upgrade 없이 직접 WS 2. 클라이언트 → hello { client, protocol, appVersion, token } -3. 서버 → hello_ack { serverVersion, obsMode, denyList } +3. 서버 → hello_ack { serverVersion, obsMode, allowedList } 4. 서버 → snapshot { 전체 상태 } 5. 이후 tauri_event (keys:state, settings:changed 등) + invoke_request/invoke_response 6. seq gap 감지 시 → resync_request → snapshot 재전송 @@ -104,11 +104,15 @@ "client": "obs-browser", "protocol": 1, "appVersion": "1.5.2", - "resumeFromSeq": 0 + "resumeFromSeq": 0, + "token": "<세션 토큰>" } } ``` +서버는 `v`와 `payload.protocol`이 서버의 `OBS_PROTOCOL_VERSION`과 일치하지 않으면 +`error { code: "PROTOCOL_MISMATCH" }`를 보내고 연결을 종료한다 (fail-closed, 토큰 검증보다 먼저 수행). + #### snapshot (S→C) ✅ ```json { @@ -543,13 +547,13 @@ OBS 환경: |------|------|:---:| | 프론트 IPC shim | `__TAURI_INTERNALS__` → WS RPC 교체 | **범용** | | 프론트 이벤트 시스템 | 콜백 레지스트리 + `tauri_event` 디스패치 | **범용** | -| 프론트 deny 체크 | `hello_ack`에서 수신한 단일 배열로 동적 구성 | **백엔드에서 수신 (관리 불필요)** | +| 프론트 allow 체크 | `hello_ack`에서 수신한 단일 배열로 동적 구성 | **백엔드에서 수신 (관리 불필요)** | | 백엔드 WS RPC | `invoke_request` → `webview.on_message()` 자동 디스패치 | **범용** | -| 백엔드 deny 리스트 | OBS에서 의미 없는 커맨드 차단 | **프로젝트별 설정 (유일한 관리 포인트)** | +| 백엔드 allow 리스트 | OBS에서 허용할 커맨드만 명시 | **프로젝트별 설정 (유일한 관리 포인트)** | | 백엔드 이벤트 포워딩 | Tauri emit → `tauri_event` WS 브로드캐스트 | **범용** | -프로젝트별로 관리하는 것은 **deny 리스트 하나뿐** — **백엔드 Rust 코드에서 1곳만 관리**. -프론트엔드는 WS handshake(`hello_ack`)에서 deny 리스트를 수신하여 No-op Set을 동적 구성. +프로젝트별로 관리하는 것은 **allow 리스트 하나뿐** — **백엔드 Rust 코드에서 1곳만 관리**. +프론트엔드는 WS handshake(`hello_ack`)에서 allow 리스트를 수신하여 허용 Set을 동적 구성. 나머지 인프라는 어떤 Tauri 앱이든 그대로 이식 가능. ### 12.4 IPC Shim 구현 @@ -559,14 +563,15 @@ OBS 환경: shim은 **커맨드별 분기를 하지 않는다**. 모든 invoke 호출은 다음 3단계로만 처리: 1. **이벤트 플러그인** (`plugin:event|*`) → 로컬 콜백 레지스트리에서 처리 -2. **No-op** (OBS에서 의미 없는 창 관리 커맨드) → 즉시 반환 +2. **allow 체크** (allowlist에 없는 커맨드는 no-op/차단) → 즉시 반환 3. **WS RPC** → 백엔드에 전달, 실제 커맨드 핸들러가 처리 ```typescript // src/renderer/api/ipcShim.ts -// deny 리스트는 하드코딩 아님 — WS handshake(hello_ack)에서 수신 -let denyList: string[] = []; +// allow 리스트는 하드코딩 아님 — WS handshake(hello_ack)에서 수신 +let allowList: string[] = []; +let allowListReceived = false; async function shimInvoke(cmd: string, args?: Record): Promise { // 1. 이벤트 플러그인 (프론트엔드 로컬) @@ -574,74 +579,86 @@ async function shimInvoke(cmd: string, args?: Record): Promise< if (cmd === 'plugin:event|unlisten') { handleEventUnlisten(args); return; } if (cmd === 'plugin:event|emit') { handleEventEmit(args); return; } - // 2. deny 체크 (백엔드에서 수신한 단일 리스트) - if (isDenied(cmd)) return; + // 2. allow 체크 (백엔드에서 수신한 단일 리스트) + if (!isAllowed(cmd)) return; - // 3. 나머지 전부 → WS RPC (백엔드가 실제 처리) + // 3. 허용된 커맨드만 → WS RPC (백엔드가 실제 처리) return wsRpc(cmd, args); } -// "|"로 끝나면 prefix 매칭, 아니면 exact 매칭 -function isDenied(cmd: string): boolean { - return denyList.some(entry => - entry.endsWith('|') ? cmd.startsWith(entry) : cmd === entry - ); +// allowlist 정확 일치 — hello_ack 수신 전에는 백엔드 이중 검사에 위임 +function isAllowed(cmd: string): boolean { + if (!allowListReceived) return true; + return allowList.includes(cmd); } ``` -**커맨드 추가 시 shim 수정 불필요** — 백엔드에 커맨드가 있으면 자동으로 동작. +**신규 커맨드는 기본 차단** — OBS 오버레이에 실제로 필요한 커맨드만 백엔드 allowlist에 추가한다. +deny 방식(신규 커맨드 기본 노출)과 정반대이며, 위험 커맨드가 실수로 원격에 열리는 것을 구조적으로 막는다. -#### deny 리스트 일원화 +#### allow 리스트 일원화 -**백엔드가 유일한 source of truth**. 단일 배열 하나로 exact + prefix 매칭 통합. -`|`로 끝나는 항목은 prefix 매칭, 아니면 exact 매칭. +**백엔드가 유일한 source of truth**. 단일 배열, **정확 일치(exact match)** 매칭. +prefix 매칭은 없다 — 명시된 커맨드 이름만 허용된다. 프론트엔드는 WS handshake에서 수신: ```json -// hello_ack 응답에 deny 리스트 포함 +// hello_ack 응답에 allow 리스트 포함 { "type": "hello_ack", "payload": { - "serverVersion": "1.5.2", + "serverVersion": "1.6.1", "obsMode": true, - "denyList": [ - "overlay_resize", "overlay_set_visible", "overlay_set_lock", - "overlay_set_anchor", "overlay_get", - "window_minimize", "window_close", "window_show_main", - "window_open_devtools_all", - "app_quit", "app_restart", "app_open_external", "app_auto_update", - "plugin:window|", "plugin:menu|", "plugin:resources|" + "allowedList": [ + "app_bootstrap", "settings_get", "layer_groups_get", + "note_tab_get_all", "note_tab_get", + "css_get", "css_get_use", "css_tab_get_all", "css_tab_get", + "js_get", "js_get_use", "get_cursor_settings", + "keys_get", "keys_get_counters", "positions_get", + "stat_positions_get", "graph_positions_get", "knob_positions_get", + "custom_tabs_list", "counter_animation_list", + "plugin_bridge_send", "plugin_bridge_send_to", + "raw_input_subscribe", "raw_input_unsubscribe", + "plugin_storage_get", "plugin_storage_set", "plugin_storage_remove", + "plugin_storage_keys", "plugin_storage_has_data", "plugin_storage_clear_by_prefix" ] } } ``` -프론트 shim은 `hello_ack` 수신 시 `denyList`를 그대로 저장: +프론트 shim은 `hello_ack` 수신 시 `allowedList`를 그대로 저장: ```typescript function onHelloAck(payload: HelloAckPayload) { - denyList = payload.denyList ?? []; + if (payload.allowedList) { + allowList = payload.allowedList; + allowListReceived = true; + } } ``` 이 구조의 장점: -- **관리 포인트 1곳** — Rust 코드의 `DENIED_WS_COMMANDS` 배열 하나만 수정 -- **단일 배열** — exact/prefix 구분 없이 하나의 리스트로 통합 (`|` suffix 컨벤션) +- **관리 포인트 1곳** — Rust 코드의 `ALLOWED_WS_COMMANDS` 배열 하나만 수정 +- **fail-closed** — allowlist에 없으면 차단 — 신규 커맨드가 검토 없이 원격에 열리지 않음 - **빌드 의존성 없음** — codegen이나 공유 JSON 파일 불필요 - **런타임 동기화** — 백엔드 버전이 올라가도 프론트 shim 재빌드 필요 없음 +- **백엔드 이중 검사** — 프론트는 UX상 조기 차단일 뿐, 실제 경계는 `handle_invoke_request`가 `ALLOWED_WS_COMMANDS`로 재검사 -#### deny 커맨드 목록 (참고 — Rust에서만 관리) +#### allow 커맨드 목록 (참고 — Rust에서만 관리) -| 항목 | 매칭 | 이유 | -|------|------|------| -| `overlay_resize`, `overlay_set_visible` 등 | exact | Tauri 윈도우 조작 | -| `window_minimize`, `window_close` 등 | exact | 네이티브 윈도우 제어 | -| `app_quit`, `app_restart` 등 | exact | 앱 생명주기 | -| `plugin:window\|` | prefix | Tauri window 플러그인 전체 | -| `plugin:menu\|` | prefix | Tauri menu 플러그인 전체 | -| `plugin:resources\|` | prefix | Tauri resources 플러그인 전체 | +오버레이 렌더에 필요한 읽기 계열 + 플러그인 브릿지/구독/네임스페이스 storage만 허용. 전부 정확 일치. -`raw_input_subscribe` 등 **백엔드 기능이 필요한 커맨드**는 deny가 아닌 WS RPC로 처리. +| 분류 | 커맨드 | 이유 | +|------|--------|------| +| 부트스트랩·상태 | `app_bootstrap`, `settings_get`, `layer_groups_get`, `note_tab_get_all`, `note_tab_get` | 오버레이 초기 스냅샷·상태 읽기 | +| CSS/JS 읽기 | `css_get`, `css_get_use`, `css_tab_get_all`, `css_tab_get`, `js_get`, `js_get_use` | 커스텀 CSS/JS 로드 | +| 위치·카운터 읽기 | `keys_get`, `keys_get_counters`, `positions_get`, `stat_positions_get`, `graph_positions_get`, `knob_positions_get`, `custom_tabs_list`, `counter_animation_list` | 배치·통계 읽기 | +| 커서 | `get_cursor_settings` | macOS 커서 처리 | +| 플러그인 브릿지·구독 | `plugin_bridge_send`, `plugin_bridge_send_to`, `raw_input_subscribe`, `raw_input_unsubscribe` | 오버레이 플러그인 동기화·입력 구독 | +| 플러그인 storage | `plugin_storage_get`, `plugin_storage_set`, `plugin_storage_remove`, `plugin_storage_keys`, `plugin_storage_has_data`, `plugin_storage_clear_by_prefix` | 플러그인 네임스페이스 저장소 | + +`settings_update`, `sound_delete`, `js_set_content`, `preset_save` 등 **변이·파일 커맨드는 allowlist에 없어 원격 차단**. +`plugin:window|`, `plugin:menu|` 등 네이티브 창/메뉴 커맨드도 allowlist에 없으므로 자동 no-op 처리된다. ### 12.5 WS ↔ Tauri 이벤트 매핑 @@ -681,8 +698,8 @@ shim이 설치되면 자동으로 WS 경유 동작 — 별도 처리 불필요. overlay/App.tsx가 `@tauri-apps/api/window`, `@tauri-apps/api/menu` 등을 직접 import. 이 모듈들은 내부적으로 `invoke('plugin:window|...', ...)` 형태로 호출. -백엔드 deny 리스트의 `denyPrefixes`에 `plugin:window|`, `plugin:menu|` 등이 포함되어 -shim이 handshake 시 수신한 prefix 매칭으로 자동 no-op 처리 — 별도 모듈 모킹 불필요. +`plugin:window|`, `plugin:menu|` 등은 allowlist에 없으므로 shim의 allow 체크에서 +자동 no-op 처리 — 별도 모듈 모킹 불필요. `convertFileSrc()`는 `__TAURI_INTERNALS__.convertFileSrc`에 설치되므로 shim에서 직접 제공. OBS HTTP 서버의 `/media/?token=...` 경로로 변환: @@ -767,9 +784,9 @@ async function bootstrap() { | 리스크 | 심각도 | 대응 | |--------|--------|------| | `window.__TAURI_INTERNALS__` 내부 API 변경 | 중 | Tauri 버전 고정 + 업그레이드 시 shim 검증 | -| WS RPC 보안 (임의 커맨드 실행) | 중 | deny 리스트 + Tauri ACL 재사용 + 세션 토큰 검증 | +| WS RPC 보안 (임의 커맨드 실행) | 중 | allow 리스트(정확 일치) + Tauri ACL 재사용 + 세션 토큰 검증 + Host/Origin 검증 | | `InvokeRequest` API 안정성 | 중 | Tauri 2.x 내 변경 가능성 낮음, 업그레이드 시 한 곳만 수정 | -| overlay 전용 API 누락으로 런타임 에러 | 낮 | No-op prefix 매칭 (`plugin:window|*`) + try/catch 가드 | +| overlay 전용 API 누락으로 런타임 에러 | 낮 | allowlist 미포함 커맨드 자동 no-op + try/catch 가드 | | WS RPC 지연 (localhost) | 낮 | <1ms, 체감 불가 | | pendingRpc dispose 시 미해결 Promise | 낮 | dispose 시 모든 pending을 reject 처리 | @@ -792,8 +809,8 @@ async fn handle_invoke_request( args: Value, ws_tx: &WsSender, ) { - // 1. deny 리스트 체크 (= 프론트 No-op 리스트와 동일) - if DENIED_WS_COMMANDS.contains(&command) { + // 1. allow 리스트 체크 (= 프론트 허용 리스트와 동일, 정확 일치) + if !ALLOWED_WS_COMMANDS.contains(&command) { ws_tx.send(invoke_response_error(request_id, "Command not allowed")); return; } @@ -817,54 +834,54 @@ async fn handle_invoke_request( } ``` -#### deny 리스트 (유일한 source of truth) +#### allow 리스트 (유일한 source of truth) **이 배열 하나가 프론트/백엔드 양쪽의 유일한 관리 포인트**. -`|`로 끝나는 항목은 prefix 매칭, 아니면 exact 매칭 — 프론트/백엔드 동일 규칙. +정확 일치(exact match)만 허용 — prefix 매칭 없음. WS handshake 시 `hello_ack`에 포함하여 프론트엔드에 전달 (§12.4 참조). ```rust -// obs_bridge.rs — 유일한 deny 리스트 정의 (단일 배열) -const DENIED_WS_COMMANDS: &[&str] = &[ - // exact 매칭 - "overlay_resize", "overlay_set_visible", "overlay_set_lock", - "overlay_set_anchor", "overlay_get", - "window_minimize", "window_close", "window_show_main", - "window_open_devtools_all", - "app_quit", "app_restart", "app_open_external", "app_auto_update", - // prefix 매칭 ("|"로 끝남) - "plugin:window|", "plugin:menu|", "plugin:resources|", +// obs_bridge.rs — 유일한 allow 리스트 정의 (단일 배열, 정확 일치) +const ALLOWED_WS_COMMANDS: &[&str] = &[ + "app_bootstrap", "settings_get", "layer_groups_get", + "note_tab_get_all", "note_tab_get", + "css_get", "css_get_use", "css_tab_get_all", "css_tab_get", + "js_get", "js_get_use", "get_cursor_settings", + "keys_get", "keys_get_counters", "positions_get", + "stat_positions_get", "graph_positions_get", "knob_positions_get", + "custom_tabs_list", "counter_animation_list", + "plugin_bridge_send", "plugin_bridge_send_to", + "raw_input_subscribe", "raw_input_unsubscribe", + "plugin_storage_get", "plugin_storage_set", "plugin_storage_remove", + "plugin_storage_keys", "plugin_storage_has_data", "plugin_storage_clear_by_prefix", ]; -fn is_denied(cmd: &str) -> bool { - DENIED_WS_COMMANDS.iter().any(|entry| { - if entry.ends_with('|') { cmd.starts_with(entry) } - else { cmd == *entry } - }) +fn is_allowed_command(cmd: &str) -> bool { + ALLOWED_WS_COMMANDS.contains(&cmd) } ``` ```rust -// hello_ack 전송 시 deny 리스트 포함 +// hello_ack 전송 시 allow 리스트 포함 fn build_hello_ack(&self) -> Value { json!({ "serverVersion": self.server_version, "obsMode": true, - "denyList": DENIED_WS_COMMANDS, + "allowedList": ALLOWED_WS_COMMANDS, }) } ``` -deny에 없는 커맨드는 **자동으로 Tauri가 처리** — 새 커맨드 추가 시 양쪽 모두 수정 불필요. -Tauri의 ACL 시스템이 보안 경계 역할을 하므로 별도 화이트리스트 불필요. +allowlist에 없는 커맨드는 **차단** — 신규 `#[tauri::command]`는 검토 후 명시적으로 추가해야 원격 노출된다. +Tauri ACL에 더해, 변이·파일·창 제어 커맨드를 원격 표면에서 구조적으로 배제하는 것이 이 리스트의 목적. #### 장점 - **match문 완전 제거** — 커맨드별 분기 없음 - **인자 역직렬화 자동** — Tauri의 `#[tauri::command]` 매크로가 처리 - **ACL 재사용** — Tauri permissions 시스템이 보안 검증 -- **새 커맨드 자동 지원** — `#[tauri::command]` 추가하면 WS에서도 즉시 동작 -- **관리 포인트 1개** — Rust deny 리스트만 수정하면 `hello_ack`로 프론트에 자동 전파 +- **fail-closed** — allowlist에 명시한 커맨드만 원격 노출, 신규 커맨드는 기본 차단 +- **관리 포인트 1개** — Rust allow 리스트만 수정하면 `hello_ack`로 프론트에 자동 전파 #### 제약 @@ -973,8 +990,8 @@ function onWsMessage(envelope) { - overlay/App.tsx **코드 변경 0** - obs/index.tsx → IPC Shim 설치 → overlay/App.tsx **동일 코드** 실행 - 중복 로직 **완전 해소** (레거시 624줄 삭제) -- **커맨드 추가 시 양쪽 모두 수정 불필요** — deny 리스트에 없으면 자동 동작 -- **deny 리스트 관리 포인트 1곳** — Rust `DENIED_WS_COMMANDS` 수정 시 WS handshake로 프론트에 자동 반영 +- **신규 커맨드 기본 차단** — allowlist에 명시해야 원격 노출, 위험 커맨드 실수 노출 방지 +- **allow 리스트 관리 포인트 1곳** — Rust `ALLOWED_WS_COMMANDS` 수정 시 WS handshake로 프론트에 자동 반영 - **auto_start_obs 경로**에도 IPC Shim 지원 추가 (set_app_handle + register_event_forwarding) Codex(GPT 5.4) 리뷰에서 발견/수정한 이슈: diff --git a/plugin/kps-builtin.js b/plugin/kps-builtin.js index 64d774b2..aec8d4d7 100644 --- a/plugin/kps-builtin.js +++ b/plugin/kps-builtin.js @@ -82,7 +82,7 @@ dmn.plugin.defineElement({ default: true, label: "settings.showGraph", }, - graphDivider: { type: "divider" }, + graphSection: { type: "section" }, graphType: { type: "select", options: [ diff --git a/plugin/kps.js b/plugin/kps.js index f8e6f5d0..1dc6142c 100644 --- a/plugin/kps.js +++ b/plugin/kps.js @@ -76,7 +76,7 @@ dmn.plugin.defineElement({ default: true, label: "settings.showGraph", }, - graphDivider: { type: "divider" }, + graphSection: { type: "section" }, graphType: { type: "select", options: [ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 74e81a9e..76a3d87d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,6 +2,7 @@ name = "dm-note" version = "1.6.1" edition = "2021" +rust-version = "1.87" default-run = "dm-note" [features] diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index 57c6baaf..5a8e3fd8 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -6,26 +6,25 @@ use uuid::Uuid; use crate::{errors::CmdResult, models::obs::ObsStatus, state::AppState}; /// 저장된 토큰 재사용 또는 신규 생성 후 store에 저장 -fn resolve_and_save_token(state: &AppState) -> String { - let existing = state.store.with_state(|s| s.obs_token.clone()); - if let Some(token) = existing { - if !token.is_empty() { - return token; - } - } - // 신규 생성 후 저장 - let token = Uuid::new_v4().simple().to_string(); +/// 재사용이어도 항상 저장 — 이전 저장 실패로 메모리/디스크가 갈라졌어도 서버가 쓰는 토큰은 +/// 반드시 디스크에 존재하는 토큰이 되도록 시작 시점에 봉합하고, 저장 불가면 시작을 중단 +fn resolve_and_save_token(state: &AppState) -> CmdResult { + let token = state + .store + .with_state(|s| s.obs_token.clone()) + .filter(|token| !token.is_empty()) + .unwrap_or_else(|| Uuid::new_v4().simple().to_string()); let t = token.clone(); - let _ = state.store.update(|s| { + state.store.update(|s| { s.obs_token = Some(t.clone()); - }); - token + })?; + Ok(token) } #[tauri::command] pub async fn obs_start(app: AppHandle, state: State<'_, AppState>) -> CmdResult { let port = state.store.with_state(|s| s.obs_port); - let token = resolve_and_save_token(&state); + let token = resolve_and_save_token(&state)?; // OBS 정적 파일 서빙 설정 if cfg!(debug_assertions) { @@ -58,10 +57,13 @@ pub async fn obs_start(app: AppHandle, state: State<'_, AppState>) -> CmdResult< .await .map_err(crate::errors::CommandError::msg)?; // 성공한 포트를 store에 저장 (fallback 시 다음 시작에 재사용) + // 실패해도 서버는 이미 동작 중 — 다음 시작에 fallback을 다시 거치므로 경고만 남김 if actual_port != port { - let _ = state.store.update(|s| { + if let Err(error) = state.store.update(|s| { s.obs_port = actual_port; - }); + }) { + log::warn!("[ObsBridge] fallback 포트 저장 실패: {error}"); + } } // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨) state.refresh_obs_snapshot(); @@ -90,11 +92,11 @@ pub fn obs_status(state: State<'_, AppState>) -> CmdResult { #[tauri::command] pub fn obs_regenerate_token(app: AppHandle, state: State<'_, AppState>) -> CmdResult { let token = Uuid::new_v4().simple().to_string(); - // store에 저장 + // 디스크 저장이 성공한 뒤에만 메모리 토큰 교체 — 실패 시 구 토큰이 그대로 유효한 일관 상태 유지 let t = token.clone(); - let _ = state.store.update(|s| { + state.store.update(|s| { s.obs_token = Some(t.clone()); - }); + })?; // 실행 중이면 bridge 메모리 토큰도 교체 if state.obs_bridge.is_running() { state.obs_bridge.set_token(token); diff --git a/src-tauri/src/commands/keys/sound.rs b/src-tauri/src/commands/keys/sound.rs index 50ad96bb..9413d93f 100644 --- a/src-tauri/src/commands/keys/sound.rs +++ b/src-tauri/src/commands/keys/sound.rs @@ -385,10 +385,16 @@ pub fn sound_delete( // 원본 파일도 삭제 if let Some(ref orig_rel) = original_rel_path { - let orig_abs = sounds_dir.join(orig_rel); - if orig_abs.exists() && orig_abs.starts_with(&sounds_dir) { - if let Err(e) = fs::remove_file(&orig_abs) { - log::warn!("[Sound] 원본 사운드 파일 삭제 실패: {e}"); + let original_path = sounds_dir.join(orig_rel); + match validate_sound_path(&sounds_dir, &original_path.to_string_lossy()) { + Ok(original_path) if original_path.exists() => { + if let Err(e) = fs::remove_file(&original_path) { + log::warn!("[Sound] 원본 사운드 파일 삭제 실패: {e}"); + } + } + Ok(_) => {} + Err(error) => { + log::warn!("[Sound] 잘못된 원본 사운드 경로 무시: {error}"); } } } @@ -427,12 +433,23 @@ pub fn sound_delete( } } } + + for positions in store.knob_positions.values_mut() { + for knob_position in positions.iter_mut() { + if knob_position.position.sound_path.as_deref() == Some(&path_key) { + knob_position.position.sound_path = None; + knob_position.position.sound_enabled = Some(false); + references_changed = true; + } + } + } })?; if references_changed { 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)?; } Ok(SoundDeleteResponse { success: true }) @@ -582,10 +599,8 @@ pub fn sound_load_original( }) .ok_or_else(|| CommandError::msg("원본 파일 정보가 없습니다."))?; - let original_abs = sounds_dir.join(&original_rel); - if !original_abs.starts_with(&sounds_dir) { - return Err(CommandError::msg("잘못된 원본 경로입니다.")); - } + let original_path = sounds_dir.join(&original_rel); + let original_abs = validate_sound_path(&sounds_dir, &original_path.to_string_lossy())?; if !original_abs.exists() { return Err(CommandError::msg("원본 파일이 존재하지 않습니다.")); } @@ -687,11 +702,47 @@ fn validate_sound_path(sounds_dir: &Path, sound_path: &str) -> CmdResult path, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match fs::symlink_metadata(&path) { + Ok(_) => { + return Err(CommandError::msg(format!("사운드 경로 확인 실패: {error}"))); + } + Err(metadata_error) if metadata_error.kind() != std::io::ErrorKind::NotFound => { + return Err(CommandError::msg(format!( + "사운드 경로 확인 실패: {metadata_error}" + ))); + } + Err(_) => {} + } + + let parent = path + .parent() + .ok_or_else(|| CommandError::msg("사운드 파일의 부모 경로가 없습니다."))?; + let file_name = path + .file_name() + .ok_or_else(|| CommandError::msg("사운드 파일명이 없습니다."))?; + let canonical_parent = fs::canonicalize(parent).map_err(|parent_error| { + CommandError::msg(format!("사운드 파일의 부모 경로 확인 실패: {parent_error}")) + })?; + canonical_parent.join(file_name) + } + Err(error) => { + return Err(CommandError::msg(format!("사운드 경로 확인 실패: {error}"))); + } + }; + + if !canonical_path.starts_with(&canonical_sounds_dir) { return Err(CommandError::msg( "appData/sounds 외부 경로에는 접근할 수 없습니다.", )); } + // canonical은 경계 검사 전용 — 반환은 store 키와 일치하는 원 경로 + // (Windows에서 canonicalize가 \\?\ verbatim 경로를 반환해 조회 키를 오염시키는 것 방지) Ok(path) } @@ -726,3 +777,53 @@ fn is_supported_sound_file(path: &Path) -> bool { .iter() .any(|allowed| ext.eq_ignore_ascii_case(allowed)) } + +#[cfg(test)] +mod tests { + use super::validate_sound_path; + + #[test] + fn sound_path_validation_resolves_existing_and_missing_paths() { + let root = + std::env::temp_dir().join(format!("dmnote-sound-path-test-{}", uuid::Uuid::new_v4())); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let existing = sounds_dir.join("existing.wav"); + std::fs::write(&existing, b"sound").unwrap(); + + // macOS temp_dir는 /var → /private/var 심링크 — 경계 검사는 canonical로 통과하되 + // 반환은 원 경로여야 함 (store 키 일관성) + assert_eq!( + validate_sound_path(&sounds_dir, &existing.to_string_lossy()).unwrap(), + existing + ); + + let missing = sounds_dir.join("missing.wav"); + assert_eq!( + validate_sound_path(&sounds_dir, &missing.to_string_lossy()).unwrap(), + missing + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn sound_path_validation_rejects_parent_directory_escape() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-escape-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let outside = root.join("outside.wav"); + std::fs::write(&outside, b"outside").unwrap(); + + let escaped_existing = sounds_dir.join("..").join("outside.wav"); + assert!(validate_sound_path(&sounds_dir, &escaped_existing.to_string_lossy()).is_err()); + + let escaped_missing = sounds_dir.join("..").join("missing.wav"); + assert!(validate_sound_path(&sounds_dir, &escaped_missing.to_string_lossy()).is_err()); + + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 47e58493..653fefbf 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, fs, path::Path}; +use std::{ + collections::{BTreeSet, HashMap, HashSet}, + fs, + path::Path, +}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use rfd::FileDialog; @@ -6,11 +10,12 @@ use tauri::{AppHandle, Emitter, Manager, State}; use uuid::Uuid; use crate::{ + commands::editor::css::TabCssResponse, defaults::{default_keys, default_positions}, errors::{CmdResult, CommandError}, models::{ CustomCssPatch, CustomJsPatch, FontType, GraphPositions, KeyMappings, KeyPositions, - KnobPositions, NoteSettingsPatch, SettingsPatchInput, StatPositions, + KnobPositions, NoteSettingsPatch, SettingsPatchInput, StatPositions, TabCssOverrides, }, state::AppState, }; @@ -50,8 +55,11 @@ pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult = merged + .custom_fonts + .iter() + .map(|font| font.name.as_str()) + .collect(); + let existing_ids: HashSet<&str> = merged + .custom_fonts + .iter() + .map(|font| font.id.as_str()) + .collect(); + + let mut incoming = Vec::new(); + for mut font in imported_fonts.custom_fonts { + // 같은 이름은 기존 정의 유지 — 요소가 이름으로 폰트를 참조하므로 그대로 해석됨 + if existing_names.contains(font.name.as_str()) { + continue; + } + if existing_ids.contains(font.id.as_str()) { + font.id = Uuid::new_v4().to_string(); + } + incoming.push(font); + } + + if !incoming.is_empty() { + merged.custom_fonts.extend(incoming); + let diff = state.settings.apply_patch(SettingsPatchInput { + font_settings: Some(merged), + ..SettingsPatchInput::default() + })?; + state.emit_settings_changed(&diff, &app)?; + } + } + + app.emit("layerGroups:changed", &full_layer_groups)?; + sync_tab_css_runtime( + state.inner(), + &app, + &previous_tab_css_overrides, + &full_tab_css_overrides, + )?; app.emit("keys:changed", &full_keys)?; app.emit("positions:changed", &full_positions)?; app.emit("statPositions:changed", &full_stat_positions)?; @@ -327,6 +451,43 @@ pub fn preset_load_tab( }) } +fn sync_tab_css_runtime( + state: &AppState, + app: &AppHandle, + previous: &TabCssOverrides, + current: &TabCssOverrides, +) -> CmdResult<()> { + let tab_ids: BTreeSet = previous.keys().chain(current.keys()).cloned().collect(); + + for tab_id in tab_ids { + if previous.get(&tab_id) == current.get(&tab_id) { + continue; + } + + state.unwatch_tab_css(&tab_id); + let css = current.get(&tab_id).cloned(); + if let Some(tab_css) = css.as_ref() { + if tab_css.enabled { + if let Some(path) = tab_css.path.as_deref() { + if let Err(error) = state.watch_tab_css(path, &tab_id) { + log::warn!("[Preset] 탭 CSS 감시 시작 실패 (tab={tab_id}): {error}"); + } + } + } + } + + app.emit( + "tabCss:changed", + &TabCssResponse { + tab_id: tab_id.clone(), + css, + }, + )?; + } + + Ok(()) +} + fn choose_tab_preset_source_tab( keys: &KeyMappings, selected_key_type: Option<&str>, @@ -645,6 +806,7 @@ fn restore_preset_local_sounds( key_positions: &mut KeyPositions, stat_positions: &mut StatPositions, graph_positions: &mut GraphPositions, + knob_positions: &mut KnobPositions, embedded_local_sounds: Option<&[EmbeddedLocalSound]>, ) -> CmdResult<()> { let has_any_sounds = key_positions.values().any(|positions| { @@ -659,28 +821,51 @@ fn restore_preset_local_sounds( positions .iter() .any(|graph_position| option_has_non_empty_text(&graph_position.position.sound_path)) + }) || knob_positions.values().any(|positions| { + positions + .iter() + .any(|knob_position| option_has_non_empty_text(&knob_position.position.sound_path)) }); if !has_any_sounds { return Ok(()); } + let app_data_dir = app.path().app_data_dir()?; + let sounds_dir = app_data_dir.join("sounds"); + + restore_preset_local_sounds_in_dir( + &sounds_dir, + key_positions, + stat_positions, + graph_positions, + knob_positions, + embedded_local_sounds, + ) +} + +fn restore_preset_local_sounds_in_dir( + sounds_dir: &Path, + key_positions: &mut KeyPositions, + stat_positions: &mut StatPositions, + graph_positions: &mut GraphPositions, + knob_positions: &mut KnobPositions, + embedded_local_sounds: Option<&[EmbeddedLocalSound]>, +) -> CmdResult<()> { + fs::create_dir_all(sounds_dir)?; + let embedded_map: HashMap<&str, &EmbeddedLocalSound> = embedded_local_sounds .unwrap_or(&[]) .iter() .map(|sound| (sound.sound_id.as_str(), sound)) .collect(); - let app_data_dir = app.path().app_data_dir()?; - let sounds_dir = app_data_dir.join("sounds"); - fs::create_dir_all(&sounds_dir)?; - let mut restored_path_cache: HashMap = HashMap::new(); for positions in key_positions.values_mut() { for position in positions.iter_mut() { restore_position_sound_reference( - &sounds_dir, + sounds_dir, &embedded_map, &mut restored_path_cache, &mut position.sound_path, @@ -691,7 +876,7 @@ fn restore_preset_local_sounds( for positions in stat_positions.values_mut() { for stat_position in positions.iter_mut() { restore_position_sound_reference( - &sounds_dir, + sounds_dir, &embedded_map, &mut restored_path_cache, &mut stat_position.position.sound_path, @@ -702,7 +887,7 @@ fn restore_preset_local_sounds( for positions in graph_positions.values_mut() { for graph_position in positions.iter_mut() { restore_position_sound_reference( - &sounds_dir, + sounds_dir, &embedded_map, &mut restored_path_cache, &mut graph_position.position.sound_path, @@ -710,6 +895,17 @@ fn restore_preset_local_sounds( } } + for positions in knob_positions.values_mut() { + for knob_position in positions.iter_mut() { + restore_position_sound_reference( + sounds_dir, + &embedded_map, + &mut restored_path_cache, + &mut knob_position.position.sound_path, + )?; + } + } + Ok(()) } @@ -810,3 +1006,61 @@ fn choose_selected_key_type( } "4key".to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{defaults::default_positions, models::KnobPosition}; + + #[test] + fn sound_restore_restores_knob_sound() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-knob-load-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = temp_dir.join("sounds"); + let sound_id = "knob-sound"; + let embedded = vec![EmbeddedLocalSound { + sound_id: sound_id.to_string(), + extension: Some("wav".to_string()), + data_base64: BASE64_STANDARD.encode(b"restored-knob-sound"), + }]; + + let mut position = default_positions()["4key"][0].clone(); + position.sound_path = Some(format!("{PRESET_LOCAL_SOUND_PREFIX}{sound_id}")); + let mut knob_positions = KnobPositions::new(); + knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }], + ); + + restore_preset_local_sounds_in_dir( + &sounds_dir, + &mut KeyPositions::new(), + &mut StatPositions::new(), + &mut GraphPositions::new(), + &mut knob_positions, + Some(&embedded), + ) + .unwrap(); + + let restored_path = Path::new( + knob_positions["4key"][0] + .position + .sound_path + .as_deref() + .unwrap(), + ); + assert!(restored_path.starts_with(&sounds_dir)); + assert_eq!( + std::fs::read(restored_path).unwrap(), + b"restored-knob-sound" + ); + 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 e5bed0a4..93aa9a42 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::models::{ CustomCss, CustomJs, CustomTab, FontSettings, GraphPositions, KeyMappings, KeyPositions, - KnobPositions, NoteSettings, StatPositions, TabNoteOverrides, + KnobPositions, LayerGroups, NoteSettings, StatPositions, TabCssOverrides, TabNoteOverrides, }; #[derive(Serialize)] @@ -58,6 +58,10 @@ pub(crate) struct PresetFile { pub font_settings: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tab_note_overrides: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_groups: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_css_overrides: Option, pub embedded_local_fonts: Option>, pub embedded_local_images: Option>, pub embedded_local_sounds: Option>, @@ -218,3 +222,36 @@ pub(crate) fn option_has_non_empty_text(value: &Option) -> bool { .map(|text| !text.trim().is_empty()) .unwrap_or(false) } + +#[cfg(test)] +mod tests { + use super::PresetFile; + use serde_json::json; + + #[test] + fn preset_round_trip_preserves_layer_groups_and_tab_css_overrides() { + let value = json!({ + "layerGroups": { "4key": [] }, + "tabCssOverrides": { + "4key": { + "path": "/tmp/tab.css", + "content": ".key { color: red; }", + "enabled": true + } + } + }); + let preset: PresetFile = serde_json::from_value(value.clone()).unwrap(); + let serialized = serde_json::to_value(preset).unwrap(); + + assert_eq!(serialized["layerGroups"], value["layerGroups"]); + assert_eq!(serialized["tabCssOverrides"], value["tabCssOverrides"]); + } + + #[test] + fn legacy_preset_defaults_new_fields_to_none() { + let preset: PresetFile = serde_json::from_value(json!({})).unwrap(); + + assert!(preset.layer_groups.is_none()); + assert!(preset.tab_css_overrides.is_none()); + } +} diff --git a/src-tauri/src/commands/preset/save.rs b/src-tauri/src/commands/preset/save.rs index 2b1badf8..e3fe5bbf 100644 --- a/src-tauri/src/commands/preset/save.rs +++ b/src-tauri/src/commands/preset/save.rs @@ -12,7 +12,7 @@ use crate::{ errors::CmdResult, models::{ FontSettings, FontType, GraphPositions, KeyMappings, KeyPositions, KnobPositions, - StatPositions, TabNoteOverrides, + LayerGroups, StatPositions, TabCssOverrides, TabNoteOverrides, }, state::AppState, }; @@ -43,6 +43,7 @@ pub fn preset_save(state: State<'_, AppState>) -> CmdResult) -> CmdResult) -> CmdResult) -> CmdResult) -> CmdResult) -> CmdResult) -> CmdResult HashSet { let mut used = HashSet::new(); @@ -253,6 +287,16 @@ fn collect_used_font_families( } } + for positions in knob_positions.values() { + for knob_position in positions { + maybe_insert_font_family(knob_position.position.font_family.as_ref(), &mut used); + maybe_insert_font_family( + knob_position.position.counter.font_family.as_ref(), + &mut used, + ); + } + } + used } @@ -493,15 +537,18 @@ fn build_preset_sound_payload( key_positions: &KeyPositions, stat_positions: &StatPositions, graph_positions: &GraphPositions, + knob_positions: &KnobPositions, ) -> CmdResult<( KeyPositions, StatPositions, GraphPositions, + KnobPositions, Vec, )> { let mut exported_key_positions = key_positions.clone(); let mut exported_stat_positions = stat_positions.clone(); let mut exported_graph_positions = graph_positions.clone(); + let mut exported_knob_positions = knob_positions.clone(); let mut embedded_local_sounds = Vec::new(); let mut path_to_sound_id: HashMap = HashMap::new(); @@ -535,10 +582,21 @@ fn build_preset_sound_payload( } } + for positions in exported_knob_positions.values_mut() { + for knob_position in positions.iter_mut() { + rewrite_position_sound_reference( + &mut knob_position.position.sound_path, + &mut embedded_local_sounds, + &mut path_to_sound_id, + )?; + } + } + Ok(( exported_key_positions, exported_stat_positions, exported_graph_positions, + exported_knob_positions, embedded_local_sounds, )) } @@ -589,3 +647,55 @@ fn rewrite_position_sound_reference( *sound_ref = Some(format!("{PRESET_LOCAL_SOUND_PREFIX}{sound_id}")); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{defaults::default_positions, models::KnobPosition}; + + #[test] + fn sound_payload_embeds_knob_sound() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-knob-save-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let sound_path = temp_dir.join("knob.wav"); + std::fs::write(&sound_path, b"knob-sound").unwrap(); + + let mut position = default_positions()["4key"][0].clone(); + position.sound_path = Some(sound_path.to_string_lossy().to_string()); + let mut knob_positions = KnobPositions::new(); + knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }], + ); + + let (_, _, _, exported_knobs, embedded) = build_preset_sound_payload( + &KeyPositions::new(), + &StatPositions::new(), + &GraphPositions::new(), + &knob_positions, + ) + .unwrap(); + + assert_eq!(embedded.len(), 1); + let sound_ref = exported_knobs["4key"][0] + .position + .sound_path + .as_deref() + .unwrap(); + let sound_id = sound_ref.strip_prefix(PRESET_LOCAL_SOUND_PREFIX).unwrap(); + assert_eq!(sound_id, embedded[0].sound_id); + assert_eq!( + BASE64_STANDARD.decode(&embedded[0].data_base64).unwrap(), + b"knob-sound" + ); + let _ = std::fs::remove_dir_all(temp_dir); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 22581232..b1387c24 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -203,6 +203,10 @@ pub struct KeyPosition { pub note_color: NoteColor, pub note_opacity: u32, #[serde(default)] + pub note_opacity_top: Option, + #[serde(default)] + pub note_opacity_bottom: Option, + #[serde(default)] pub note_border_radius: Option, /// 노트 넓이(px). None이면 키 width를 사용(자동). #[serde(default)] @@ -219,6 +223,10 @@ pub struct KeyPosition { #[serde(default = "default_note_glow_opacity")] pub note_glow_opacity: u32, #[serde(default)] + pub note_glow_opacity_top: Option, + #[serde(default)] + pub note_glow_opacity_bottom: Option, + #[serde(default)] pub note_glow_color: Option, #[serde(default = "default_note_auto_y_correction")] pub note_auto_y_correction: bool, @@ -1890,4 +1898,27 @@ mod tests { assert_eq!(pos.note_glow_size, 20.0); assert_eq!(pos.note_width, None); } + + #[test] + fn gradient_opacity_fields_survive_serde_round_trip() { + let json = key_position_json( + r#""noteOpacityTop": 91, "noteOpacityBottom": 37, + "noteGlowOpacityTop": 64, "noteGlowOpacityBottom": 18"#, + ); + let position: KeyPosition = serde_json::from_str(&json).unwrap(); + + assert_eq!(position.note_opacity_top, Some(91)); + assert_eq!(position.note_opacity_bottom, Some(37)); + assert_eq!(position.note_glow_opacity_top, Some(64)); + assert_eq!(position.note_glow_opacity_bottom, Some(18)); + + let serialized = serde_json::to_value(&position).unwrap(); + assert_eq!(serialized["noteOpacityTop"], 91); + assert_eq!(serialized["noteOpacityBottom"], 37); + assert_eq!(serialized["noteGlowOpacityTop"], 64); + assert_eq!(serialized["noteGlowOpacityBottom"], 18); + + let restored: KeyPosition = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored, position); + } } diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs index 12e7bd52..d1516802 100644 --- a/src-tauri/src/models/obs.rs +++ b/src-tauri/src/models/obs.rs @@ -27,8 +27,8 @@ pub struct ObsEnvelope { pub struct HelloAckPayload { pub server_version: String, pub obs_mode: bool, - /// OBS 클라이언트에 전달할 deny list (|로 끝나면 prefix 매칭) - pub deny_list: Vec, + /// OBS 클라이언트에 전달할 정확 일치 allowlist + pub allowed_list: Vec, } /// invoke_request 페이로드 (클라이언트 → 서버) @@ -87,3 +87,24 @@ fn timestamp_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } + +#[cfg(test)] +mod tests { + use super::HelloAckPayload; + + #[test] + fn hello_ack_exposes_allowed_list() { + let value = serde_json::to_value(HelloAckPayload { + server_version: "test".to_string(), + obs_mode: true, + allowed_list: vec!["settings_get".to_string()], + }) + .unwrap(); + + assert_eq!( + value.get("allowedList"), + Some(&serde_json::json!(["settings_get"])) + ); + assert!(value.get("denyList").is_none()); + } +} diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index a212cc32..1d61a15d 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -1,7 +1,10 @@ -use std::net::SocketAddr; +use std::io::Cursor; +use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; +use std::pin::Pin; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; +use std::task::{Context, Poll}; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; @@ -10,74 +13,290 @@ use serde_json::Value; use tauri::ipc::{CallbackFn, InvokeBody, InvokeResponse, InvokeResponseBody}; use tauri::webview::InvokeRequest; use tauri::{AppHandle, Listener, Manager, Wry}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, oneshot}; -use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{ + accept_hdr_async, + tungstenite::{ + handshake::server::{ErrorResponse, Request as WsRequest, Response as WsResponse}, + http::{header, StatusCode}, + Message, + }, + WebSocketStream, +}; use crate::models::obs::{ make_envelope, HelloAckPayload, InvokeRequestPayload, ObsBroadcast, ObsEnvelope, ObsStatus, + OBS_PROTOCOL_VERSION, }; -/// 로컬 IP 조회 실패 시 Game Bar가 사용할 loopback fallback host -const DEFAULT_LOOPBACK_HOST: &str = "127.0.0.1"; - -/// OBS 클라이언트에서 실행 불가능한 커맨드 목록 -/// `|`로 끝나는 항목은 prefix 매칭 (예: "plugin:window|" → "plugin:window|*" 전부 차단) -const DENIED_WS_COMMANDS: &[&str] = &[ - // 오버레이 제어 (OBS에서 조작 불가) - "overlay_resize", - "overlay_set_visible", - "overlay_set_lock", - "overlay_set_anchor", - "overlay_get", - // 윈도우/앱 제어 - "window_minimize", - "window_close", - "window_show_main", - "window_open_devtools_all", - "app_quit", - "app_restart", - "app_open_external", - "app_auto_update", - // OBS 서버 제어 (자기 자신 종료/재시작 방지) - "obs_start", - "obs_stop", - "obs_regenerate_token", - // 파일 대화상자 / 파일 쓰기 (로컬 파일 시스템 접근) - "image_load", - "font_load", - "sound_load", - "sound_save_processed_wav", - "css_load", - "css_reset", - "js_load", - "js_reset", - "js_reload", - "preset_load", - "preset_load_tab", - // Tauri 플러그인 (네이티브 윈도우/메뉴/리소스) - "plugin:window|", - "plugin:menu|", - "plugin:resources|", +const MAX_HTTP_HEADER_SIZE: usize = 16 * 1024; + +/// OBS 클라이언트에서 실행 가능한 커맨드 목록 +const ALLOWED_WS_COMMANDS: &[&str] = &[ + "app_bootstrap", + "settings_get", + "layer_groups_get", + "note_tab_get_all", + "note_tab_get", + "css_get", + "css_get_use", + "css_tab_get_all", + "css_tab_get", + "js_get", + "js_get_use", + "get_cursor_settings", + "keys_get", + "keys_get_counters", + "positions_get", + "stat_positions_get", + "graph_positions_get", + "knob_positions_get", + "custom_tabs_list", + "counter_animation_list", + "plugin_bridge_send", + "plugin_bridge_send_to", + "raw_input_subscribe", + "raw_input_unsubscribe", + "plugin_storage_get", + "plugin_storage_set", + "plugin_storage_remove", + "plugin_storage_keys", + "plugin_storage_has_data", + "plugin_storage_clear_by_prefix", ]; -/// 커맨드가 deny list에 해당하는지 확인 -fn is_denied(cmd: &str) -> bool { - DENIED_WS_COMMANDS.iter().any(|entry| { - if let Some(prefix) = entry.strip_suffix('|') { - cmd.starts_with(prefix) - && cmd.len() > prefix.len() - && cmd.as_bytes()[prefix.len()] == b'|' - } else { - cmd == *entry +fn is_allowed_command(command: &str) -> bool { + ALLOWED_WS_COMMANDS.contains(&command) +} + +fn build_allowed_list() -> Vec { + ALLOWED_WS_COMMANDS + .iter() + .map(|command| command.to_string()) + .collect() +} + +struct PrefixedStream { + prefix: Cursor>, + stream: TcpStream, +} + +impl PrefixedStream { + fn new(prefix: Vec, stream: TcpStream) -> Self { + Self { + prefix: Cursor::new(prefix), + stream, + } + } +} + +impl AsyncRead for PrefixedStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let position = this.prefix.position() as usize; + let prefix = this.prefix.get_ref(); + if position < prefix.len() { + let count = (prefix.len() - position).min(buffer.remaining()); + buffer.put_slice(&prefix[position..position + count]); + this.prefix.set_position((position + count) as u64); + return Poll::Ready(Ok(())); + } + Pin::new(&mut this.stream).poll_read(cx, buffer) + } +} + +impl AsyncWrite for PrefixedStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_write(cx, buffer) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_shutdown(cx) + } +} + +async fn read_http_request_headers(stream: &mut TcpStream) -> std::io::Result> { + let mut request = Vec::with_capacity(1024); + let mut chunk = [0u8; 1024]; + + loop { + if request.len() == MAX_HTTP_HEADER_SIZE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP request headers too large", + )); + } + + let remaining = MAX_HTTP_HEADER_SIZE - request.len(); + let chunk_len = remaining.min(chunk.len()); + let read = stream.read(&mut chunk[..chunk_len]).await?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete HTTP request headers", + )); + } + request.extend_from_slice(&chunk[..read]); + + if http_header_end(&request).is_some() { + return Ok(request); } + } +} + +fn http_header_end(request: &[u8]) -> Option { + request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) +} + +fn http_header_values<'a>(request: &'a str, name: &str) -> Vec<&'a str> { + request + .lines() + .skip(1) + .take_while(|line| !line.trim_end_matches('\r').is_empty()) + .filter_map(|line| line.trim_end_matches('\r').split_once(':')) + .filter_map(|(header_name, value)| { + header_name + .eq_ignore_ascii_case(name) + .then_some(value.trim()) + }) + .collect() +} + +fn is_websocket_upgrade_request(request: &str) -> bool { + http_header_values(request, "upgrade").iter().any(|value| { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("websocket")) }) } -/// deny list를 Vec으로 변환 (hello_ack 전송용) -fn build_deny_list() -> Vec { - DENIED_WS_COMMANDS.iter().map(|s| s.to_string()).collect() +fn is_allowed_host_name(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + + let ip_literal = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + ip_literal.parse::().is_ok() +} + +fn is_allowed_host_header(value: &str) -> bool { + let value = value.trim(); + if value.is_empty() || value.bytes().any(|byte| byte.is_ascii_whitespace()) { + return false; + } + if is_allowed_host_name(value) { + return true; + } + + if let Some(closing_bracket) = value.find(']') { + let (host, suffix) = value.split_at(closing_bracket + 1); + return is_allowed_host_name(host) + && suffix + .strip_prefix(':') + .is_some_and(|port| port.parse::().is_ok()); + } + + value + .rsplit_once(':') + .is_some_and(|(host, port)| is_allowed_host_name(host) && port.parse::().is_ok()) +} + +/// 이 머신 자신의 IP인지 판정 (loopback 또는 로컬 인터페이스에 실제 할당된 IP) +fn is_local_machine_ip(ip: &IpAddr) -> bool { + if ip.is_loopback() { + return true; + } + local_ip_address::list_afinet_netifas() + .map(|interfaces| { + interfaces + .iter() + .any(|(_, interface_ip)| interface_ip == ip) + }) + .unwrap_or(false) +} + +/// Origin은 브라우저가 강제하는 헤더 — 이 머신이 서빙한 페이지만 신뢰 +/// (localhost/loopback 또는 로컬 인터페이스에 실제 할당된 IP만 허용, OWASP 권고 allowlist) +fn is_local_machine_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + + let ip_literal = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + let Ok(ip) = ip_literal.parse::() else { + return false; + }; + is_local_machine_ip(&ip) +} + +fn is_allowed_origin(value: &str) -> bool { + tauri::Url::parse(value) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .is_some_and(|host| is_local_machine_host(&host)) +} + +fn has_allowed_http_host(request: &str) -> bool { + let hosts = http_header_values(request, "host"); + hosts.len() == 1 && is_allowed_host_header(hosts[0]) +} + +fn validate_websocket_request(request: &WsRequest) -> Result<(), &'static str> { + let hosts: Vec<_> = request.headers().get_all(header::HOST).iter().collect(); + if hosts.len() != 1 || !hosts[0].to_str().ok().is_some_and(is_allowed_host_header) { + return Err("Invalid Host header"); + } + + let origins: Vec<_> = request.headers().get_all(header::ORIGIN).iter().collect(); + if origins.len() > 1 + || origins + .first() + .is_some_and(|origin| !origin.to_str().ok().is_some_and(is_allowed_origin)) + { + return Err("Invalid Origin header"); + } + + Ok(()) +} + +fn websocket_forbidden_response(reason: &str) -> ErrorResponse { + let mut response = ErrorResponse::new(Some(reason.to_string())); + *response.status_mut() = StatusCode::FORBIDDEN; + response +} + +// OBS 모드는 같은 네트워크의 다른 PC 접속이 제품 계약 — 항상 전체 인터페이스에 바인딩 +// (WS/미디어는 토큰 인증, 커맨드는 allowlist로 보호) +fn bind_address(port: u16) -> SocketAddr { + SocketAddr::from(([0, 0, 0, 0], port)) +} + +async fn write_empty_http_response(stream: &mut TcpStream, status: &str) { + let response = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + let _ = stream.write_all(response.as_bytes()).await; } /// 임베딩 에셋 조회 함수 타입 (path → Option<(bytes, mime_type)>) @@ -212,6 +431,7 @@ impl ObsBridgeService { Some(t.clone()) } }; + // 같은 네트워크의 다른 PC 접속 안내용 LAN IP (조회 실패 시 프론트가 localhost로 fallback) let local_ip = local_ip_address::local_ip().ok().map(|ip| ip.to_string()); ObsStatus { running: self.is_running(), @@ -266,7 +486,7 @@ impl ObsBridgeService { let mut last_err = String::new(); for offset in 0u16..10 { let try_port = port.saturating_add(offset); - let addr = SocketAddr::from(([0, 0, 0, 0], try_port)); + let addr = bind_address(try_port); match TcpListener::bind(addr).await { Ok(l) => { if offset > 0 { @@ -330,6 +550,12 @@ impl ObsBridgeService { /// 세션 토큰 교체 (실행 중 호출 가능) pub fn set_token(&self, token: String) { *self.session_token.write() = token; + // 기존 인증 세션 종료 + let _ = self.broadcast_tx.send(ObsBroadcast::Shutdown); + } + + fn is_current_session_token(&self, token: &str) -> bool { + self.session_token.read().as_str() == token } async fn server_loop( @@ -362,22 +588,47 @@ impl ObsBridgeService { } async fn handle_connection(self: &Arc, mut stream: TcpStream, addr: SocketAddr) { - // TCP 스트림을 peek하여 WebSocket upgrade 요청인지 판별 - let mut peek_buf = [0u8; 4096]; - let n = match stream.peek(&mut peek_buf).await { - Ok(n) if n > 0 => n, - _ => return, + let request_bytes = match tokio::time::timeout( + Duration::from_secs(5), + read_http_request_headers(&mut stream), + ) + .await + { + Ok(Ok(request)) => request, + Ok(Err(error)) => { + log::debug!("[ObsBridge] HTTP 요청 헤더 파싱 실패 from {addr}: {error}"); + write_empty_http_response(&mut stream, "400 Bad Request").await; + return; + } + Err(_) => { + log::debug!("[ObsBridge] HTTP 요청 헤더 타임아웃 from {addr}"); + write_empty_http_response(&mut stream, "408 Request Timeout").await; + return; + } }; - let request_preview = String::from_utf8_lossy(&peek_buf[..n]); - let is_websocket = request_preview.lines().any(|line| { - line.to_ascii_lowercase().starts_with("upgrade:") - && line.to_ascii_lowercase().contains("websocket") - }); + let header_end = http_header_end(&request_bytes).unwrap_or(request_bytes.len()); + let request = match std::str::from_utf8(&request_bytes[..header_end]) { + Ok(request) => request, + Err(error) => { + log::debug!("[ObsBridge] HTTP 요청 헤더 인코딩 오류 from {addr}: {error}"); + write_empty_http_response(&mut stream, "400 Bad Request").await; + return; + } + }; - if is_websocket { + if is_websocket_upgrade_request(request) { // WebSocket 핸드셰이크 - let ws_stream = match tokio_tungstenite::accept_async(stream).await { + #[allow(clippy::result_large_err)] + let callback = move |request: &WsRequest, response: WsResponse| { + if let Err(reason) = validate_websocket_request(request) { + log::warn!("[ObsBridge] WS 요청 거부 from {addr}: {reason}"); + return Err(websocket_forbidden_response(reason)); + } + Ok(response) + }; + let prefixed_stream = PrefixedStream::new(request_bytes, stream); + let ws_stream = match accept_hdr_async(prefixed_stream, callback).await { Ok(ws) => ws, Err(e) => { log::debug!("[ObsBridge] WS 핸드셰이크 실패 from {addr}: {e}"); @@ -387,16 +638,17 @@ impl ObsBridgeService { self.handle_ws_client(ws_stream, addr).await; } else { // HTTP 정적 파일 서빙 - self.handle_http_request(&mut stream, &request_preview) - .await; + self.handle_http_request(&mut stream, addr, request).await; } } /// HTTP GET 요청에 대해 정적 파일 서빙 - async fn handle_http_request(&self, stream: &mut TcpStream, request: &str) { - // 요청 소비 (peek 데이터를 실제로 읽어야 함) - let mut discard = vec![0u8; request.len()]; - let _ = stream.read(&mut discard).await; + async fn handle_http_request(&self, stream: &mut TcpStream, addr: SocketAddr, request: &str) { + if !has_allowed_http_host(request) { + log::warn!("[ObsBridge] HTTP 요청 거부 from {addr}: 허용되지 않은 Host"); + write_empty_http_response(stream, "403 Forbidden").await; + return; + } // GET 경로 파싱 let path = request @@ -407,19 +659,24 @@ impl ObsBridgeService { let route = path.split('?').next().unwrap_or(path); let query = path.split_once('?').map(|(_, q)| q).unwrap_or(""); - if route == "/gamebar" { - self.handle_gamebar_entry(stream).await; - return; - } - - if route == "/gamebar/bootstrap.json" { - self.handle_gamebar_bootstrap(stream).await; + // /media/?token=xxx — 사용자 로컬 미디어 파일 서빙 + // dev 리다이렉트보다 먼저 처리 — 미디어는 Vite가 아니라 브릿지가 직접 서빙 + if let Some(rest) = path.strip_prefix("/media/") { + self.handle_media_request(stream, rest).await; return; } // dev 모드: Vite dev server로 리다이렉트 let dev_url = self.dev_url.read().clone(); if let Some(dev_base) = &dev_url { + // 리다이렉트가 token을 쿼리로 전달하므로 이 머신 자신의 요청에만 허용 — + // 원격 LAN peer가 debug 서버에서 무인증 토큰을 얻는 경로 차단. + // 같은 PC가 자기 LAN IP로 접속하는 경우(URL 복사 기본값)는 peer도 로컬 인터페이스 IP라 유지됨 + if !is_local_machine_ip(&addr.ip()) { + log::warn!("[ObsBridge] dev 리다이렉트 거부 from {addr}: 원격 peer"); + write_empty_http_response(stream, "403 Forbidden").await; + return; + } let is_root_route = route == "/" || route.is_empty(); let redirect_path = if is_root_route { if query.is_empty() { @@ -448,12 +705,6 @@ impl ObsBridgeService { return; } - // /media/?token=xxx — 사용자 로컬 미디어 파일 서빙 - if let Some(rest) = path.strip_prefix("/media/") { - self.handle_media_request(stream, rest).await; - return; - } - // 경로 정규화: "/" → "obs/index.html" let normalized = if route == "/" || route.is_empty() { "obs/index.html" @@ -508,50 +759,6 @@ impl ObsBridgeService { .await; } - async fn handle_gamebar_entry(&self, stream: &mut TcpStream) { - let location = self.build_gamebar_target_url(); - let response = format!( - "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n" - ); - let _ = stream.write_all(response.as_bytes()).await; - } - - async fn handle_gamebar_bootstrap(&self, stream: &mut TcpStream) { - let port = *self.port.read(); - let token = self.session_token.read().clone(); - let body = serde_json::json!({ - "running": self.is_running(), - "port": port, - "token": token, - "url": self.build_gamebar_target_url(), - }) - .to_string(); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(response.as_bytes()).await; - } - - /// Game Bar 셸이 최종적으로 열어야 할 오버레이 진입 URL 구성 - fn build_gamebar_target_url(&self) -> String { - let port = *self.port.read(); - let token = self.session_token.read().clone(); - let host = self.resolve_gamebar_access_host(); - format!("http://{host}:{port}/?host={host}&port={port}&token={token}") - } - - /// Game Bar WebView가 접근할 host 결정 - /// 우선 로컬 네트워크 IP를 사용하고, 실패 시 loopback으로 fallback - fn resolve_gamebar_access_host(&self) -> String { - local_ip_address::local_ip() - .ok() - .map(|ip| ip.to_string()) - .filter(|host| !host.is_empty()) - .unwrap_or_else(|| DEFAULT_LOOPBACK_HOST.to_string()) - } - /// Tauri 임베딩 에셋 조회 async fn resolve_asset(&self, path: &str) -> Option<(Vec, String)> { let fetcher = self.asset_fetcher.read().clone(); @@ -561,11 +768,10 @@ impl ObsBridgeService { None } - async fn handle_ws_client( - self: &Arc, - ws: tokio_tungstenite::WebSocketStream, - addr: SocketAddr, - ) { + async fn handle_ws_client(self: &Arc, ws: WebSocketStream, addr: SocketAddr) + where + S: AsyncRead + AsyncWrite + Unpin + Send, + { self.client_count.fetch_add(1, Ordering::Relaxed); log::info!( "[ObsBridge] 클라이언트 연결: {addr} (총 {})", @@ -611,6 +817,30 @@ impl ObsBridgeService { } }; + // 프로토콜 버전 검증 — handshake 계약이 다른 클라이언트는 fail-closed로 거부 + let client_protocol = hello.payload.get("protocol").and_then(Value::as_u64); + if hello.v != OBS_PROTOCOL_VERSION + || client_protocol != Some(u64::from(OBS_PROTOCOL_VERSION)) + { + log::warn!( + "[ObsBridge] {addr}: 프로토콜 버전 불일치 (v={}, protocol={client_protocol:?}), 연결 거부", + hello.v + ); + let err_msg = make_envelope( + "error", + 0, + serde_json::json!({ + "code": "PROTOCOL_MISMATCH", + "message": format!( + "Unsupported protocol version (expected {OBS_PROTOCOL_VERSION})" + ), + }), + ); + let _ = ws_tx.send(Message::Text(err_msg.to_string())).await; + self.client_count.fetch_sub(1, Ordering::Relaxed); + return; + } + // 보안 토큰 검증 let expected_token = self.session_token.read().clone(); if !expected_token.is_empty() { @@ -632,11 +862,15 @@ impl ObsBridgeService { } } - // hello_ack 전송 (deny list 포함) + // hello_ack 전송 (allowlist 포함) + if !self.is_current_session_token(&expected_token) { + self.client_count.fetch_sub(1, Ordering::Relaxed); + return; + } let ack_payload = serde_json::to_value(HelloAckPayload { server_version: self.server_version.clone(), obs_mode: true, - deny_list: build_deny_list(), + allowed_list: build_allowed_list(), }) .unwrap_or_default(); let ack_msg = make_envelope("hello_ack", next_seq(), ack_payload); @@ -650,6 +884,10 @@ impl ObsBridgeService { } // snapshot 전송 + if !self.is_current_session_token(&expected_token) { + self.client_count.fetch_sub(1, Ordering::Relaxed); + return; + } let snapshot = self.cached_snapshot.read().clone(); let snapshot_msg = make_envelope("snapshot", next_seq(), snapshot); if ws_tx @@ -672,6 +910,9 @@ impl ObsBridgeService { tokio::select! { // broadcast 채널에서 메시지 수신 → 클라이언트에 전송 result = broadcast_rx.recv() => { + if !self.is_current_session_token(&expected_token) { + break; + } match result { Ok(ObsBroadcast::Shutdown) => break, Ok(broadcast) => { @@ -694,6 +935,9 @@ impl ObsBridgeService { } // 클라이언트에서 메시지 수신 msg = ws_rx.next() => { + if !self.is_current_session_token(&expected_token) { + break; + } match msg { Some(Ok(Message::Text(text))) => { if let Ok(envelope) = serde_json::from_str::(&text) { @@ -731,6 +975,9 @@ impl ObsBridgeService { } // RPC 응답 전송 (invoke_request → invoke_response) Some((request_id, result)) = rpc_rx.recv() => { + if !self.is_current_session_token(&expected_token) { + break; + } let payload = match result { Ok(data) => serde_json::json!({ "requestId": request_id, "result": data }), Err(err) => serde_json::json!({ "requestId": request_id, "error": err }), @@ -742,6 +989,9 @@ impl ObsBridgeService { } // 서버 주도 ping (연결 유지) _ = ping_interval.tick() => { + if !self.is_current_session_token(&expected_token) { + break; + } let ping_msg = make_envelope("ping", next_seq(), Value::Null); if ws_tx.send(Message::Text(ping_msg.to_string())).await.is_err() { break; @@ -779,13 +1029,13 @@ impl ObsBridgeService { } }; - // deny 체크 (이중 안전망 — 프론트엔드에서도 차단하지만 백엔드에서 한번 더) - if is_denied(&req.command) { - log::debug!("[ObsBridge] {addr}: denied cmd={}", req.command); + // allowlist 검사 (클라이언트 검사와 별도인 백엔드 안전망) + if !is_allowed_command(&req.command) { + log::debug!("[ObsBridge] {addr}: 허용되지 않은 cmd={}", req.command); let _ = rpc_tx.send(( req.request_id, Err(serde_json::json!(format!( - "Command denied: {}", + "Command not allowed: {}", req.command ))), )); @@ -921,8 +1171,46 @@ impl ObsBridgeService { } }; + if !file_path.is_absolute() { + write_empty_http_response(stream, "403 Forbidden").await; + return; + } + + let app_data_dir = self + .app_handle + .read() + .clone() + .and_then(|app| app.path().app_data_dir().ok()); + let Some(app_data_dir) = app_data_dir else { + write_empty_http_response(stream, "403 Forbidden").await; + return; + }; + let canonical_app_data = match tokio::fs::canonicalize(app_data_dir).await { + Ok(path) => path, + Err(error) => { + log::warn!("[ObsBridge] app data 경로 확인 실패: {error}"); + write_empty_http_response(stream, "403 Forbidden").await; + return; + } + }; + let canonical_file_path = match tokio::fs::canonicalize(&file_path).await { + Ok(path) => path, + Err(_) => { + write_empty_http_response(stream, "404 Not Found").await; + return; + } + }; + if !canonical_file_path.starts_with(&canonical_app_data) { + log::warn!( + "[ObsBridge] app data 밖 media 요청 차단: {}", + file_path.display() + ); + write_empty_http_response(stream, "403 Forbidden").await; + return; + } + // 허용 확장자 화이트리스트 (미디어/폰트 파일만) - let ext = file_path + let ext = canonical_file_path .extension() .and_then(|e| e.to_str()) .unwrap_or("") @@ -952,9 +1240,9 @@ impl ObsBridgeService { } // 파일 읽기 및 서빙 - match tokio::fs::read(&file_path).await { + match tokio::fs::read(&canonical_file_path).await { Ok(content) => { - let mime = guess_mime(&file_path.to_string_lossy()); + let mime = guess_mime(&canonical_file_path.to_string_lossy()); let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: max-age=3600\r\nConnection: close\r\n\r\n", content.len() @@ -1034,3 +1322,403 @@ fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value { ObsBroadcast::Shutdown => unreachable!("Shutdown은 직접 처리됨"), } } + +#[cfg(test)] +mod tests { + use super::*; + use tokio_tungstenite::{connect_async, MaybeTlsStream}; + + type TestWebSocket = WebSocketStream>; + + async fn receive_envelope(ws: &mut TestWebSocket, expected_type: &str) -> ObsEnvelope { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match ws.next().await { + Some(Ok(Message::Text(text))) => { + let envelope = serde_json::from_str::(&text) + .expect("OBS envelope 파싱 실패"); + if envelope.msg_type == expected_type { + return envelope; + } + } + Some(Ok(_)) => {} + Some(Err(error)) => panic!("WS 메시지 수신 실패: {error}"), + None => panic!("WS 연결이 예기치 않게 종료됨"), + } + } + }) + .await + .expect("WS 메시지 수신 타임아웃") + } + + async fn connect_authenticated(port: u16, token: &str) -> TestWebSocket { + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + let hello = make_envelope( + "hello", + 0, + serde_json::json!({ "token": token, "protocol": OBS_PROTOCOL_VERSION }), + ); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + + receive_envelope(&mut ws, "hello_ack").await; + receive_envelope(&mut ws, "snapshot").await; + ws + } + + #[test] + fn websocket_allowlist_uses_exact_matching() { + assert_eq!(ALLOWED_WS_COMMANDS.len(), 30); + assert!(is_allowed_command("app_bootstrap")); + assert!(is_allowed_command("plugin_storage_clear_by_prefix")); + assert!(!is_allowed_command("settings_update")); + assert!(!is_allowed_command("app_bootstrap_extra")); + assert!(!is_allowed_command("plugin:window|close")); + assert_eq!(build_allowed_list().len(), ALLOWED_WS_COMMANDS.len()); + } + + #[test] + fn host_header_allows_only_ip_literals_and_localhost() { + for allowed in [ + "localhost", + "LOCALHOST:34891", + "127.0.0.1", + "127.0.0.1:34891", + "192.168.0.10:80", + "::1", + "[::1]", + "[2001:db8::1]:34891", + ] { + assert!(is_allowed_host_header(allowed), "{allowed}"); + } + + for rejected in [ + "", + "example.com", + "example.com:34891", + "localhost.example.com", + "localhost:invalid", + "[localhost]:34891", + "127.0.0.1:99999", + ] { + assert!(!is_allowed_host_header(rejected), "{rejected}"); + } + } + + #[test] + fn http_host_validation_rejects_missing_or_duplicate_headers() { + assert!(has_allowed_http_host( + "GET / HTTP/1.1\r\nHost: localhost:34891\r\n\r\n" + )); + assert!(!has_allowed_http_host("GET / HTTP/1.1\r\n\r\n")); + assert!(!has_allowed_http_host( + "GET / HTTP/1.1\r\nHost: localhost\r\nHost: example.com\r\n\r\n" + )); + } + + #[test] + fn websocket_origin_allows_absence_or_local_machine_hosts_only() { + let no_origin = WsRequest::builder() + .header(header::HOST, "127.0.0.1:34891") + .body(()) + .unwrap(); + assert!(validate_websocket_request(&no_origin).is_ok()); + + let local_origin = WsRequest::builder() + .header(header::HOST, "localhost:34891") + .header(header::ORIGIN, "http://[::1]:3400") + .body(()) + .unwrap(); + assert!(validate_websocket_request(&local_origin).is_ok()); + + // 이 머신에 할당된 LAN IP는 허용 (LAN 클라이언트가 접속에 쓰는 페이지 origin) + if let Ok(lan_ip) = local_ip_address::local_ip() { + let lan_origin = WsRequest::builder() + .header(header::HOST, format!("{lan_ip}:34891")) + .header(header::ORIGIN, format!("http://{lan_ip}:34891")) + .body(()) + .unwrap(); + assert!(validate_websocket_request(&lan_origin).is_ok()); + } + + let domain_origin = WsRequest::builder() + .header(header::HOST, "127.0.0.1:34891") + .header(header::ORIGIN, "https://example.com") + .body(()) + .unwrap(); + assert_eq!( + validate_websocket_request(&domain_origin), + Err("Invalid Origin header") + ); + + // 이 머신의 인터페이스가 아닌 임의 숫자 IP는 거부 (TEST-NET-3) + let foreign_ip_origin = WsRequest::builder() + .header(header::HOST, "127.0.0.1:34891") + .header(header::ORIGIN, "http://203.0.113.7") + .body(()) + .unwrap(); + assert_eq!( + validate_websocket_request(&foreign_ip_origin), + Err("Invalid Origin header") + ); + } + + async fn http_get(host: &str, port: u16, path: &str) -> String { + let mut stream = TcpStream::connect((host, port)) + .await + .expect("TCP 연결 실패"); + let request = + format!("GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("요청 전송 실패"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("응답 수신 실패"); + String::from_utf8_lossy(&response).into_owned() + } + + #[test] + fn local_machine_ip_rejects_foreign_addresses() { + assert!(is_local_machine_ip(&"127.0.0.1".parse().unwrap())); + assert!(is_local_machine_ip(&"::1".parse().unwrap())); + // TEST-NET-3 — 로컬 인터페이스에 할당될 일 없는 주소 + assert!(!is_local_machine_ip(&"203.0.113.7".parse().unwrap())); + if let Ok(lan_ip) = local_ip_address::local_ip() { + assert!(is_local_machine_ip(&lan_ip)); + } + } + + // dev 리다이렉트(token 쿼리 포함)는 이 머신 자신의 peer에만 허용 — + // 같은 PC가 자기 LAN IP로 접속하는 URL 복사 경로는 유지되어야 함 + #[tokio::test] + async fn dev_redirect_serves_only_local_machine_peers() { + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.set_dev_url("http://localhost:3400".to_string()); + let port = bridge + .start(0, "dev-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + // loopback peer → 기존 dev 부트스트랩(302 + token) 유지 + let response = http_get("127.0.0.1", port, "/").await; + assert!(response.starts_with("HTTP/1.1 302"), "{response}"); + assert!(response.contains("token=dev-token"), "{response}"); + + // 같은 머신의 LAN IP 경유 — peer도 로컬 인터페이스 IP이므로 유지되어야 함 + if let Ok(lan_ip) = local_ip_address::local_ip() { + let response = http_get(&lan_ip.to_string(), port, "/").await; + assert!( + response.starts_with("HTTP/1.1 302"), + "같은 PC의 LAN IP 접속이 깨지면 안 됨: {response}" + ); + } + + bridge.stop(); + } + + // Game Bar 엔드포인트 제거 검증 — 어떤 경로도 무인증 HTTP 응답으로 토큰을 내주면 안 됨 + #[tokio::test] + async fn gamebar_paths_no_longer_leak_token() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "secret-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + for path in ["/gamebar", "/gamebar/bootstrap.json"] { + let mut stream = TcpStream::connect(("127.0.0.1", port)) + .await + .expect("TCP 연결 실패"); + let request = + format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("요청 전송 실패"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("응답 수신 실패"); + let response = String::from_utf8_lossy(&response); + assert!( + !response.contains("secret-token"), + "{path} 응답에 토큰 노출: {response}" + ); + } + + bridge.stop(); + } + + // dev 모드에서도 /media는 Vite 리다이렉트가 아니라 브릿지가 직접 처리해야 함 + #[tokio::test] + async fn media_requests_bypass_dev_redirect() { + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.set_dev_url("http://localhost:3400".to_string()); + let port = bridge + .start(0, "media-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + let mut stream = TcpStream::connect(("127.0.0.1", port)) + .await + .expect("TCP 연결 실패"); + stream + .write_all( + b"GET /media/abc?token=wrong HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + ) + .await + .expect("요청 전송 실패"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("응답 수신 실패"); + let response = String::from_utf8_lossy(&response); + assert!( + response.starts_with("HTTP/1.1 403"), + "302 리다이렉트가 아닌 미디어 핸들러 403이어야 함: {response}" + ); + + bridge.stop(); + } + + #[tokio::test] + async fn protocol_mismatch_is_rejected_before_auth() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + // 올바른 토큰이라도 프로토콜 버전이 다르면 거부되어야 함 + let hello = serde_json::json!({ + "v": 999, + "type": "hello", + "seq": 0, + "payload": { "token": "token", "protocol": 999 }, + }); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + + let error = receive_envelope(&mut ws, "error").await; + assert_eq!( + error.payload.get("code").and_then(Value::as_str), + Some("PROTOCOL_MISMATCH") + ); + + bridge.stop(); + } + + #[test] + fn binding_listens_on_all_interfaces_for_lan_access() { + assert!(bind_address(34891).ip().is_unspecified()); + assert_eq!(bind_address(34891).port(), 34891); + } + + // 같은 네트워크의 다른 PC 접속은 제품 계약 — LAN IP 경유 인증 접속이 실제로 성립해야 함 + #[tokio::test] + async fn lan_address_accepts_authenticated_clients() { + let Ok(lan_ip) = local_ip_address::local_ip() else { + eprintln!("LAN IP 조회 불가 환경, 테스트 스킵"); + return; + }; + + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.update_snapshot(serde_json::json!({ "source": "lan" })); + let port = bridge + .start(0, "lan-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + assert_eq!(bridge.status().local_ip, Some(lan_ip.to_string())); + + let (mut ws, _) = connect_async(format!("ws://{lan_ip}:{port}")) + .await + .expect("LAN IP로 WS 연결 실패"); + let hello = make_envelope( + "hello", + 0, + serde_json::json!({ "token": "lan-token", "protocol": OBS_PROTOCOL_VERSION }), + ); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + receive_envelope(&mut ws, "hello_ack").await; + let snapshot = receive_envelope(&mut ws, "snapshot").await; + assert_eq!(snapshot.payload, serde_json::json!({ "source": "lan" })); + + bridge.stop(); + } + + #[tokio::test] + async fn token_rotation_disconnects_existing_sessions_and_keeps_server_running() { + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.update_snapshot(serde_json::json!({ "source": "initial" })); + let port = bridge + .start(0, "old-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut old_ws = connect_authenticated(port, "old-token").await; + + bridge.set_token("new-token".to_string()); + assert!(bridge.is_running()); + assert_eq!(bridge.status().port, port); + + let resync = make_envelope("resync_request", 1, Value::Null); + let _ = old_ws.send(Message::Text(resync.to_string())).await; + bridge.broadcast_tauri_event( + "token-rotation-test".to_string(), + serde_json::json!({ "authenticated": false }), + ); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match old_ws.next().await { + Some(Ok(Message::Text(text))) => { + let envelope: ObsEnvelope = + serde_json::from_str(&text).expect("OBS envelope 파싱 실패"); + assert_ne!(envelope.msg_type, "snapshot"); + if envelope.msg_type == "tauri_event" { + assert_ne!( + envelope.payload.get("event").and_then(Value::as_str), + Some("token-rotation-test") + ); + } + } + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, + Some(Ok(_)) => {} + } + } + }) + .await + .expect("구 토큰 세션이 종료되지 않음"); + + let mut new_ws = connect_authenticated(port, "new-token").await; + bridge.broadcast_tauri_event( + "token-rotation-test".to_string(), + serde_json::json!({ "authenticated": true }), + ); + let event = receive_envelope(&mut new_ws, "tauri_event").await; + assert_eq!( + event.payload, + serde_json::json!({ + "event": "token-rotation-test", + "data": { "authenticated": true } + }) + ); + + bridge.stop(); + } +} diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 0f1b3eae..d239ca7f 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -69,6 +69,7 @@ pub struct AppState { pub obs_bridge: Arc, /// OBS 모드 시작 전 오버레이 가시성 상태 (복원용) obs_previous_overlay_visible: Arc>>, + shutdown_started: AtomicBool, } impl AppState { @@ -108,6 +109,7 @@ impl AppState { css_watcher: RwLock::new(None), obs_bridge, obs_previous_overlay_visible: Arc::new(RwLock::new(None)), + shutdown_started: AtomicBool::new(false), }) } @@ -222,6 +224,13 @@ impl AppState { } pub fn set_main_window_hidden(&self, hidden: bool) -> Result<()> { + // 값이 그대로면 저장 생략 — 기동마다 전체 store 재기록(fsync) 방지 + if self + .store + .with_state(|state| state.main_window_hidden == hidden) + { + return Ok(()); + } let _ = self.store.update(|state| { state.main_window_hidden = hidden; })?; @@ -535,19 +544,29 @@ impl AppState { } pub fn shutdown(&self) { - if let Err(err) = self.persist_key_counters() { - log::warn!("failed to persist key counters during shutdown: {err}"); - } - if let Err(err) = self.store.cleanup_orphan_assets_now() { - log::warn!("failed to cleanup orphan assets during shutdown: {err}"); + if self.shutdown_started.swap(true, Ordering::SeqCst) { + return; } if let Some(task) = self.keyboard_task.write().take() { drop(task); } - // CSS 워처 정리 if let Some(watcher) = self.css_watcher.write().take() { watcher.shutdown(); } + if let Err(err) = self.persist_key_counters() { + log::warn!("failed to persist key counters during shutdown: {err}"); + } + match self.store.flush_and_shutdown() { + Ok(()) => { + if let Err(err) = self.store.cleanup_orphan_assets_now() { + log::warn!("failed to cleanup orphan assets during shutdown: {err}"); + } + } + Err(err) => { + log::warn!("failed to flush store writer during shutdown: {err}"); + log::warn!("skipping orphan asset cleanup after store flush failure"); + } + } } pub fn request_shutdown(&self, app_handle: AppHandle) { diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index a3161697..95df1f43 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -1,4 +1,5 @@ use std::{ + collections::{HashMap, HashSet}, fs, path::{Path, PathBuf}, }; @@ -7,28 +8,35 @@ use anyhow::{Context, Result}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use dirs_next::config_dir; use serde::Deserialize; -use serde_json::Value; +use serde_json::{Map, Value}; use uuid::Uuid; use crate::{ defaults::{default_keys, default_positions}, models::{ AppStoreData, FontType, GraphPositions, KeyCounters, KeyMappings, KeyPositions, - NoteSettings, OverlayBounds, StatPositions, + KnobPositions, LayerGroups, OverlayBounds, StatPositions, }, }; const LEGACY_OVERLAY_WIDTH: f64 = 860.0; const LEGACY_OVERLAY_HEIGHT: f64 = 320.0; +pub(crate) struct LoadedStore { + pub(crate) data: AppStoreData, + pub(crate) needs_persist: bool, + pub(crate) repaired: bool, +} + /// store 파일 로드 및 마이그레이션 적용 -pub(crate) fn load_store_from_path(path: &Path) -> Result<(AppStoreData, bool)> { - let content = fs::read_to_string(path) +pub(crate) fn load_store_from_path(path: &Path) -> Result { + // 바이트로 읽어 잘못된 UTF-8도 IO 에러 대신 JSON 파싱 실패로 흘려 복구 분기에 합류 + let content = fs::read(path) .with_context(|| format!("failed to read store file at {}", path.display()))?; - let (state, needs_persist) = match serde_json::from_str::(&content) { + let (state, needs_persist, repaired) = match serde_json::from_slice::(&content) { Ok(mut value) => { let sound_library_migrated = migrate_sound_library_enabled(&mut value); - match serde_json::from_value::(value) { + match serde_json::from_value::(value.clone()) { Ok(mut data) => { let mut needs_persist = sound_library_migrated || data.font_settings.custom_fonts.iter().any(|font| { @@ -46,22 +54,43 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result<(AppStoreData, bool)> if migrate_legacy_knob_sensitivity(&mut data) { needs_persist = true; } - (normalize_state(data), needs_persist) + (normalize_state(data), needs_persist, false) + } + Err(err) => { + log::warn!( + "[Store] Falling back to field-level recovery for {}: {err}", + path.display() + ); + (repair_legacy_state(value), true, true) } - // 레거시/비정상 store 파일 복구 후 정규화 상태 저장 - Err(_) => (repair_legacy_state(&content), true), } } - // 레거시/비정상 store 파일 복구 후 정규화 상태 저장 - Err(_) => (repair_legacy_state(&content), true), + Err(err) => { + log::warn!( + "[Store] Falling back to default recovery for invalid JSON at {}: {err}", + path.display() + ); + (repair_legacy_state(Value::Null), true, true) + } }; + // 로드 시점은 정의와 참조가 함께 확정되는 경계 — dangling groupId 정리 + // 정리가 발생하면 마이그레이션과 같은 경로로 디스크에도 영속 + let mut state = state; + let mut needs_persist = needs_persist; + if clear_dangling_group_ids(&mut state) { + needs_persist = true; + } if needs_persist { log::info!( "[Store] Persisting migrated store file at {}", path.display() ); } - Ok((state, needs_persist)) + Ok(LoadedStore { + data: state, + needs_persist, + repaired, + }) } fn migrate_sound_library_enabled(value: &mut Value) -> bool { @@ -148,9 +177,10 @@ pub(crate) fn migrate_local_fonts_to_app_data( _ => "", }; - if !local_path.is_empty() { - let source = PathBuf::from(local_path); + let source = (!local_path.is_empty()).then(|| PathBuf::from(local_path)); + let source_exists = source.as_ref().is_some_and(|path| path.exists()); + if let Some(source) = source.as_ref() { // 이미 앱 데이터 fonts 디렉터리 내부에 있으면 cssContent만 제거 if source.starts_with(&fonts_dir) && source.exists() { if font.css_content.is_some() { @@ -160,14 +190,14 @@ pub(crate) fn migrate_local_fonts_to_app_data( continue; } - if source.exists() { + if source_exists { let ext = source .extension() .and_then(|e| e.to_str()) .unwrap_or("ttf") .to_lowercase(); let dest = fonts_dir.join(format!("{}.{}", Uuid::new_v4(), ext)); - match fs::copy(&source, &dest) { + match fs::copy(source, &dest) { Ok(_) => { font.local_path = Some(dest.to_string_lossy().to_string()); font.css_content = None; @@ -184,15 +214,43 @@ pub(crate) fn migrate_local_fonts_to_app_data( } } - // 폰트 파일 임포트 실패 시 비활성화하고 cssContent 제거 (store 비대화 방지) + let fallback_extension = source + .as_ref() + .and_then(|path| path.extension()) + .and_then(|extension| extension.to_str()); + if let Some((bytes, extension)) = font + .css_content + .as_deref() + .and_then(|css| decode_font_data_url(css, fallback_extension)) + { + let dest = fonts_dir.join(format!("{}.{}", Uuid::new_v4(), extension)); + match fs::write(&dest, bytes) { + Ok(_) => { + font.local_path = Some(dest.to_string_lossy().to_string()); + font.css_content = None; + changed = true; + continue; + } + Err(err) => { + log::warn!( + "[Fonts] Failed to restore embedded local font into {}: {err}", + dest.display() + ); + continue; + } + } + } + + if source_exists { + continue; + } + + // 복구 가능한 원본과 data URI가 모두 없을 때 비활성화만 수행 + // css_content는 보존 — 미지 mime 등 디코더 개선 시 재복구 여지 유지 if font.enabled { font.enabled = false; changed = true; } - if font.css_content.is_some() { - font.css_content = None; - changed = true; - } } changed @@ -217,6 +275,11 @@ pub(crate) fn migrate_key_images_to_app_data(app_data_dir: &Path, data: &mut App option_has_non_empty_text(&graph_position.position.active_image) || option_has_non_empty_text(&graph_position.position.inactive_image) }) + }) || data.knob_positions.values().any(|positions| { + positions.iter().any(|knob_position| { + option_has_non_empty_text(&knob_position.position.active_image) + || option_has_non_empty_text(&knob_position.position.inactive_image) + }) }); if !has_any_images { @@ -266,6 +329,19 @@ pub(crate) fn migrate_key_images_to_app_data(app_data_dir: &Path, data: &mut App } } + for positions in data.knob_positions.values_mut() { + for knob_position in positions.iter_mut() { + changed |= migrate_image_reference_to_app_data( + &images_dir, + &mut knob_position.position.active_image, + ); + changed |= migrate_image_reference_to_app_data( + &images_dir, + &mut knob_position.position.inactive_image, + ); + } + } + changed } @@ -437,6 +513,83 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { data } +/// 그룹 정의가 사라진 요소의 groupId를 해제 — dangling 참조는 UI에서 해석 불가 +/// 정리 발생 여부를 반환 (로드 경로에서 needs_persist 판단에 사용) +/// +/// 파일 로드·프리셋 임포트처럼 정의와 참조가 함께 확정되는 경계에서만 호출할 것. +/// 프론트는 positions(groupId 포함)를 먼저 저장하고 layerGroups를 나중에 저장하므로, +/// normalize_state 같은 부분 저장 경로에서 돌리면 새 그룹 생성이 파괴된다. +pub(crate) fn clear_dangling_group_ids(data: &mut AppStoreData) -> bool { + clear_dangling_group_ids_in( + &mut data.key_positions, + &mut data.stat_positions, + &mut data.graph_positions, + &mut data.knob_positions, + &data.layer_groups, + ) +} + +pub(crate) fn clear_dangling_group_ids_in( + key_positions: &mut KeyPositions, + stat_positions: &mut StatPositions, + graph_positions: &mut GraphPositions, + knob_positions: &mut KnobPositions, + layer_groups: &LayerGroups, +) -> bool { + let valid_ids: HashMap<&str, HashSet<&str>> = layer_groups + .iter() + .map(|(tab, groups)| { + ( + tab.as_str(), + groups.iter().map(|group| group.id.as_str()).collect(), + ) + }) + .collect(); + + let is_dangling = |tab: &str, group_id: &Option| { + group_id.as_ref().is_some_and(|id| { + !valid_ids + .get(tab) + .is_some_and(|ids| ids.contains(id.as_str())) + }) + }; + + let mut changed = false; + for (tab, positions) in key_positions.iter_mut() { + for position in positions.iter_mut() { + if is_dangling(tab, &position.group_id) { + position.group_id = None; + changed = true; + } + } + } + for (tab, positions) in stat_positions.iter_mut() { + for stat in positions.iter_mut() { + if is_dangling(tab, &stat.position.group_id) { + stat.position.group_id = None; + changed = true; + } + } + } + for (tab, positions) in graph_positions.iter_mut() { + for graph in positions.iter_mut() { + if is_dangling(tab, &graph.position.group_id) { + graph.position.group_id = None; + changed = true; + } + } + } + for (tab, positions) in knob_positions.iter_mut() { + for knob in positions.iter_mut() { + if is_dangling(tab, &knob.position.group_id) { + knob.position.group_id = None; + changed = true; + } + } + } + changed +} + /// 누락된 모드만 기본값으로 채움 fn merge_default_modes(target: &mut KeyMappings, defaults: &KeyMappings) { for (mode, value) in defaults.iter() { @@ -467,172 +620,79 @@ fn merge_default_counters(target: &mut KeyCounters, keys: &KeyMappings) { target.retain(|mode, _| available_modes.contains(mode)); } -/// 레거시/비정상 store 파일 수동 복구 -fn repair_legacy_state(raw: &str) -> AppStoreData { - let value: Value = serde_json::from_str(raw).unwrap_or(Value::Null); - let mut data = AppStoreData::default(); - if let Value::Object(obj) = value { - if let Some(v) = obj.get("hardwareAcceleration").and_then(Value::as_bool) { - data.hardware_acceleration = v; - } - if let Some(v) = obj.get("alwaysOnTop").and_then(Value::as_bool) { - data.always_on_top = v; - } - if let Some(v) = obj.get("overlayLocked").and_then(Value::as_bool) { - data.overlay_locked = v; - } - if let Some(v) = obj.get("noteEffect").and_then(Value::as_bool) { - data.note_effect = v; - } - if let Some(v) = obj - .get("noteSettings") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - { - data.note_settings = v; - } - if let Some(v) = obj.get("selectedKeyType").and_then(Value::as_str) { - data.selected_key_type = v.to_string(); - } - if let Some(v) = obj - .get("customTabs") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - { - data.custom_tabs = v; - } - if let Some(v) = obj - .get("counterAnimationPresets") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - { - data.counter_animation_presets = v; - } - if let Some(v) = obj.get("angleMode").and_then(Value::as_str) { - data.angle_mode = v.to_string(); - } - if let Some(v) = obj.get("language").and_then(Value::as_str) { - data.language = v.to_string(); - } - if let Some(v) = obj.get("laboratoryEnabled").and_then(Value::as_bool) { - data.laboratory_enabled = v; - } - if let Some(v) = obj.get("developerModeEnabled").and_then(Value::as_bool) { - data.developer_mode_enabled = v; - } - if let Some(v) = obj.get("trayEnabled").and_then(Value::as_bool) { - data.tray_enabled = v; - } - if let Some(v) = obj.get("autoUpdateEnabled").and_then(Value::as_bool) { - data.auto_update_enabled = v; - } - if let Some(v) = obj.get("mainWindowHidden").and_then(Value::as_bool) { - data.main_window_hidden = v; - } - if let Some(v) = obj - .get("keys") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - { - data.keys = v; - } - if let Some(v) = obj - .get("keyPositions") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - { - data.key_positions = v; - } - if let Some(v) = obj - .get("statPositions") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - { - data.stat_positions = v; - } - if let Some(v) = obj - .get("graphPositions") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - { - data.graph_positions = v; - } - if let Some(v) = obj - .get("keyCounters") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - { - data.key_counters = v; - } - if let Some(v) = obj.get("backgroundColor").and_then(Value::as_str) { - data.background_color = v.to_string(); - } - if let Some(v) = obj.get("useCustomCSS").and_then(Value::as_bool) { - data.use_custom_css = v; - } else if let Some(v) = obj.get("useCustomCss").and_then(Value::as_bool) { - data.use_custom_css = v; - } - if let Some(v) = obj - .get("customCSS") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - { - data.custom_css = v; - } else if let Some(v) = obj - .get("customCss") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - { - data.custom_css = v; - } - if let Some(v) = obj.get("useCustomJS").and_then(Value::as_bool) { - data.use_custom_js = v; - } else if let Some(v) = obj.get("useCustomJs").and_then(Value::as_bool) { - data.use_custom_js = v; - } - if let Some(v) = obj - .get("customJS") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - { - data.custom_js = v; - } else if let Some(v) = obj - .get("customJs") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - { - data.custom_js = v; - } - if let Some(v) = obj - .get("overlayResizeAnchor") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - { - data.overlay_resize_anchor = v; - } - if let Some(v) = obj - .get("overlayWindowBounds") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - { - data.overlay_bounds = Some(OverlayBounds { - x: v.x, - y: v.y, - width: v.width, - height: v.height, - }); - } - if data.overlay_bounds.is_none() { - if let Some(v) = obj - .get("overlayWindowPosition") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - { - data.overlay_bounds = Some(OverlayBounds { - x: v.x, - y: v.y, - width: LEGACY_OVERLAY_WIDTH, - height: LEGACY_OVERLAY_HEIGHT, - }); +/// 레거시/비정상 store 파일 필드별 복구 +fn repair_legacy_state(value: Value) -> AppStoreData { + let Value::Object(mut source) = value else { + return normalize_state(AppStoreData::default()); + }; + migrate_legacy_repair_fields(&mut source); + + let Value::Object(mut recovered) = + serde_json::to_value(AppStoreData::default()).unwrap_or_else(|_| Value::Object(Map::new())) + else { + return normalize_state(AppStoreData::default()); + }; + + for (field, value) in source { + let previous = recovered.insert(field.clone(), value); + if serde_json::from_value::(Value::Object(recovered.clone())).is_err() { + log::warn!("[Store] Resetting invalid field to default: {field}"); + match previous { + Some(value) => { + recovered.insert(field, value); + } + None => { + recovered.remove(&field); + } } } - if let Some(v) = obj - .get("overlayLastContentTopOffset") - .and_then(Value::as_f64) - { - data.overlay_last_content_top_offset = Some(v); + } + + let mut data = + serde_json::from_value::(Value::Object(recovered)).unwrap_or_default(); + migrate_legacy_knob_sensitivity(&mut data); + normalize_state(data) +} + +fn migrate_legacy_repair_fields(fields: &mut Map) { + promote_legacy_field(fields, "useCustomCss", "useCustomCSS"); + promote_legacy_field(fields, "customCss", "customCSS"); + promote_legacy_field(fields, "useCustomJs", "useCustomJS"); + promote_legacy_field(fields, "customJs", "customJS"); + + let legacy_bounds = fields.remove("overlayWindowBounds"); + let legacy_position = fields.remove("overlayWindowPosition"); + if fields.contains_key("overlayBounds") { + return; + } + if let Some(value) = legacy_bounds { + if serde_json::from_value::(value.clone()).is_ok() { + fields.insert("overlayBounds".to_string(), value); + return; } - if let Some(v) = obj.get("keyCounterEnabled").and_then(Value::as_bool) { - data.key_counter_enabled = v; + } + if let Some(value) = legacy_position { + if let Ok(position) = serde_json::from_value::(value) { + fields.insert( + "overlayBounds".to_string(), + serde_json::json!({ + "x": position.x, + "y": position.y, + "width": LEGACY_OVERLAY_WIDTH, + "height": LEGACY_OVERLAY_HEIGHT, + }), + ); } } - let _ = data.custom_js.normalize(); - normalize_state(data) +} + +fn promote_legacy_field(fields: &mut Map, current: &str, legacy: &str) { + if fields.contains_key(current) { + return; + } + if let Some(value) = fields.remove(legacy) { + fields.insert(current.to_string(), value); + } } fn option_has_non_empty_text(value: &Option) -> bool { @@ -642,6 +702,55 @@ fn option_has_non_empty_text(value: &Option) -> bool { .unwrap_or(false) } +fn decode_font_data_url( + value: &str, + fallback_extension: Option<&str>, +) -> Option<(Vec, String)> { + let lower = value.to_ascii_lowercase(); + let data_start = lower.find("data:")?; + let data_url = &value[data_start..]; + let (header, raw_payload) = data_url.split_once(',')?; + let header_lower = header.to_ascii_lowercase(); + if !header_lower.contains(";base64") { + return None; + } + + let mime = header_lower.strip_prefix("data:")?.split(';').next()?; + let extension = match mime { + "font/otf" | "application/x-font-opentype" => "otf", + "font/woff" | "application/font-woff" | "application/x-font-woff" => "woff", + "font/woff2" | "application/font-woff2" => "woff2", + "font/ttf" | "application/x-font-ttf" => "ttf", + _ if mime.starts_with("font/") => normalize_font_extension(fallback_extension), + _ => return None, + }; + let payload: String = raw_payload + .chars() + .take_while(|character| !matches!(character, ')' | '\'' | '"' | ';')) + .filter(|character| !character.is_ascii_whitespace()) + .collect(); + if payload.is_empty() { + return None; + } + + let bytes = BASE64_STANDARD.decode(payload.as_bytes()).ok()?; + Some((bytes, extension.to_string())) +} + +fn normalize_font_extension(extension: Option<&str>) -> &'static str { + match extension + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "otf" => "otf", + "woff" => "woff", + "woff2" => "woff2", + _ => "ttf", + } +} + fn decode_image_data_url(value: &str) -> Option<(Vec, String)> { let (header, payload) = value.split_once(',')?; let header_lower = header.to_ascii_lowercase(); @@ -692,15 +801,6 @@ fn normalize_image_extension(extension: Option<&str>) -> String { } } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct LegacyOverlayBounds { - x: f64, - y: f64, - width: f64, - height: f64, -} - #[derive(Deserialize)] struct LegacyOverlayPosition { x: f64, @@ -709,8 +809,86 @@ struct LegacyOverlayPosition { #[cfg(test)] mod tests { - use super::{load_store_from_path, migrate_sound_library_enabled, rgba_to_hex}; - use crate::models::AppStoreData; + use super::{ + load_store_from_path, migrate_local_fonts_to_app_data, migrate_sound_library_enabled, + normalize_state, rgba_to_hex, + }; + use crate::{ + defaults::default_positions, + models::{ + AppStoreData, CustomFont, FontType, KnobPosition, LayerGroupDef, OverlayBounds, + SoundLibraryEntry, TabCss, TabNoteSettings, + }, + }; + use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + + #[test] + fn clear_dangling_group_ids_removes_ghosts_and_keeps_valid_ones() { + let mut data = AppStoreData::default(); + let mut positions = default_positions().clone(); + if let Some(list) = positions.get_mut("4key") { + list[0].group_id = Some("ghost-group".to_string()); + list[1].group_id = Some("real-group".to_string()); + } + data.key_positions = positions; + data.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "real-group".to_string(), + name: "Group".to_string(), + }], + ); + + assert!(super::clear_dangling_group_ids(&mut data)); + let list = &data.key_positions["4key"]; + assert_eq!(list[0].group_id, None); + assert_eq!(list[1].group_id.as_deref(), Some("real-group")); + + // 정리할 것이 없으면 변경 없음을 보고 (로드 시 불필요한 재저장 방지) + assert!(!super::clear_dangling_group_ids(&mut data)); + } + + // 부분 저장(update_*)은 positions 먼저 → layerGroups 나중 순서로 들어오므로 + // normalize_state가 중간 상태의 신규 groupId를 지워선 안 됨 + #[test] + fn normalize_state_preserves_group_ids_saved_before_group_definitions() { + let mut data = AppStoreData::default(); + let mut positions = default_positions().clone(); + if let Some(list) = positions.get_mut("4key") { + list[0].group_id = Some("group-created-just-now".to_string()); + } + data.key_positions = positions; + // layerGroups 정의는 아직 저장 전 (다음 커맨드에서 도착) + + let normalized = normalize_state(data); + assert_eq!( + normalized.key_positions["4key"][0].group_id.as_deref(), + Some("group-created-just-now") + ); + } + + #[test] + fn load_clears_dangling_group_ids_from_disk() { + let dir = + std::env::temp_dir().join(format!("dmnote-dangling-load-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + + let mut data = AppStoreData::default(); + let mut positions = default_positions().clone(); + if let Some(list) = positions.get_mut("4key") { + list[0].group_id = Some("ghost-group".to_string()); + } + data.key_positions = positions; + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert_eq!(loaded.data.key_positions["4key"][0].group_id, None); + // 정리 결과가 디스크에도 영속되도록 재저장 필요 플래그가 올라가야 함 + assert!(loaded.needs_persist); + + let _ = std::fs::remove_dir_all(dir); + } use serde_json::{json, Value}; const TEST_SOUND_PATH: &str = "/tmp/test-sound.wav"; @@ -727,9 +905,9 @@ mod tests { ); std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); - let result = load_store_from_path(&path).unwrap(); + let loaded = load_store_from_path(&path).unwrap(); let _ = std::fs::remove_file(path); - result + (loaded.data, loaded.needs_persist) } #[test] @@ -808,4 +986,169 @@ mod tests { assert_eq!(rgba_to_hex("garbage"), None); assert_eq!(rgba_to_hex("rgba(300, 0, 0, 1)"), None); // u8 범위 초과 } + + #[test] + fn invalid_field_recovery_preserves_every_other_store_field() { + let path = std::env::temp_dir().join(format!( + "dmnote-field-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut expected = AppStoreData { + hardware_acceleration: false, + always_on_top: false, + overlay_bounds: Some(OverlayBounds { + x: 11.0, + y: 22.0, + width: 933.0, + height: 411.0, + }), + obs_mode_enabled: true, + obs_port: 18_321, + obs_token: Some("obs-token-sentinel".to_string()), + ..AppStoreData::default() + }; + expected.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "layer-group-sentinel".to_string(), + name: "Layer Group".to_string(), + }], + ); + expected.plugin_data.insert( + "pluginData".to_string(), + json!({ "sentinel": "plugin-data" }), + ); + expected.plugin_data.insert( + "obsSettings".to_string(), + json!({ "sentinel": "obs-settings" }), + ); + expected.grid_settings.grid_snap_size = 9; + expected.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "knob-axis-sentinel".to_string(), + sensitivity: 2.5, + reverse: true, + position: default_positions()["4key"][0].clone(), + }], + ); + expected.font_settings.custom_fonts.push(CustomFont { + id: "custom-font-sentinel".to_string(), + font_type: FontType::Web, + name: "Custom Font".to_string(), + display_name: "Custom Font".to_string(), + enabled: true, + local_path: None, + css_content: Some("@font-face { font-family: Custom; }".to_string()), + }); + expected.tab_css_overrides.insert( + "custom-tab".to_string(), + TabCss { + path: Some("/tmp/custom-tab.css".to_string()), + content: ".sentinel { color: red; }".to_string(), + enabled: true, + }, + ); + expected.tab_note_overrides.insert( + "custom-tab".to_string(), + TabNoteSettings { + speed: Some(987), + ..TabNoteSettings::default() + }, + ); + expected.shortcuts.toggle_always_on_top.key = "F12".to_string(); + expected.sound_library.insert( + "/tmp/sound-sentinel.wav".to_string(), + SoundLibraryEntry { + display_name: Some("Sound Sentinel".to_string()), + ..SoundLibraryEntry::default() + }, + ); + + let mut fixture = serde_json::to_value(&expected).unwrap(); + assert!(serde_json::from_value::(fixture.clone()).is_ok()); + fixture.as_object_mut().unwrap().insert( + "alwaysOnTop".to_string(), + Value::String("invalid".to_string()), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + expected.always_on_top = AppStoreData::default().always_on_top; + expected = normalize_state(expected); + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data, expected); + } + + #[test] + fn non_utf8_store_recovers_instead_of_failing_init() { + // 잘못된 UTF-8 바이트(0xFF)는 과거 read_to_string에서 IO 에러로 초기화 실패 + .bak 미생성 + let path = std::env::temp_dir().join(format!( + "dmnote-non-utf8-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, [b'{', 0xFF, b'}']).unwrap(); + + let loaded = load_store_from_path(&path).expect("비 UTF-8 store는 Err 대신 복구되어야 함"); + let _ = std::fs::remove_file(&path); + + // 복구 분기 합류 — repaired/needs_persist로 store.rs가 .bak 백업 후 재저장 + assert!(loaded.repaired); + assert!(loaded.needs_persist); + + // 문법만 깨진 UTF-8 JSON과 동일한 기본값 복구 경로로 수렴 + let broken_path = std::env::temp_dir().join(format!( + "dmnote-broken-json-baseline-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write(&broken_path, b"{ not json").unwrap(); + let baseline = load_store_from_path(&broken_path).unwrap(); + let _ = std::fs::remove_file(&broken_path); + assert_eq!(loaded.data, baseline.data); + } + + #[test] + fn missing_local_font_is_restored_from_embedded_data_uri() { + let app_data_dir = std::env::temp_dir().join(format!( + "dmnote-font-migration-test-{}", + uuid::Uuid::new_v4() + )); + let missing_path = app_data_dir.join("missing.woff2"); + let font_bytes = b"embedded-font"; + let css_content = format!( + "@font-face {{ src: url(data:font/woff2;base64,{}) format('woff2'); }}", + BASE64_STANDARD.encode(font_bytes) + ); + let mut data = AppStoreData::default(); + data.font_settings.custom_fonts.push(CustomFont { + id: "local-font".to_string(), + font_type: FontType::Local, + name: "Local Font".to_string(), + display_name: "Local Font".to_string(), + enabled: true, + local_path: Some(missing_path.to_string_lossy().to_string()), + css_content: Some(css_content), + }); + + assert!(migrate_local_fonts_to_app_data(&app_data_dir, &mut data)); + + let font = &data.font_settings.custom_fonts[0]; + let restored_path = std::path::PathBuf::from(font.local_path.as_ref().unwrap()); + assert!(font.enabled); + assert!(font.css_content.is_none()); + assert_eq!( + restored_path.parent(), + Some(app_data_dir.join("fonts").as_path()) + ); + assert_eq!( + restored_path.extension().and_then(|ext| ext.to_str()), + Some("woff2") + ); + assert_eq!(std::fs::read(&restored_path).unwrap(), font_bytes); + + let _ = std::fs::remove_dir_all(app_data_dir); + } } diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 35feb4a7..7af685f9 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -1,19 +1,22 @@ use std::{ collections::HashSet, - fs, + fs::{self, OpenOptions}, io::Write, path::{Path, PathBuf}, + sync::mpsc, + thread::{self, JoinHandle}, }; -use anyhow::{Context, Result}; -use parking_lot::RwLock; +use anyhow::{anyhow, Context, Result}; +use parking_lot::{Mutex, RwLock, RwLockWriteGuard}; use serde_json::Value; use tauri::path::PathResolver; use tauri::Runtime; +use uuid::Uuid; use crate::models::{ - AppStoreData, FontType, GraphPositions, KeyCounters, KeyMappings, KeyPositions, KnobPositions, - LayerGroups, SettingsState, StatPositions, + AppStoreData, FontType, GraphPositions, KeyCounters, KeyMappings, KeyPosition, KeyPositions, + KnobPositions, LayerGroups, SettingsState, StatPositions, }; use super::builtin_sounds::seed_builtin_sounds; @@ -24,7 +27,40 @@ use super::migration::{ pub struct AppStore { path: PathBuf, - state: RwLock, + state: RwLock, + writer: StoreWriter, +} + +struct VersionedStoreState { + data: AppStoreData, + revision: u64, + accepting_writes: bool, +} + +struct PersistTicket { + revision: u64, + completion_rx: mpsc::Receiver, +} + +struct PersistCompletion { + revision: u64, + result: std::result::Result<(), String>, +} + +enum WriterMessage { + Persist { + revision: u64, + snapshot: Box, + completion_tx: mpsc::Sender, + }, + Shutdown { + completion_tx: mpsc::Sender<()>, + }, +} + +struct StoreWriter { + sender: Mutex>>, + handle: Mutex>>, } impl AppStore { @@ -32,47 +68,50 @@ impl AppStore { let dir = resolver .app_data_dir() .context("failed to resolve app data directory")?; - fs::create_dir_all(&dir) + Self::initialize_in_dir(&dir) + } + + fn initialize_in_dir(dir: &Path) -> Result { + fs::create_dir_all(dir) .with_context(|| format!("failed to create data directory at {}", dir.display()))?; let default_path = dir.join("store.json"); let (path, mut state, mut needs_persist) = if default_path.exists() { - let (state, migrated) = load_store_from_path(&default_path)?; - (default_path.clone(), state, migrated) + let loaded = load_store_from_path(&default_path)?; + if loaded.repaired { + backup_store_file(&default_path)?; + } + (default_path.clone(), loaded.data, loaded.needs_persist) } else if let Some(legacy_path) = find_legacy_store_file() { // 레거시 파일 로드 후 새 포맷으로 default_path에 저장 - let (legacy, _) = load_store_from_path(&legacy_path)?; - (default_path.clone(), legacy, true) + let loaded = load_store_from_path(&legacy_path)?; + (default_path.clone(), loaded.data, true) } else { (default_path, initialize_default_state(), true) }; // 마이그레이션: 로컬 폰트 base64 cssContent → 앱 데이터 경로 기반 파일로 변환 - if migrate_local_fonts_to_app_data(&dir, &mut state) { + if migrate_local_fonts_to_app_data(dir, &mut state) { needs_persist = true; } - if migrate_key_images_to_app_data(&dir, &mut state) { + if migrate_key_images_to_app_data(dir, &mut state) { needs_persist = true; } // 내장 키음 시딩 - if seed_builtin_sounds(&dir, &mut state) { + if seed_builtin_sounds(dir, &mut state) { needs_persist = true; } - let store = Self { - path: path.clone(), - state: RwLock::new(state), - }; + let store = Self::new(path.clone(), state)?; if needs_persist || !path.exists() { - let snapshot = store.state.read().clone(); - store.persist_locked(&snapshot)?; + store.persist_current()?; } // macOS: WKWebView Metal 설정 강제 적용 #[cfg(target_os = "macos")] { - let should_force = store.state.read().angle_mode != "metal"; + let should_force = store.state.read().data.angle_mode != "metal"; if should_force { store.update(|state| { state.angle_mode = "metal".to_string(); @@ -83,204 +122,224 @@ impl AppStore { Ok(store) } + fn new(path: PathBuf, state: AppStoreData) -> Result { + Ok(Self { + writer: StoreWriter::start(path.clone())?, + path, + state: RwLock::new(VersionedStoreState { + data: state, + revision: 0, + accepting_writes: true, + }), + }) + } + + fn lock_for_update(&self) -> Result> { + let guard = self.state.write(); + if !guard.accepting_writes { + return Err(anyhow!("store writer is shut down")); + } + Ok(guard) + } + + fn enqueue_locked(&self, state: &mut VersionedStoreState) -> Result { + state.revision = state + .revision + .checked_add(1) + .context("store revision overflow")?; + self.writer.enqueue(state.revision, state.data.clone()) + } + + fn persist_current(&self) -> Result<()> { + let ticket = { + let mut guard = self.lock_for_update()?; + self.enqueue_locked(&mut guard)? + }; + ticket.wait() + } + + pub(crate) fn flush_and_shutdown(&self) -> Result<()> { + let mut guard = self.state.write(); + guard.accepting_writes = false; + self.writer.shutdown() + } + pub fn snapshot(&self) -> AppStoreData { - self.state.read().clone() + self.state.read().data.clone() } pub fn with_state(&self, reader: impl FnOnce(&AppStoreData) -> T) -> T { let guard = self.state.read(); - reader(&guard) + reader(&guard.data) } pub fn settings_snapshot(&self) -> SettingsState { - settings_from_store(&self.state.read()) + settings_from_store(&self.state.read().data) } + pub fn update(&self, mut updater: F) -> Result where F: FnMut(&mut AppStoreData), { - let mut guard = self.state.write(); - updater(&mut guard); - *guard = normalize_state(guard.clone()); - self.persist_locked(&guard)?; - Ok(guard.clone()) + let (result, ticket) = { + let mut guard = self.lock_for_update()?; + updater(&mut guard.data); + guard.data = normalize_state(guard.data.clone()); + let result = guard.data.clone(); + let ticket = self.enqueue_locked(&mut guard)?; + (result, ticket) + }; + ticket.wait()?; + Ok(result) } pub fn update_keys(&self, mappings: KeyMappings) -> Result { - let mut guard = self.state.write(); - guard.keys = mappings.clone(); - *guard = normalize_state(guard.clone()); - self.persist_locked(&guard)?; - Ok(guard.keys.clone()) + let (result, ticket) = { + let mut guard = self.lock_for_update()?; + guard.data.keys = mappings; + guard.data = normalize_state(guard.data.clone()); + let result = guard.data.keys.clone(); + let ticket = self.enqueue_locked(&mut guard)?; + (result, ticket) + }; + ticket.wait()?; + Ok(result) } pub fn update_positions(&self, positions: KeyPositions) -> Result { - let mut guard = self.state.write(); - guard.key_positions = positions.clone(); - *guard = normalize_state(guard.clone()); - self.persist_locked(&guard)?; - Ok(guard.key_positions.clone()) + let (result, ticket) = { + let mut guard = self.lock_for_update()?; + guard.data.key_positions = positions; + guard.data = normalize_state(guard.data.clone()); + let result = guard.data.key_positions.clone(); + let ticket = self.enqueue_locked(&mut guard)?; + (result, ticket) + }; + ticket.wait()?; + Ok(result) } pub fn update_layer_groups(&self, groups: LayerGroups) -> Result { - let mut guard = self.state.write(); - guard.layer_groups = groups; - *guard = normalize_state(guard.clone()); - let result = guard.layer_groups.clone(); - drop(guard); - let snapshot = self.state.read().clone(); - self.persist_locked(&snapshot)?; + let (result, ticket) = { + let mut guard = self.lock_for_update()?; + guard.data.layer_groups = groups; + guard.data = normalize_state(guard.data.clone()); + let result = guard.data.layer_groups.clone(); + let ticket = self.enqueue_locked(&mut guard)?; + (result, ticket) + }; + ticket.wait()?; Ok(result) } pub fn update_stat_positions(&self, positions: StatPositions) -> Result { - let mut guard = self.state.write(); - guard.stat_positions = positions.clone(); - *guard = normalize_state(guard.clone()); - self.persist_locked(&guard)?; - Ok(guard.stat_positions.clone()) + let (result, ticket) = { + let mut guard = self.lock_for_update()?; + guard.data.stat_positions = positions; + guard.data = normalize_state(guard.data.clone()); + let result = guard.data.stat_positions.clone(); + let ticket = self.enqueue_locked(&mut guard)?; + (result, ticket) + }; + ticket.wait()?; + Ok(result) } pub fn update_graph_positions(&self, positions: GraphPositions) -> Result { - let mut guard = self.state.write(); - guard.graph_positions = positions.clone(); - *guard = normalize_state(guard.clone()); - self.persist_locked(&guard)?; - Ok(guard.graph_positions.clone()) + let (result, ticket) = { + let mut guard = self.lock_for_update()?; + guard.data.graph_positions = positions; + guard.data = normalize_state(guard.data.clone()); + let result = guard.data.graph_positions.clone(); + let ticket = self.enqueue_locked(&mut guard)?; + (result, ticket) + }; + ticket.wait()?; + Ok(result) } pub fn update_knob_positions(&self, positions: KnobPositions) -> Result { - let mut guard = self.state.write(); - guard.knob_positions = positions.clone(); - *guard = normalize_state(guard.clone()); - self.persist_locked(&guard)?; - Ok(guard.knob_positions.clone()) + let (result, ticket) = { + let mut guard = self.lock_for_update()?; + guard.data.knob_positions = positions; + guard.data = normalize_state(guard.data.clone()); + let result = guard.data.knob_positions.clone(); + let ticket = self.enqueue_locked(&mut guard)?; + (result, ticket) + }; + ticket.wait()?; + Ok(result) } pub fn set_key_counters(&self, counters: KeyCounters) -> Result { - let mut guard = self.state.write(); - guard.key_counters = counters.clone(); - *guard = normalize_state(guard.clone()); - self.persist_locked(&guard)?; - Ok(guard.key_counters.clone()) + let (result, ticket) = { + let mut guard = self.lock_for_update()?; + guard.data.key_counters = counters; + guard.data = normalize_state(guard.data.clone()); + let result = guard.data.key_counters.clone(); + let ticket = self.enqueue_locked(&mut guard)?; + (result, ticket) + }; + ticket.wait()?; + Ok(result) } pub fn set_selected_key_type(&self, key: impl Into) -> Result { let key = key.into(); - let mut guard = self.state.write(); - guard.selected_key_type = key.clone(); - *guard = normalize_state(guard.clone()); - self.persist_locked(&guard)?; - Ok(guard.selected_key_type.clone()) + let (result, ticket) = { + let mut guard = self.lock_for_update()?; + guard.data.selected_key_type = key; + guard.data = normalize_state(guard.data.clone()); + let result = guard.data.selected_key_type.clone(); + let ticket = self.enqueue_locked(&mut guard)?; + (result, ticket) + }; + ticket.wait()?; + Ok(result) } // 플러그인 데이터 관련 메서드 pub fn get_plugin_data(&self, key: &str) -> Result> { let guard = self.state.read(); - Ok(guard.plugin_data.get(key).cloned()) + Ok(guard.data.plugin_data.get(key).cloned()) } pub fn set_plugin_data(&self, key: &str, value: Value) -> Result<()> { - let mut guard = self.state.write(); - guard.plugin_data.insert(key.to_string(), value); - self.persist_locked(&guard)?; - Ok(()) + let ticket = { + let mut guard = self.lock_for_update()?; + guard.data.plugin_data.insert(key.to_string(), value); + self.enqueue_locked(&mut guard)? + }; + ticket.wait() } pub fn remove_plugin_data(&self, key: &str) -> Result<()> { - let mut guard = self.state.write(); - guard.plugin_data.remove(key); - self.persist_locked(&guard)?; - Ok(()) + let ticket = { + let mut guard = self.lock_for_update()?; + guard.data.plugin_data.remove(key); + self.enqueue_locked(&mut guard)? + }; + ticket.wait() } pub fn clear_all_plugin_data(&self) -> Result<()> { - let mut guard = self.state.write(); - guard.plugin_data.clear(); - self.persist_locked(&guard)?; - Ok(()) + let ticket = { + let mut guard = self.lock_for_update()?; + guard.data.plugin_data.clear(); + self.enqueue_locked(&mut guard)? + }; + ticket.wait() } pub fn get_all_plugin_keys(&self) -> Result> { let guard = self.state.read(); - Ok(guard.plugin_data.keys().cloned().collect()) - } - - fn persist_locked(&self, state: &AppStoreData) -> Result<()> { - // JSON 출력 시 key 모드 순서 4,5,6,8 고정, 나머지 사전순 정렬 - use serde_json::{to_value, Map, Value}; - - let mut root = to_value(state)?; - if let Value::Object(ref mut obj) = root { - // 정렬 도우미 - let reorder = |value: &mut Value| { - if let Value::Object(current) = value { - let desired = ["4key", "5key", "6key", "8key"]; - let mut next = Map::new(); - // 우선순위 키들 먼저 - for k in desired.iter() { - if let Some(v) = current.get(*k) { - next.insert((*k).to_string(), v.clone()); - } - } - // 나머지 키들 알파벳 순 - let mut rest: Vec<(String, Value)> = current - .iter() - .filter(|(k, _)| !desired.contains(&k.as_str())) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - rest.sort_by(|a, b| a.0.cmp(&b.0)); - for (k, v) in rest.into_iter() { - next.insert(k, v); - } - *value = Value::Object(next); - } - }; - - if let Some(v) = obj.get_mut("keys") { - reorder(v); - } - if let Some(v) = obj.get_mut("keyPositions") { - reorder(v); - } - if let Some(v) = obj.get_mut("statPositions") { - reorder(v); - } - if let Some(v) = obj.get_mut("graphPositions") { - reorder(v); - } - if let Some(v) = obj.get_mut("knobPositions") { - reorder(v); - } - if let Some(v) = obj.get_mut("keyCounters") { - reorder(v); - } - } - - let json = serde_json::to_string_pretty(&root)?; - // 원자 교체 — 쓰기 도중 크래시해도 기존 store가 온전히 남도록 - // 같은 디렉토리의 임시 파일에 쓴 뒤 rename (동일 파일시스템 보장) - let tmp_path = self.path.with_extension("json.tmp"); - { - let mut tmp = fs::File::create(&tmp_path).with_context(|| { - format!("failed to create temp store file at {}", tmp_path.display()) - })?; - tmp.write_all(json.as_bytes()).with_context(|| { - format!("failed to write temp store file at {}", tmp_path.display()) - })?; - // 전원 손실 대비 — rename 전에 데이터를 디스크로 플러시 - tmp.sync_all().with_context(|| { - format!("failed to sync temp store file at {}", tmp_path.display()) - })?; - } - fs::rename(&tmp_path, &self.path) - .with_context(|| format!("failed to replace store file at {}", self.path.display())) + Ok(guard.data.plugin_data.keys().cloned().collect()) } /// 앱 종료 시점에 한 번 호출하는 자원 정리. /// 현재 store에서 참조하지 않는 appData/fonts, appData/images, appData/sounds 파일을 삭제 pub fn cleanup_orphan_assets_now(&self) -> Result<()> { - let snapshot = self.state.read().clone(); + let snapshot = self.state.read().data.clone(); let app_data_dir = self .path .parent() @@ -301,6 +360,219 @@ impl AppStore { } } +impl Drop for AppStore { + fn drop(&mut self) { + if let Err(err) = self.flush_and_shutdown() { + log::warn!("failed to stop store writer during drop: {err}"); + } + } +} + +impl PersistTicket { + fn wait(self) -> Result<()> { + let completion = self + .completion_rx + .recv() + .with_context(|| format!("store writer stopped before revision {}", self.revision))?; + if completion.revision != self.revision { + return Err(anyhow!( + "store writer returned revision {} for requested revision {}", + completion.revision, + self.revision + )); + } + completion.result.map_err(anyhow::Error::msg) + } +} + +impl StoreWriter { + fn start(path: PathBuf) -> Result { + let (sender, receiver) = mpsc::channel(); + let handle = thread::Builder::new() + .name("dmnote-store-writer".to_string()) + .spawn(move || run_store_writer(&path, receiver)) + .context("failed to start store writer")?; + Ok(Self { + sender: Mutex::new(Some(sender)), + handle: Mutex::new(Some(handle)), + }) + } + + fn enqueue(&self, revision: u64, snapshot: AppStoreData) -> Result { + let (completion_tx, completion_rx) = mpsc::channel(); + let guard = self.sender.lock(); + let sender = guard.as_ref().context("store writer is shut down")?; + sender + .send(WriterMessage::Persist { + revision, + snapshot: Box::new(snapshot), + completion_tx, + }) + .with_context(|| format!("failed to enqueue store revision {revision}"))?; + Ok(PersistTicket { + revision, + completion_rx, + }) + } + + fn shutdown(&self) -> Result<()> { + let sender = self.sender.lock().take(); + let Some(sender) = sender else { + return Ok(()); + }; + + let (completion_tx, completion_rx) = mpsc::channel(); + if let Err(err) = sender.send(WriterMessage::Shutdown { completion_tx }) { + drop(sender); + let join_result = self + .handle + .lock() + .take() + .map(|handle| { + handle + .join() + .map_err(|_| anyhow!("store writer thread panicked")) + }) + .transpose(); + join_result?; + return Err(anyhow!("failed to enqueue store writer shutdown: {err}")); + } + drop(sender); + let completion_result = completion_rx + .recv() + .context("store writer stopped before shutdown flush completed"); + let join_result = self + .handle + .lock() + .take() + .map(|handle| { + handle + .join() + .map_err(|_| anyhow!("store writer thread panicked")) + }) + .transpose(); + + completion_result?; + join_result?; + Ok(()) + } +} + +fn run_store_writer(path: &Path, receiver: mpsc::Receiver) { + while let Ok(message) = receiver.recv() { + match message { + WriterMessage::Persist { + revision, + snapshot, + completion_tx, + } => { + let result = write_store_snapshot(path, revision, snapshot.as_ref()) + .map_err(|err| format!("{err:#}")); + let _ = completion_tx.send(PersistCompletion { revision, result }); + } + WriterMessage::Shutdown { completion_tx } => { + let _ = completion_tx.send(()); + break; + } + } + } +} + +fn write_store_snapshot(path: &Path, revision: u64, state: &AppStoreData) -> Result<()> { + let json = serialize_store(state)?; + atomic_replace(path, json.as_bytes(), &format!("revision-{revision}")) +} + +fn serialize_store(state: &AppStoreData) -> Result { + use serde_json::{to_value, Map, Value}; + + let mut root = to_value(state)?; + if let Value::Object(ref mut obj) = root { + let reorder = |value: &mut Value| { + if let Value::Object(current) = value { + let desired = ["4key", "5key", "6key", "8key"]; + let mut next = Map::new(); + for key in desired.iter() { + if let Some(value) = current.get(*key) { + next.insert((*key).to_string(), value.clone()); + } + } + let mut rest: Vec<(String, Value)> = current + .iter() + .filter(|(key, _)| !desired.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + rest.sort_by(|left, right| left.0.cmp(&right.0)); + for (key, value) in rest { + next.insert(key, value); + } + *value = Value::Object(next); + } + }; + + for field in [ + "keys", + "keyPositions", + "statPositions", + "graphPositions", + "knobPositions", + "keyCounters", + ] { + if let Some(value) = obj.get_mut(field) { + reorder(value); + } + } + } + + serde_json::to_string_pretty(&root).context("failed to serialize store") +} + +fn backup_store_file(path: &Path) -> Result<()> { + let content = fs::read(path) + .with_context(|| format!("failed to read store backup source at {}", path.display()))?; + let backup_path = path.with_extension("json.bak"); + atomic_replace(&backup_path, &content, "backup").with_context(|| { + format!( + "failed to back up damaged store before recovery at {}", + backup_path.display() + ) + }) +} + +fn atomic_replace(path: &Path, content: &[u8], label: &str) -> Result<()> { + let parent = path + .parent() + .context("failed to resolve store file parent directory")?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .context("failed to resolve store file name")?; + let tmp_path = parent.join(format!(".{file_name}.{label}-{}.tmp", Uuid::new_v4())); + + let result = (|| { + let mut tmp = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path) + .with_context(|| { + format!("failed to create temp store file at {}", tmp_path.display()) + })?; + tmp.write_all(content).with_context(|| { + format!("failed to write temp store file at {}", tmp_path.display()) + })?; + tmp.sync_all() + .with_context(|| format!("failed to sync temp store file at {}", tmp_path.display()))?; + drop(tmp); + fs::rename(&tmp_path, path) + .with_context(|| format!("failed to replace store file at {}", path.display())) + })(); + + if result.is_err() { + let _ = fs::remove_file(&tmp_path); + } + result +} + fn settings_from_store(store: &AppStoreData) -> SettingsState { let mut custom_js = store.custom_js.clone(); let _ = custom_js.normalize(); @@ -356,37 +628,9 @@ fn collect_local_font_path_keys(data: &AppStoreData) -> HashSet { fn collect_local_image_path_keys(data: &AppStoreData) -> HashSet { let mut paths = HashSet::new(); - for positions in data.key_positions.values() { - for position in positions { - collect_image_path_from_option(&mut paths, position.active_image.as_ref()); - collect_image_path_from_option(&mut paths, position.inactive_image.as_ref()); - } - } - - for positions in data.stat_positions.values() { - for stat_position in positions { - collect_image_path_from_option( - &mut paths, - stat_position.position.active_image.as_ref(), - ); - collect_image_path_from_option( - &mut paths, - stat_position.position.inactive_image.as_ref(), - ); - } - } - - for positions in data.graph_positions.values() { - for graph_position in positions { - collect_image_path_from_option( - &mut paths, - graph_position.position.active_image.as_ref(), - ); - collect_image_path_from_option( - &mut paths, - graph_position.position.inactive_image.as_ref(), - ); - } + for position in iter_all_positions(data) { + collect_image_path_from_option(&mut paths, position.active_image.as_ref()); + collect_image_path_from_option(&mut paths, position.inactive_image.as_ref()); } paths @@ -395,10 +639,8 @@ fn collect_local_image_path_keys(data: &AppStoreData) -> HashSet { fn collect_local_sound_path_keys(data: &AppStoreData) -> HashSet { let mut paths = HashSet::new(); - for positions in data.key_positions.values() { - for position in positions { - collect_sound_path_from_option(&mut paths, position.sound_path.as_ref()); - } + for position in iter_all_positions(data) { + collect_sound_path_from_option(&mut paths, position.sound_path.as_ref()); } // 사운드 라이브러리에 등록된 파일도 보호 (키에 할당 안 되어도 유지) @@ -412,6 +654,30 @@ fn collect_local_sound_path_keys(data: &AppStoreData) -> HashSet { paths } +fn iter_all_positions(data: &AppStoreData) -> impl Iterator { + data.key_positions + .values() + .flatten() + .chain( + data.stat_positions + .values() + .flatten() + .map(|position| &position.position), + ) + .chain( + data.graph_positions + .values() + .flatten() + .map(|position| &position.position), + ) + .chain( + data.knob_positions + .values() + .flatten() + .map(|position| &position.position), + ) +} + fn collect_image_path_from_option(paths: &mut HashSet, value: Option<&String>) { let Some(path) = value else { return; @@ -537,3 +803,269 @@ fn sweep_unreferenced_asset_files( Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ + collect_local_image_path_keys, collect_local_sound_path_keys, path_lookup_key, AppStore, + }; + use crate::{ + defaults::default_positions, + models::{ + AppStoreData, GraphPosition, GraphStatType, GraphType, KnobPosition, StatPosition, + StatType, + }, + }; + use serde_json::{json, Value}; + use std::sync::{Arc, Barrier}; + + fn test_directory(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("dmnote-{label}-{}", uuid::Uuid::new_v4())) + } + + // 프론트 저장 순서 재현: positions(groupId 포함) 먼저 → layerGroups 정의 나중 + // 부분 저장 중간 정규화가 신규 그룹 참조를 지우면 그룹 생성이 통째로 깨진다 + #[test] + fn group_ids_survive_positions_saved_before_group_definitions() { + let dir = test_directory("group-order-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + let mut positions = store.snapshot().key_positions; + if let Some(list) = positions.get_mut("4key") { + 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(), + vec![crate::models::LayerGroupDef { + id: "new-group".to_string(), + name: "New Group".to_string(), + }], + ); + store.update_layer_groups(groups).unwrap(); + + let data = store.snapshot(); + assert_eq!( + data.key_positions["4key"][0].group_id.as_deref(), + Some("new-group") + ); + assert_eq!( + data.key_positions["4key"][1].group_id.as_deref(), + Some("new-group") + ); + + // 재기동(로드 경계)에서도 정의가 존재하므로 참조가 유지되어야 함 + store.flush_and_shutdown().unwrap(); + drop(store); + let reopened = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!( + reopened.snapshot().key_positions["4key"][0] + .group_id + .as_deref(), + Some("new-group") + ); + reopened.flush_and_shutdown().unwrap(); + drop(reopened); + let _ = std::fs::remove_dir_all(dir); + } + + // 로드 시 dangling groupId 정리는 디스크에도 영속되고, 두 번째 기동은 재저장이 필요 없어야 함 + #[test] + fn dangling_group_ids_are_cleaned_and_persisted_on_boot() { + let dir = test_directory("dangling-persist-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + + let mut data = AppStoreData::default(); + let mut positions = default_positions().clone(); + if let Some(list) = positions.get_mut("4key") { + list[0].group_id = Some("ghost-group".to_string()); + } + data.key_positions = positions; + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store.flush_and_shutdown().unwrap(); + drop(store); + + let on_disk: AppStoreData = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(on_disk.key_positions["4key"][0].group_id, None); + + let reloaded = super::load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn repaired_store_is_backed_up_before_persist() { + let dir = test_directory("store-backup-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture.as_object_mut().unwrap().insert( + "alwaysOnTop".to_string(), + Value::String("invalid".to_string()), + ); + let original = serde_json::to_vec_pretty(&fixture).unwrap(); + std::fs::write(&path, &original).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store.flush_and_shutdown().unwrap(); + + assert_eq!(std::fs::read(dir.join("store.json.bak")).unwrap(), original); + assert!(serde_json::from_slice::(&std::fs::read(path).unwrap()).is_ok()); + + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn backup_failure_keeps_damaged_store_untouched() { + let dir = test_directory("store-backup-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture.as_object_mut().unwrap().insert( + "alwaysOnTop".to_string(), + Value::String("invalid".to_string()), + ); + let original = serde_json::to_vec_pretty(&fixture).unwrap(); + std::fs::write(&path, &original).unwrap(); + std::fs::create_dir(dir.join("store.json.bak")).unwrap(); + + let result = AppStore::initialize_in_dir(&dir); + if let Ok(store) = result { + let _ = store.flush_and_shutdown(); + panic!("store initialization unexpectedly succeeded without a backup"); + } + assert_eq!(std::fs::read(path).unwrap(), original); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn concurrent_updates_persist_all_revisions_without_replace_races() { + const UPDATE_COUNT: usize = 60; + + let dir = test_directory("store-concurrency-test"); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let barrier = Arc::new(Barrier::new(UPDATE_COUNT + 1)); + let mut handles = Vec::with_capacity(UPDATE_COUNT); + + for _ in 0..UPDATE_COUNT { + let store = Arc::clone(&store); + let barrier = Arc::clone(&barrier); + handles.push(std::thread::spawn(move || { + barrier.wait(); + store.update(|state| { + let count = state + .plugin_data + .get("concurrent_update_count") + .and_then(Value::as_u64) + .unwrap_or(0); + state + .plugin_data + .insert("concurrent_update_count".to_string(), json!(count + 1)); + }) + })); + } + + barrier.wait(); + let mut failures = Vec::new(); + for handle in handles { + match handle.join() { + Ok(Ok(_)) => {} + Ok(Err(err)) => failures.push(format!("{err:#}")), + Err(_) => failures.push("update thread panicked".to_string()), + } + } + assert!(failures.is_empty(), "persist failures: {failures:?}"); + + let final_snapshot = store.snapshot(); + store.flush_and_shutdown().unwrap(); + assert!(store + .update(|state| state.always_on_top = !state.always_on_top) + .is_err()); + assert_eq!(store.snapshot(), final_snapshot); + let persisted: AppStoreData = + serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); + assert_eq!( + persisted.plugin_data["concurrent_update_count"].as_u64(), + Some(UPDATE_COUNT as u64) + ); + assert_eq!(persisted, final_snapshot); + assert!(!std::fs::read_dir(&dir).unwrap().any(|entry| { + entry + .ok() + .and_then(|entry| entry.file_name().into_string().ok()) + .is_some_and(|name| name.ends_with(".tmp")) + })); + + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn asset_references_include_every_position_kind() { + let mut data = AppStoreData::default(); + let base_position = default_positions()["4key"][0].clone(); + let root = std::env::temp_dir().join("dmnote-position-assets"); + + let next_position = |kind: &str| { + let mut position = base_position.clone(); + position.active_image = Some( + root.join(format!("{kind}.png")) + .to_string_lossy() + .to_string(), + ); + position.sound_path = Some( + root.join(format!("{kind}.wav")) + .to_string_lossy() + .to_string(), + ); + position + }; + + data.key_positions + .insert("mode".to_string(), vec![next_position("key")]); + data.stat_positions.insert( + "mode".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: next_position("stat"), + }], + ); + data.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: next_position("graph"), + }], + ); + data.knob_positions.insert( + "mode".to_string(), + vec![KnobPosition { + axis_id: String::new(), + sensitivity: 1.0, + reverse: false, + position: next_position("knob"), + }], + ); + + let image_paths = collect_local_image_path_keys(&data); + let sound_paths = collect_local_sound_path_keys(&data); + for kind in ["key", "stat", "graph", "knob"] { + assert!(image_paths.contains(&path_lookup_key(&root.join(format!("{kind}.png"))))); + assert!(sound_paths.contains(&path_lookup_key(&root.join(format!("{kind}.wav"))))); + } + } +} diff --git a/src/renderer/api/ipcShim.ts b/src/renderer/api/ipcShim.ts index c4b16c05..62cf6f00 100644 --- a/src/renderer/api/ipcShim.ts +++ b/src/renderer/api/ipcShim.ts @@ -6,8 +6,8 @@ * overlay/App.tsx가 코드 변경 없이 동작. * * 설계 원칙 (§12.4): - * - 커맨드별 분기 없음. 3단계만: plugin:event → deny → WS RPC - * - deny 리스트는 hello_ack에서 수신 (백엔드가 유일한 source of truth) + * - 커맨드별 분기 없음. 3단계만: plugin:event → allow → WS RPC + * - allow 리스트는 hello_ack에서 수신 (백엔드가 유일한 source of truth) */ import { OBS_PROTOCOL_VERSION } from '@src/types/obs'; @@ -24,8 +24,10 @@ let connHost = '127.0.0.1'; let connPort = '34891'; let connToken = ''; -// deny 리스트 — hello_ack에서 수신 (백엔드가 유일한 source of truth) -let denyList: string[] = []; +// allow 리스트 — hello_ack에서 수신 (백엔드가 유일한 source of truth) +let allowList: string[] = []; +// hello_ack 수신 전에는 백엔드 이중 검사에 위임 (fail-open은 프론트 한정, 경계는 백엔드) +let allowListReceived = false; // 콜백 레지스트리 (transformCallback/runCallback) const callbacks = new Map void>(); @@ -48,13 +50,12 @@ const pendingRpc = new Map< // snapshot 수신 여부 (initIpcShim에서 연결 준비 확인용) let _snapshotReceived = false; -// ── deny 체크 ── +// ── allow 체크 ── -/** "|"로 끝나면 prefix 매칭, 아니면 exact 매칭 */ -function isDenied(cmd: string): boolean { - return denyList.some((entry) => - entry.endsWith('|') ? cmd.startsWith(entry) : cmd === entry, - ); +/** allowlist 정확 일치 — hello_ack 수신 전에는 백엔드 이중 검사에 위임 */ +function isAllowed(cmd: string): boolean { + if (!allowListReceived) return true; + return allowList.includes(cmd); } // ── 콜백 관리 (transformCallback / runCallback) ── @@ -221,8 +222,8 @@ async function shimInvoke( return; } - // 2. deny 체크 (hello_ack에서 수신한 리스트) - if (isDenied(cmd)) { + // 2. allow 체크 (hello_ack에서 수신한 리스트) + if (!isAllowed(cmd)) { return; } @@ -264,7 +265,7 @@ function shimConvertFileSrc(filePath: string, _protocol = 'asset'): string { // ── 공개 API ── /** - * IPC shim 초기화. WS 연결 → hello_ack(denyList 수신) → snapshot 수신 → 글로벌 설치. + * IPC shim 초기화. WS 연결 → hello_ack(allowList 수신) → snapshot 수신 → 글로벌 설치. * 반드시 dmnoteApi import 전에 호출. */ export function initIpcShim(wsUrl: string, token: string): Promise { @@ -308,10 +309,11 @@ export function initIpcShim(wsUrl: string, token: string): Promise { } if (envelope.type === 'hello_ack') { - // deny 리스트 수신 (없으면 기본값 유지) + // allow 리스트 수신 (없으면 기본값 유지) const payload = envelope.payload as HelloAckPayload; - if (payload.denyList) { - denyList = payload.denyList; + if (payload.allowedList) { + allowList = payload.allowedList; + allowListReceived = true; } return; } @@ -428,6 +430,7 @@ export function disposeIpcShim() { callbacks.clear(); eventListeners.clear(); eventListenersByName.clear(); - denyList = []; + allowList = []; + allowListReceived = false; _snapshotReceived = false; } diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index c6e28d1b..a9055ea9 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -354,8 +354,12 @@ const PropertiesPanel: React.FC = ({ // 패널 ref (컬러픽커/이미지픽커 위치 기준) const [panelElement, setPanelElement] = useState(null); - // 패널 모드 상태 (layer: 레이어 패널, property: 속성 패널) - const [panelMode, setPanelMode] = useState<'layer' | 'property'>('property'); + // 패널 모드 (layer: 레이어 패널, property: 속성 패널) + // 설정 왕복으로 리마운트돼도 열림 상태와 함께 보존되도록 store에 유지 + const panelMode = usePropertiesPanelStore((state) => state.canvasPanelMode); + const setPanelMode = usePropertiesPanelStore( + (state) => state.setCanvasPanelMode, + ); // panelMode를 ref로도 유지 (useEffect에서 최신 값 참조용) const panelModeRef = useRef(panelMode); @@ -696,6 +700,7 @@ const PropertiesPanel: React.FC = ({ singleStatPosition, singleGraphPosition, singleKnobPosition, + setPanelMode, ]); // 선택이 변경되면 rename 모드 해제 @@ -851,6 +856,7 @@ const PropertiesPanel: React.FC = ({ isPanelVisible, pluginSettingsPanel, setIsPanelVisible, + setPanelMode, closePage, ]); @@ -871,7 +877,12 @@ const PropertiesPanel: React.FC = ({ setPanelMode('property'); setIsPanelVisible(true); } - }, [selectedBatchStyleElements.length, isPanelVisible, setIsPanelVisible]); + }, [ + selectedBatchStyleElements.length, + isPanelVisible, + setIsPanelVisible, + setPanelMode, + ]); useEffect(() => { if (pluginSettingsPanel) { @@ -879,13 +890,14 @@ const PropertiesPanel: React.FC = ({ setPanelMode('property'); setIsPanelVisible(true); } - }, [pluginSettingsPanel, setIsPanelVisible]); + }, [pluginSettingsPanel, setIsPanelVisible, setPanelMode]); - // 레이어 패널이 열려있고 선택이 없는 상태에서 그리드 빈 공간 클릭 시 패널 닫기 + // 레이어 뷰가 표시된 상태(선택 없음)에서 그리드 빈 공간 클릭 시 패널 닫기 + // panelMode가 property로 남아 있어도 선택이 없으면 레이어 뷰가 표시되므로 동일하게 닫음 useEffect(() => { const hasSelection = selectedKeyElements.length > 0 || selectedElements.length > 0; - if (panelMode !== 'layer' || !isPanelVisible || hasSelection) { + if (!isPanelVisible || hasSelection) { return undefined; } @@ -924,7 +936,6 @@ const PropertiesPanel: React.FC = ({ selectedKeyElements.length, selectedKeyLikeElements.length, selectedElements.length, - panelMode, setIsPanelVisible, ]); @@ -1064,7 +1075,7 @@ const PropertiesPanel: React.FC = ({ }; const handleToggleMode = () => { - setPanelMode((prev) => (prev === 'layer' ? 'property' : 'layer')); + setPanelMode(panelMode === 'layer' ? 'property' : 'layer'); }; const showFrame = isPanelVisible || !!pluginSettingsPanel; diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index 28a6f9de..9320d558 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -708,7 +708,11 @@ export function useGridSelection({ [selectedKeyType]: modeGroups, }; useLayerGroupStore.getState().setLayerGroups(updatedLayerGroups); - window.api.layerGroups.update(updatedLayerGroups).catch(() => {}); + window.api.layerGroups + .update(updatedLayerGroups) + .catch((error: Error) => { + console.error('Failed to paste layer groups', error); + }); // 원본 그룹의 collapsed 상태 복원 for (const cg of clipboardGroups) { diff --git a/src/renderer/stores/grid/usePropertiesPanelStore.ts b/src/renderer/stores/grid/usePropertiesPanelStore.ts index e657b634..85845b29 100644 --- a/src/renderer/stores/grid/usePropertiesPanelStore.ts +++ b/src/renderer/stores/grid/usePropertiesPanelStore.ts @@ -19,6 +19,8 @@ export type PluginSettingsPanelPayload = { resolve: (confirmed: boolean) => void; }; +export type CanvasPanelMode = 'layer' | 'property'; + interface PropertiesPanelState { pluginSettingsPanel: PluginSettingsPanelPayload | null; openPluginSettingsPanel: (payload: PluginSettingsPanelPayload) => void; @@ -32,6 +34,10 @@ interface PropertiesPanelState { isCanvasPanelOpen: boolean; setCanvasPanelOpen: (value: boolean) => void; + // 패널 모드 — 설정 화면 왕복으로 패널이 리마운트돼도 열림 상태와 함께 보존 + canvasPanelMode: CanvasPanelMode; + setCanvasPanelMode: (mode: CanvasPanelMode) => void; + // 외부(단축키 등)에서 토글 요청을 보내기 위한 시그널 canvasPanelToggleSignal: number; requestCanvasPanelToggle: () => void; @@ -66,6 +72,9 @@ export const usePropertiesPanelStore = create( isCanvasPanelOpen: false, setCanvasPanelOpen: (value) => set({ isCanvasPanelOpen: value }), + canvasPanelMode: 'property', + setCanvasPanelMode: (mode) => set({ canvasPanelMode: mode }), + canvasPanelToggleSignal: 0, requestCanvasPanelToggle: () => set((state) => ({ diff --git a/src/types/obs.ts b/src/types/obs.ts index 1781f7e1..9ba9b107 100644 --- a/src/types/obs.ts +++ b/src/types/obs.ts @@ -26,7 +26,7 @@ export interface HelloPayload { export interface HelloAckPayload { serverVersion: string; obsMode: boolean; - denyList?: string[]; + allowedList?: string[]; } export interface ObsStatus { From 1b9154cf6a2db16732539cdc02f5a1ab1fec3465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 12 Jul 2026 23:22:27 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=EC=9D=84=20=EC=A2=8C=EC=B8=A1=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListPopup의 textAlign 옵션과 가운데 정렬 전용 행 분기를 제거해 모든 리스트형 팝업이 좌측 정렬과 동일한 행 레이아웃을 쓰도록 정리 --- .../components/main/Modal/ListPopup.tsx | 41 ++----------------- .../pickers/CounterAnimationPicker.tsx | 1 - .../main/Modal/content/pickers/FontPicker.tsx | 2 - .../Modal/content/pickers/SoundPicker.tsx | 1 - .../components/main/Tool/SettingTool.tsx | 1 - 5 files changed, 3 insertions(+), 43 deletions(-) diff --git a/src/renderer/components/main/Modal/ListPopup.tsx b/src/renderer/components/main/Modal/ListPopup.tsx index ab1bc356..ff2b644a 100644 --- a/src/renderer/components/main/Modal/ListPopup.tsx +++ b/src/renderer/components/main/Modal/ListPopup.tsx @@ -27,8 +27,6 @@ interface ListPopupProps { className?: string; offsetX?: number; offsetY?: number; - /** 텍스트 정렬 방향 */ - textAlign?: 'left' | 'center'; /** 최대 표시 항목 수 (초과 시 스크롤) */ maxVisibleItems?: number; } @@ -38,7 +36,6 @@ const SubMenu = ({ items, onSelect, onCloseAll, - textAlign = 'left', maxVisibleItems, anchorRect, onMouseEnter, @@ -48,7 +45,6 @@ const SubMenu = ({ items: ListItem[]; onSelect?: (id: string) => void; onCloseAll?: () => void; - textAlign?: 'left' | 'center'; maxVisibleItems?: number; anchorRect: DOMRect | null; onMouseEnter?: () => void; @@ -151,7 +147,6 @@ const SubMenu = ({ void; onCloseAll?: () => void; /** 형제 항목 중 활성 서브메뉴를 추적하는 ref (즉시 전환용) */ @@ -242,9 +235,7 @@ const MenuItemRow = ({ ); } - const isLeft = textAlign === 'left'; const hasCheck = typeof item.checked === 'boolean'; - const isBasicCenterItem = !isLeft && !hasCheck && !hasChildren; const handleSelect = () => { if (item.disabled || hasChildren) return; @@ -252,29 +243,6 @@ const MenuItemRow = ({ onCloseAll?.(); }; - if (isBasicCenterItem) { - return ( - - ); - } - return (
{item.label} @@ -350,7 +318,6 @@ const MenuItemRow = ({ items={item.children!} onSelect={onSelect} onCloseAll={onCloseAll} - textAlign={textAlign} maxVisibleItems={item.maxVisibleChildren} anchorRect={rowRect} onMouseEnter={() => { @@ -381,7 +348,6 @@ const ListPopup = ({ className = '', offsetX = 0, offsetY = 0, - textAlign = 'center', maxVisibleItems, }: ListPopupProps) => { // 일시적 팝업은 상주 크롬(z-30, 패널·미니맵)보다 항상 위 @@ -440,7 +406,6 @@ const ListPopup = ({ { const preset = allPresets.find((p) => p.id === menu.menuKey); diff --git a/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx b/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx index e4d8292f..08f30e88 100644 --- a/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx @@ -427,7 +427,6 @@ const FontPicker = ({ referenceRef={addButtonRef} position={addMenuPosition} onClose={() => setAddMenuPosition(null)} - textAlign="center" items={addMenuItems} onSelect={(id) => { setAddMenuPosition(null); @@ -447,7 +446,6 @@ const FontPicker = ({ open position={menu.menuPosition ?? undefined} onClose={menu.close} - textAlign="center" items={menuItems} onSelect={(id) => { const font = menuTargetFont; diff --git a/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx b/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx index 1c759008..ae14501c 100644 --- a/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx @@ -334,7 +334,6 @@ const SoundPicker = ({ open position={menu.menuPosition ?? undefined} onClose={menu.close} - textAlign="center" items={menuItems} onSelect={(id) => { const item = menuTargetItem; diff --git a/src/renderer/components/main/Tool/SettingTool.tsx b/src/renderer/components/main/Tool/SettingTool.tsx index 68a805e7..2187bced 100644 --- a/src/renderer/components/main/Tool/SettingTool.tsx +++ b/src/renderer/components/main/Tool/SettingTool.tsx @@ -278,7 +278,6 @@ SettingToolProps) => { open={isExportImportOpen} referenceRef={exportImportRef} onClose={() => setIsExportImportOpen(false)} - textAlign="left" items={[ { id: 'import', From cb08a85819f1d7976fd57c230c3a0f19f2e23e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 12 Jul 2026 23:22:38 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=EC=83=81=ED=83=9C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EC=A7=81=EC=A0=91=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=EC=9E=AC=EC=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아이콘 스왑·눈 토글·패널 토글·스위치가 프로그램적 상태 변경에도 전환 연출을 재생하던 문제 수정. usePressGatedSwap 훅이 인터랙티브 조상과 행 press 스코프의 클릭 시각을 기록해 클릭 유래 변경만 애니메이션하고, 외부 변경은 data-dmn-instant를 한 프레임 부여해 즉시 전환. 설정 행처럼 컨트롤 밖을 눌러 토글하는 표면은 data-dmn-press-scope로 옵트인 --- .../PropertiesPanel/PanelToggleButton.tsx | 12 ++- .../components/main/common/Checkbox.tsx | 7 +- .../components/main/common/EyeToggleIcon.tsx | 11 ++- .../components/main/common/IconSwap.tsx | 27 ++++--- .../components/main/common/SettingRow.tsx | 2 + src/renderer/hooks/usePressGatedSwap.ts | 80 +++++++++++++++++++ src/renderer/styles/main.css | 8 ++ 7 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 src/renderer/hooks/usePressGatedSwap.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PanelToggleButton.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PanelToggleButton.tsx index c1d4bfc0..d12dea74 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/PanelToggleButton.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/PanelToggleButton.tsx @@ -1,5 +1,6 @@ import React, { useLayoutEffect, useRef } from 'react'; import { useTranslation } from '@contexts/useTranslation'; +import { usePressGatedSwap } from '@hooks/usePressGatedSwap'; interface PanelToggleButtonProps { open: boolean; @@ -21,6 +22,8 @@ const CHIP_HIDDEN = '0'; // 눈 토글과 같은 규칙: FROM 상태를 정적으로 고정하고 TO 커밋은 onfinish에서 const PanelToggleButton = ({ open, onClick }: PanelToggleButtonProps) => { const { t } = useTranslation(); + // 버튼에 data-instant 부여 — 외부 개폐 시 divider/lines transition도 차단 + const { ref, isInstant } = usePressGatedSwap(open); const chipRef = useRef(null); const mountedRef = useRef(false); const animRef = useRef(null); @@ -43,7 +46,11 @@ const PanelToggleButton = ({ open, onClick }: PanelToggleButtonProps) => { animRef.current?.cancel(); animRef.current = null; - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + // 직접 클릭이 아닌 외부 개폐는 페이드 없이 즉시 커밋 + if ( + isInstant() || + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { chip.style.opacity = restState; return; } @@ -58,7 +65,7 @@ const PanelToggleButton = ({ open, onClick }: PanelToggleButtonProps) => { chip.style.opacity = restState; }; animRef.current = anim; - }, [open]); + }, [open, isInstant]); const label = open ? t('propertiesPanel.closePanel') || '속성 패널 닫기' @@ -67,6 +74,7 @@ const PanelToggleButton = ({ open, onClick }: PanelToggleButtonProps) => { return (
- `; From 4450586eb97a26fb3be5a60ac64bae83f2e5f6d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 13 Jul 2026 06:06:55 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EC=9B=B9=20=ED=8F=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=9D=84=20=EC=A0=84=EB=A9=B4=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EA=B0=9C=ED=8E=B8=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 크롬 사이 영역을 통째로 덮는 fullSurface 모달과 공용 시트 레이아웃(FullSurfaceModalLayout) 도입, 폰트 피커 로딩 폴백도 재질 공유 - 입력 CSS에서 @font-face 블록만 추출해 초안 패밀리로 로드하는 스페시멘 미리보기, 굵기별 로드 성패 표시 - CSS 파싱을 렉서 기반 선언 단위로 통일 — 문자열 값 오인, escape로 인한 블록 경계 우회, 조건부 규칙 안 폰트 승격을 차단하고 회귀 테스트 추가 - 검증·로드 상태를 스크린리더에 전달하는 상태 영역 추가 - CodeMirror 에디터 크롬을 토큰 기반으로 정리, 여러 줄 placeholder의 줄 높이 부풀림 수정 --- .../main/Modal/FullSurfaceModalLayout.tsx | 74 ++++ src/renderer/components/main/Modal/Modal.tsx | 31 +- .../main/Modal/content/pickers/FontPicker.tsx | 10 +- .../content/pickers/WebFontInputModal.tsx | 331 ++++++++++++++---- src/renderer/locales/en.json | 11 +- src/renderer/locales/ko.json | 11 +- src/renderer/locales/ru.json | 11 +- src/renderer/locales/zh-Hant.json | 11 +- src/renderer/locales/zh-cn.json | 11 +- src/renderer/styles/main.css | 49 ++- src/types/settings/fonts.test.ts | 105 ++++++ src/types/settings/fonts.ts | 149 +++++++- 12 files changed, 688 insertions(+), 116 deletions(-) create mode 100644 src/renderer/components/main/Modal/FullSurfaceModalLayout.tsx create mode 100644 src/types/settings/fonts.test.ts diff --git a/src/renderer/components/main/Modal/FullSurfaceModalLayout.tsx b/src/renderer/components/main/Modal/FullSurfaceModalLayout.tsx new file mode 100644 index 00000000..540dc4d8 --- /dev/null +++ b/src/renderer/components/main/Modal/FullSurfaceModalLayout.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from 'react'; +import Modal from './Modal'; + +// 전면 시트 재질 — 시트 크롬과 로딩 폴백이 같은 소스를 공유 +export const FULL_SURFACE_MATERIAL_CLASS = 'bg-glass-heavy backdrop-glass'; + +interface FullSurfaceModalLayoutProps { + onClose: () => void; + title: string; + /** 제목 옆 보조 정보 (아이콘 + 캡션 등) */ + headerInfo?: ReactNode; + submitLabel: string; + submitDisabled?: boolean; + onSubmit: () => void; + cancelLabel: string; + children: ReactNode; +} + +// 전면 시트 공통 크롬 — 재질·칼럼·헤더·액션을 한 곳이 소유, 본문 레이아웃은 소비자 몫 +const FullSurfaceModalLayout = ({ + onClose, + title, + headerInfo, + submitLabel, + submitDisabled = false, + onSubmit, + cancelLabel, + children, +}: FullSurfaceModalLayoutProps) => { + return ( + +
event.stopPropagation()} + > + {/* 시트 칼럼 — 모달 카드와 같은 문법: 바깥 14px 패딩 + 내부 12px 갭 */} +
+ {/* 헤더 행 — 제목·보조 정보 좌측, 액션 우측 */} +
+
+

{title}

+ {headerInfo} +
+
+ + +
+
+ + {children} +
+
+
+ ); +}; + +export default FullSurfaceModalLayout; diff --git a/src/renderer/components/main/Modal/Modal.tsx b/src/renderer/components/main/Modal/Modal.tsx index 4cceb415..caa4a294 100644 --- a/src/renderer/components/main/Modal/Modal.tsx +++ b/src/renderer/components/main/Modal/Modal.tsx @@ -7,6 +7,8 @@ interface ModalProps { animate?: boolean; /** 스크린리더용 다이얼로그 이름 */ ariaLabel?: string; + /** 중앙 카드 대신 크롬 사이 영역을 통째로 덮는 전면 시트 */ + fullSurface?: boolean; } const Modal = ({ @@ -14,9 +16,14 @@ const Modal = ({ children, animate = true, ariaLabel, + fullSurface = false, }: ModalProps) => { const scrimAnimClass = animate ? 'animate-modal-scrim' : ''; - const contentAnimClass = animate ? 'animate-modal-scale' : ''; + const contentAnimClass = animate + ? fullSurface + ? 'animate-modal-sheet' + : 'animate-modal-scale' + : ''; const closeFromBackdropRef = useRef(false); const backdropRef = useRef(null); const onCloseRef = useRef(onClick); @@ -101,17 +108,27 @@ const Modal = ({ role="dialog" aria-modal="true" aria-label={ariaLabel} - className="fixed top-[31px] bottom-[61px] left-[1px] right-[1px] flex items-center justify-center z-50" + className="fixed top-[30px] bottom-[60px] left-0 right-0 flex items-center justify-center z-50" onPointerDown={handleBackdropPointerDown} onClick={handleBackdropClick} onWheel={handleWheel} > - {/* 스크림 언더레이 — 클릭은 래퍼로 통과 */} + {/* 스크림 언더레이 — 클릭은 래퍼로 통과. + 전면 시트는 스크림 생략 — 시트가 영역을 다 덮어 어둡히기가 무의미하고, + 스크림이 겹치면 같은 글래스 토큰인데 사이드 패널보다 어둡게 합성됨 */} + {!fullSurface && ( + , document.body, ); diff --git a/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx b/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx index 08f30e88..86840e5e 100644 --- a/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/FontPicker.tsx @@ -5,6 +5,7 @@ import type { CustomFont } from '@src/types/settings/fonts'; import { DEFAULT_FONT_FAMILY } 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 { @@ -472,9 +473,13 @@ const FontPicker = ({ {webFontModal ? ( setWebFontModal(null)} ariaLabel="로딩 중..."> + setWebFontModal(null)} + ariaLabel="로딩 중..." + >
event.stopPropagation()} >

@@ -489,6 +494,7 @@ const FontPicker = ({ onClose={() => setWebFontModal(null)} onSubmit={handleWebFontSubmit} initialCss={editingWebFont?.cssContent || ''} + mode={editingWebFont ? 'edit' : 'add'} isDuplicateFontFamily={(fontFamily) => fontLibrary.isDuplicateFontFamily(fontFamily, { excludeId: webFontModal.editingId, diff --git a/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx b/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx index c410735c..ef5ca11f 100644 --- a/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx +++ b/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx @@ -1,14 +1,7 @@ /* eslint-disable react-hooks/refs */ -import { useState, useRef, useLayoutEffect } from 'react'; +import { useState, useRef, useEffect, useLayoutEffect } from 'react'; import { EditorSelection, EditorState } from '@codemirror/state'; -import { - EditorView, - highlightActiveLine, - highlightActiveLineGutter, - keymap, - lineNumbers, - placeholder, -} from '@codemirror/view'; +import { EditorView, keymap, lineNumbers, placeholder } from '@codemirror/view'; import { defaultKeymap, history, @@ -22,8 +15,13 @@ import { syntaxHighlighting, } from '@codemirror/language'; import { tags } from '@lezer/highlight'; -import Modal from '@components/main/Modal/Modal'; -import { validateWebFontFaceCss } from '@src/types/settings/fonts'; +import FullSurfaceModalLayout from '@components/main/Modal/FullSurfaceModalLayout'; +import { + buildDraftPreviewCss, + validateWebFontFaceCss, +} from '@src/types/settings/fonts'; +import type { WebFontWeightRange } from '@src/types/settings/fonts'; +import type { CSSProperties } from 'react'; interface WebFontInputModalProps { isOpen: boolean; @@ -31,9 +29,47 @@ interface WebFontInputModalProps { onSubmit: (css: string, displayName: string) => void; initialCss?: string; isDuplicateFontFamily?: (fontFamily: string) => boolean; + /** 시트 제목 분기 — 추가/수정 */ + mode?: 'add' | 'edit'; t: (key: string, options?: Record) => string; } +// 형식 예시 — 코드라 로케일 불필요. 줄 높이 부풀림은 absolute placeholder CSS가 방지 +const WEBFONT_PLACEHOLDER_EXAMPLE = `@font-face {\n font-family: 'FontName';\n src: url('https://...') format('woff2');\n font-weight: 400;\n font-style: normal;\n}`; + +// 스페시멘 전용 패밀리 — 등록된 실제 폰트와 충돌하지 않게 초안 이름으로 교체 +const DRAFT_PREVIEW_FAMILY = 'DmnWebFontDraftPreview'; +const DRAFT_PREVIEW_STYLE_ID = 'webfont-draft-preview'; + +// 가변 범위는 경계값 + 안쪽 400/700 대표 스톱으로 압축해 행 수를 억제 +const expandWeightStops = (ranges: WebFontWeightRange[]): number[] => { + const stops = new Set(); + for (const { min, max } of ranges) { + stops.add(min); + stops.add(max); + for (const mid of [400, 700]) { + if (mid > min && mid < max) stops.add(mid); + } + } + return Array.from(stops).sort((a, b) => a - b); +}; + +const injectDraftPreviewCSS = (css: string) => { + const existing = document.getElementById(DRAFT_PREVIEW_STYLE_ID); + if (existing) { + existing.textContent = css; + } else { + const style = document.createElement('style'); + style.id = DRAFT_PREVIEW_STYLE_ID; + style.textContent = css; + document.head.appendChild(style); + } +}; + +const removeDraftPreviewCSS = () => { + document.getElementById(DRAFT_PREVIEW_STYLE_ID)?.remove(); +}; + const WEBFONT_EDITOR_HIGHLIGHT_STYLE = HighlightStyle.define([ { tag: tags.comment, color: '#6A9955' }, { tag: [tags.string, tags.special(tags.string)], color: '#CE9178' }, @@ -42,10 +78,10 @@ const WEBFONT_EDITOR_HIGHLIGHT_STYLE = HighlightStyle.define([ { tag: [tags.bracket, tags.punctuation], color: '#D4D4D4' }, ]); +// 활성 줄 하이라이트는 제외 — 여러 줄 placeholder가 한 줄에 담겨 +// 빈 상태에서 블록 전체가 선택된 것처럼 보임 const WEBFONT_EDITOR_BASE_EXTENSIONS = [ lineNumbers(), - highlightActiveLine(), - highlightActiveLineGutter(), history(), indentUnit.of(' '), css(), @@ -72,9 +108,16 @@ const WebFontInputModal = ({ onSubmit, initialCss = '', isDuplicateFontFamily, + mode = 'add', t, }: WebFontInputModalProps) => { const [cssInput, setCssInput] = useState(''); + // 스페시멘 로드 결과 — css 스냅샷 키로 최신 입력과 대조, 굵기별 성패 기록 (loading은 파생) + const [previewLoad, setPreviewLoad] = useState<{ + css: string; + loaded: number[]; + failed: number[]; + } | null>(null); const editorContainerRef = useRef(null); const editorViewRef = useRef(null); const handleSubmitRef = useRef<() => void>(() => undefined); @@ -95,10 +138,21 @@ const WebFontInputModal = ({ const canSubmit = cssValidation.status === 'ready' && !hasDuplicateFontFamily; - const detectedFontFileName = (() => { - const defaultFileName = t('webFontInput.defaultFileName') || 'web-font'; - return extractedFontFamily || defaultFileName; - })(); + // 중복 폰트도 스페시멘은 보여줌 — 저장만 막고 확인은 허용 + const previewActive = + isOpen && cssValidation.status === 'ready' && !!extractedFontFamily; + // 일부 굵기만 실패하면 ready 유지 — 실패 행은 스페시멘에서 개별 표시 + const previewStatus: 'idle' | 'loading' | 'ready' | 'error' = !previewActive + ? 'idle' + : previewLoad?.css === trimmedCSS + ? previewLoad.loaded.length > 0 + ? 'ready' + : 'error' + : 'loading'; + const weightStops = expandWeightStops(cssValidation.detectedWeights); + const failedWeights = new Set( + previewLoad?.css === trimmedCSS ? previewLoad.failed : [], + ); const availabilityLabel = (() => { if (cssValidation.status === 'ready' && hasDuplicateFontFamily) { @@ -134,14 +188,12 @@ const WebFontInputModal = ({ } })(); - const fixedHintMessage = - t('webFontInput.fixedHint') || '@font-face CSS를 추가할 수 있습니다.'; + const sheetTitle = + mode === 'edit' + ? t('webFontInput.titleEdit') || '웹 폰트 수정' + : t('webFontInput.titleAdd') || '웹 폰트 추가'; const submitButtonLabel = t('webFontInput.submit') || '저장'; - const placeholderText = `${ - t('webFontInput.cssLabel') || '@font-face CSS' - }\n\n@font-face {\n font-family: 'FontName';\n src: url('https://...') format('woff2');\n font-weight: 400;\n font-style: normal;\n}`; - const resetEditorContent = (nextValue = '') => { setCssInput(nextValue); @@ -197,7 +249,7 @@ const WebFontInputModal = ({ doc: normalizedInitialCss, extensions: [ ...WEBFONT_EDITOR_BASE_EXTENSIONS, - placeholder(placeholderText), + placeholder(WEBFONT_PLACEHOLDER_EXAMPLE), keymap.of([ ...defaultKeymap, ...historyKeymap, @@ -236,7 +288,7 @@ const WebFontInputModal = ({ editorViewRef.current = null; } }; - }, [isOpen, normalizedInitialCss, placeholderText]); + }, [isOpen, normalizedInitialCss]); useLayoutEffect(() => { if (!isOpen) return; @@ -259,33 +311,85 @@ const WebFontInputModal = ({ setCssInput(normalizedInitialCss); }, [isOpen, normalizedInitialCss]); + // 스페시멘 로드 — 입력이 잠잠해지면 초안 패밀리로 주입하고 실제 로드를 확인 + useEffect(() => { + if (!previewActive) { + removeDraftPreviewCSS(); + return; + } + + let cancelled = false; + const cssSnapshot = trimmedCSS; + const timer = setTimeout(() => { + // @font-face 블록만 주입 — 블록 밖 규칙이 앱 전역 스타일을 오염시키지 못함 + injectDraftPreviewCSS( + buildDraftPreviewCss(cssSnapshot, DRAFT_PREVIEW_FAMILY), + ); + // 선언된 굵기마다 로드 확인 — 스톱별 요청이 각자 가장 가까운 face를 당겨옴 + const stops = expandWeightStops( + validateWebFontFaceCss(cssSnapshot).detectedWeights, + ); + const targets = stops.length > 0 ? stops : [400]; + Promise.allSettled( + targets.map((weight) => + document.fonts.load(`${weight} 16px "${DRAFT_PREVIEW_FAMILY}"`), + ), + ).then((results) => { + if (cancelled) return; + const loaded: number[] = []; + const failed: number[] = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value.length > 0) { + loaded.push(targets[index]); + } else { + failed.push(targets[index]); + } + }); + setPreviewLoad({ css: cssSnapshot, loaded, failed }); + }); + }, 350); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [previewActive, trimmedCSS]); + + // 언마운트 시 초안 스타일 잔류 방지 + useEffect(() => () => removeDraftPreviewCSS(), []); + if (!isOpen) return null; + const specimenFontFamily = `'${DRAFT_PREVIEW_FAMILY}'`; + const pangramText = + t('webFontInput.previewPangram') || '다람쥐 헌 쳇바퀴에 타고파'; + const singleWeight = weightStops.length === 1; + // font-synthesis 차단 — 없는 굵기를 가짜 볼드로 합성하지 않아 실제 로드 결과가 그대로 보임 + const specimenTextStyle = (weight: number): CSSProperties => ({ + fontFamily: specimenFontFamily, + fontWeight: weight, + fontSynthesis: 'none', + }); + return ( - -

event.stopPropagation()} - > -
-
- - CSS - - - {detectedFontFileName} - + {/* 본문 — 입력(에디터)과 결과(스페시멘) 섹션 카드를 나란히 (사이드 패널 문법) */} +
+ {/* CSS 입력 섹션 */} +
+
+

+ {t('webFontInput.cssLabel') || '@font-face CSS'} +

- - {availabilityLabel} - -
- -
-
+
-
-

- {fixedHintMessage} -

-

- Ctrl/Cmd + Enter + {/* 미리보기 섹션 — 검증 상태는 결과 옆이 제자리, 색은 무채색 유지 */} +

+
+

+ {t('webFontInput.previewLabel') || '미리보기'} +

+ + {availabilityLabel} + +
+ {/* 로드 상태 발표 전용 — 스페시멘은 라이브 영역 밖에 둬서 통짜 낭독을 피함 */} +

+ {previewStatus === 'loading' + ? t('webFontInput.previewLoading') || '폰트 불러오는 중…' + : previewStatus === 'error' + ? t('webFontInput.previewError') || '폰트를 불러오지 못했습니다' + : previewStatus === 'ready' + ? t('webFontInput.previewReady') || '폰트 미리보기 준비됨' + : ''}

-
- -
- - + {/* 패딩은 스크롤 컨테이너 안쪽 소유 — 웰에 두면 스크롤 시 위아래 죽은 띠가 생김 */} +
+ {previewStatus === 'ready' ? ( + /* 스크롤 페이드와 등장 모션은 animation 충돌로 분리 — 바깥이 스크롤, 안쪽이 모션 */ +
+
+

+ {extractedFontFamily} +

+ +
+ ) : previewStatus === 'error' ? ( +
+

+ {t('webFontInput.previewError') || + '폰트를 불러오지 못했습니다'} +

+

+ {t('webFontInput.previewErrorHint') || 'src URL을 확인하세요'} +

+
+ ) : ( +

+ {previewStatus === 'loading' + ? t('webFontInput.previewLoading') || '폰트 불러오는 중…' + : t('webFontInput.previewEmpty') || + '유효한 @font-face를 입력하면 미리보기가 표시됩니다'} +

+ )} +
- + ); }; diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 6cd1c10a..587fafbe 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -620,6 +620,15 @@ "availabilityDuplicateFontFamily": "Already registered font-family", "availabilityMultipleFamilies": "Multiple fonts detected", "duplicateFontFamilyAlert": "\"{{name}}\" font is already registered.", - "fixedHint": "You can add @font-face CSS." + "titleAdd": "Add web font", + "titleEdit": "Edit web font", + "previewLabel": "Preview", + "previewEmpty": "Paste a valid @font-face rule to see a live preview", + "previewLoading": "Loading font…", + "previewError": "Couldn't load the font", + "previewErrorHint": "Check the src URL", + "previewReady": "Font preview ready", + "previewPangram": "The quick brown fox jumps over the lazy dog", + "weightLoadFailed": "Failed to load" } } diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 8c4bcdbb..929af3bd 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -620,6 +620,15 @@ "availabilityDuplicateFontFamily": "이미 등록된 font-family", "availabilityMultipleFamilies": "다중 폰트 감지", "duplicateFontFamilyAlert": "\"{{name}}\" 폰트가 이미 등록되어 있습니다.", - "fixedHint": "@font-face CSS를 추가할 수 있습니다." + "titleAdd": "웹 폰트 추가", + "titleEdit": "웹 폰트 수정", + "previewLabel": "미리보기", + "previewEmpty": "유효한 @font-face를 입력하면 미리보기가 표시됩니다", + "previewLoading": "폰트 불러오는 중…", + "previewError": "폰트를 불러오지 못했습니다", + "previewErrorHint": "src URL을 확인하세요", + "previewReady": "폰트 미리보기 준비됨", + "previewPangram": "다람쥐 헌 쳇바퀴에 타고파", + "weightLoadFailed": "로드 실패" } } diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 92a9c73c..b0816eef 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -620,6 +620,15 @@ "availabilityDuplicateFontFamily": "font-family уже есть", "availabilityMultipleFamilies": "Несколько шрифтов", "duplicateFontFamilyAlert": "Шрифт \"{{name}}\" уже добавлен.", - "fixedHint": "Можно добавить @font-face CSS." + "titleAdd": "Добавить веб-шрифт", + "titleEdit": "Изменить веб-шрифт", + "previewLabel": "Предпросмотр", + "previewEmpty": "Вставьте корректное правило @font-face, чтобы увидеть предпросмотр", + "previewLoading": "Загрузка шрифта…", + "previewError": "Не удалось загрузить шрифт", + "previewErrorHint": "Проверьте URL в src", + "previewReady": "Предпросмотр шрифта готов", + "previewPangram": "Съешь же ещё этих мягких французских булок", + "weightLoadFailed": "Не удалось загрузить" } } diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index bfd02ffa..f06bcfe6 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -620,6 +620,15 @@ "availabilityDuplicateFontFamily": "已註冊的 font-family", "availabilityMultipleFamilies": "偵測到多個字體", "duplicateFontFamilyAlert": "\"{{name}}\" 字體已註冊。", - "fixedHint": "您可以添加 @font-face CSS。" + "titleAdd": "新增網頁字體", + "titleEdit": "編輯網頁字體", + "previewLabel": "預覽", + "previewEmpty": "貼上有效的 @font-face 規則即可即時預覽", + "previewLoading": "正在載入字體…", + "previewError": "無法載入字體", + "previewErrorHint": "請檢查 src URL", + "previewReady": "字體預覽已就緒", + "previewPangram": "視野無限廣,窗外有藍天", + "weightLoadFailed": "載入失敗" } } diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 8fea88cc..74d122c2 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -620,6 +620,15 @@ "availabilityDuplicateFontFamily": "已注册的 font-family", "availabilityMultipleFamilies": "检测到多个字体", "duplicateFontFamilyAlert": "\"{{name}}\" 字体已注册。", - "fixedHint": "您可以添加 @font-face CSS。" + "titleAdd": "添加网页字体", + "titleEdit": "编辑网页字体", + "previewLabel": "预览", + "previewEmpty": "粘贴有效的 @font-face 规则即可实时预览", + "previewLoading": "正在加载字体…", + "previewError": "无法加载字体", + "previewErrorHint": "请检查 src URL", + "previewReady": "字体预览已就绪", + "previewPangram": "视野无限广,窗外有蓝天", + "weightLoadFailed": "加载失败" } } diff --git a/src/renderer/styles/main.css b/src/renderer/styles/main.css index a2d92100..40bb3bf5 100644 --- a/src/renderer/styles/main.css +++ b/src/renderer/styles/main.css @@ -315,6 +315,18 @@ body.dmn-custom-cursor * { animation: modalScale var(--ui-duration-base) var(--ui-ease-out) both; } +/* 전면 시트 — 타이틀바에서 내려앉는 등장, 종료 상태는 클래스 자연값 */ +@keyframes modalSheet { + from { + transform: translateY(-8px); + opacity: 0; + } +} + +.animate-modal-sheet > * { + animation: modalSheet var(--ui-duration-base) var(--ui-ease-out) both; +} + /* 틴트와 블러를 한 키프레임에서 보간 — 종료 상태는 클래스 자연값 */ @keyframes modalScrim { from { @@ -383,10 +395,11 @@ body.dmn-custom-cursor * { height: 100%; } +/* 크롬 색은 사이드 패널과 같은 토큰 — 웰(bg-inset)이 배경을 소유, 에디터는 투명 */ .webfont-cm-editor .cm-editor { height: 100%; - background: var(--ui-bg-inset-solid); - color: #d4d4d4; + background: transparent; + color: var(--ui-fg); } .webfont-cm-editor .cm-scroller, @@ -422,17 +435,22 @@ body.dmn-custom-cursor * { min-height: 100%; padding: 8px 12px 8px 14px; tab-size: 2; - caret-color: #d4d4d4; + caret-color: var(--ui-fg); } +/* relative — 여러 줄 예시 placeholder를 절대배치로 줄 흐름에서 분리, + 줄 박스가 부풀지 않아 캐럿·거터 높이가 정상 유지됨 */ .webfont-cm-editor .cm-line { padding: 0; + position: relative; } +/* 웰보다 한 톤 어두운 솔리드 스트립 — 기존 인셋 겹침(검정 30%)과 같은 톤이지만 + 불투명이라 가로 스크롤 시 밑을 지나는 텍스트가 비치지 않음 */ .webfont-cm-editor .cm-gutters { - background: var(--ui-bg-panel); + background: color-mix(in srgb, var(--ui-bg-inset-solid) 70%, black); color: var(--ui-fg-faint); - border-right: 1px solid var(--ui-line); + border: none; } .webfont-cm-editor .cm-lineNumbers .cm-gutterElement { @@ -443,26 +461,17 @@ body.dmn-custom-cursor * { text-align: right; } -.webfont-cm-editor .cm-activeLine { - background: #ffffff10; -} - -.webfont-cm-editor .cm-activeLineGutter { - color: var(--ui-fg-muted); - background: var(--ui-bg-panel); -} - .webfont-cm-editor .cm-content ::selection { - background: #5b7fae; + background: color-mix(in srgb, var(--ui-accent) 35%, transparent); } .webfont-cm-editor .cm-content ::-moz-selection { - background: #5b7fae; + background: color-mix(in srgb, var(--ui-accent) 35%, transparent); } .webfont-cm-editor .cm-cursor, .webfont-cm-editor .cm-dropCursor { - border-left-color: #d4d4d4; + border-left-color: var(--ui-fg); } .webfont-cm-editor .cm-editor.cm-focused { @@ -470,8 +479,12 @@ body.dmn-custom-cursor * { } .webfont-cm-editor .cm-placeholder { + position: absolute; + top: 0; + left: 0; color: var(--ui-fg-faint); white-space: pre; + pointer-events: none; } .webfont-cm-editor .cm-scroller::-webkit-scrollbar { @@ -502,7 +515,9 @@ body.dmn-custom-cursor * { @media (prefers-reduced-motion: reduce) { .tooltip-fade-in, .animate-modal-scale > *, + .animate-modal-sheet > *, .animate-modal-scrim, + .animate-pulse, .view-enter, .view-fade { animation: none; diff --git a/src/types/settings/fonts.test.ts b/src/types/settings/fonts.test.ts new file mode 100644 index 00000000..89dbcebd --- /dev/null +++ b/src/types/settings/fonts.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { buildDraftPreviewCss, validateWebFontFaceCss } from './fonts'; + +const DRAFT = 'DmnWebFontDraftPreview'; + +describe('buildDraftPreviewCss — @font-face 블록만 추출·치환', () => { + it('블록 밖 규칙은 주입 결과에서 제외됨', () => { + const input = `@font-face { font-family: 'Foo'; src: url('https://x/f.woff2'); } +body { display: none; } +.app { color: red; }`; + const result = buildDraftPreviewCss(input, DRAFT); + + expect(result).toContain(`font-family: '${DRAFT}';`); + expect(result).toContain("url('https://x/f.woff2')"); + expect(result).not.toContain('display: none'); + expect(result).not.toContain('.app'); + expect(result).not.toContain('body'); + }); + + it('여러 블록 모두 family가 초안 이름으로 치환됨', () => { + const input = `@font-face { font-family: 'Foo'; src: url(a.woff2); font-weight: 400; } +@font-face { font-family: "Foo"; src: url(b.woff2); font-weight: 700; }`; + const result = buildDraftPreviewCss(input, DRAFT); + + expect(result).not.toContain('Foo'); + expect(result.match(new RegExp(DRAFT, 'g'))).toHaveLength(2); + expect(result).toContain('font-weight: 400'); + expect(result).toContain('font-weight: 700'); + }); + + it('마지막 선언 세미콜론 생략도 validator와 동일하게 치환됨', () => { + // validator가 저장을 허용하는 문법은 미리보기 치환도 동일하게 처리해야 함 + const input = `@font-face { src: url(a.woff2); font-family: 'Foo' }`; + expect(validateWebFontFaceCss(input).status).toBe('ready'); + + const result = buildDraftPreviewCss(input, DRAFT); + expect(result).toContain(`font-family: '${DRAFT}'`); + expect(result).not.toContain('Foo'); + }); + + it('escape된 중괄호로 블록 경계를 속일 수 없음', () => { + // \{ 는 escaped code point — 브라우저 토크나이저처럼 구분자로 세지 않음 + const balanced = `@font-face { font-family: 'Foo'; src: url(a.woff2); x: \\{ } body { display: none }`; + const result = buildDraftPreviewCss(balanced, DRAFT); + expect(result).toContain(DRAFT); + expect(result).not.toContain('display: none'); + + // 브라우저 기준으로 body가 최상위로 새는 입력은 구조 불일치로 거부됨 + const attack = `@font-face { font-family: 'Foo'; src: url(a.woff2); x: \\{ } body { display: none } }`; + expect(validateWebFontFaceCss(attack).status).toBe('invalidCss'); + expect(buildDraftPreviewCss(attack, DRAFT)).toBe(''); + }); + + it('src: local() 문자열 안의 font-family 텍스트는 치환되지 않음', () => { + const input = `@font-face { font-family: 'Foo'; src: local('font-family: Trap'), url(a.woff2); }`; + expect(validateWebFontFaceCss(input).status).toBe('ready'); + expect(validateWebFontFaceCss(input).detectedFontFamily).toBe('Foo'); + + const result = buildDraftPreviewCss(input, DRAFT); + expect(result).toContain("local('font-family: Trap')"); + expect(result).toContain(`font-family: '${DRAFT}';`); + }); + + it('--font-family 커스텀 속성은 descriptor로 오인하지 않음', () => { + const input = `@font-face { --font-family: X; font-family: 'Foo'; src: url(a.woff2); }`; + const result = buildDraftPreviewCss(input, DRAFT); + + expect(result).toContain('--font-family: X'); + expect(result.match(new RegExp(DRAFT, 'g'))).toHaveLength(1); + }); + + it('조건부 at-rule 안의 @font-face는 최상위로 승격하지 않음', () => { + // 조건이 거짓이면 적용되지 않아야 할 face가 미리보기에서 로드되는 것을 방지 + const nested = `@media not all { @font-face { font-family: 'Foo'; src: url(a.woff2); } }`; + expect(validateWebFontFaceCss(nested).status).toBe('missingFontFace'); + expect(buildDraftPreviewCss(nested, DRAFT)).toBe(''); + + const mixed = `@font-face { font-family: 'Foo'; src: url(a.woff2); } +@media x { @font-face { font-family: 'Foo'; src: url(b.woff2); } }`; + const result = buildDraftPreviewCss(mixed, DRAFT); + expect(result).toContain('a.woff2'); + expect(result).not.toContain('b.woff2'); + }); + + it('문자열·주석 안의 중괄호에 속지 않음', () => { + const input = `/* body { display: none; } */ +@font-face { font-family: 'Fo}o'; src: url('a}b.woff2'); }`; + const result = buildDraftPreviewCss(input, DRAFT); + + expect(result).toContain(`font-family: '${DRAFT}';`); + expect(result).toContain("url('a}b.woff2')"); + expect(result).not.toContain('display: none'); + }); + + it('구조가 깨진 CSS는 빈 문자열 반환 (주입 없음)', () => { + expect(buildDraftPreviewCss('@font-face { font-family: X;', DRAFT)).toBe( + '', + ); + expect(buildDraftPreviewCss('', DRAFT)).toBe(''); + }); + + it('@font-face가 없으면 빈 문자열 반환', () => { + expect(buildDraftPreviewCss('body { color: red; }', DRAFT)).toBe(''); + }); +}); diff --git a/src/types/settings/fonts.ts b/src/types/settings/fonts.ts index cc082baa..5ff1b430 100644 --- a/src/types/settings/fonts.ts +++ b/src/types/settings/fonts.ts @@ -40,10 +40,17 @@ export type WebFontCssValidationStatus = | 'missingSrc' | 'multipleFamilies'; +// 블록별 font-weight 선언 — 가변 폰트는 min/max 범위, 단일 굵기는 min === max +export interface WebFontWeightRange { + min: number; + max: number; +} + export interface WebFontCssValidationResult { status: WebFontCssValidationStatus; detectedFontFamily: string | null; familyNames: string[]; + detectedWeights: WebFontWeightRange[]; } // 앱 기본 폰트 (전역 로드 — global.css @font-face) @@ -136,8 +143,6 @@ export function normalizeFontFamilyName(value: string): string { .toLowerCase(); } -const FONT_FAMILY_DECLARATION_REGEX = /font-family\s*:\s*([^;]+?)(?:;|$)/i; -const SRC_DECLARATION_REGEX = /src\s*:\s*([^;]+?)(?:;|$)/i; const CSS_BLOCK_COMMENT_REGEX = /\/\*[\s\S]*?\*\//g; interface CssLexState { @@ -190,6 +195,12 @@ function consumeCssLiteral( return cursor + 1; } + // 따옴표 밖 escape — \{ 같은 escaped code point를 실제 구분자로 세지 않음 + // (브라우저 토크나이저와 중괄호 계산이 어긋나면 블록 경계 우회가 생김) + if (char === '\\') { + return Math.min(css.length, cursor + 2); + } + if (char === '/' && next === '*') { state.inComment = true; return cursor + 2; @@ -268,6 +279,7 @@ function extractFontFaceBodies(css: string): { const bodies: string[] = []; const token = '@font-face'; let cursor = 0; + let braceDepth = 0; const state = createCssLexState(); while (cursor < css.length) { @@ -277,7 +289,15 @@ function extractFontFaceBodies(css: string): { continue; } - if (!matchesTokenIgnoreCase(css, cursor, token)) { + // 최상위 @font-face만 수집 — @media 등 조건부 안의 face를 승격하면 + // 미리보기와 저장 후 실제 적용 결과가 달라짐 + if (braceDepth > 0 || !matchesTokenIgnoreCase(css, cursor, token)) { + const skippedChar = css[cursor]; + if (skippedChar === '{') { + braceDepth += 1; + } else if (skippedChar === '}') { + braceDepth -= 1; + } cursor += 1; continue; } @@ -346,17 +366,85 @@ export function normalizeFontSettings(raw: unknown): FontSettings { return parsed.success ? parsed.data : defaults; } +function parseFontWeightToken(token: string): number | null { + const lower = token.toLowerCase(); + if (lower === 'normal') return 400; + if (lower === 'bold') return 700; + const parsed = Number(lower); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 1000) return null; + return parsed; +} + +// 선언 누락·해석 불가 값은 브라우저 기본과 같은 400 취급 +function parseFontWeightValue(value: string | null): WebFontWeightRange { + if (!value) return { min: 400, max: 400 }; + + const tokens = value.trim().split(/\s+/); + const first = parseFontWeightToken(tokens[0] ?? ''); + if (first === null) return { min: 400, max: 400 }; + + const second = tokens.length > 1 ? parseFontWeightToken(tokens[1]) : null; + if (second === null) return { min: first, max: first }; + return { min: Math.min(first, second), max: Math.max(first, second) }; +} + +// 선언 분리 — 문자열·주석·escape를 렉서로 존중하며 세미콜론 단위 분할. +// 문자열 값 안의 'font-family:' 텍스트에 속지 않는 파싱 기준점 +function splitTopLevelDeclarations(body: string): string[] { + const declarations: string[] = []; + const state = createCssLexState(); + let start = 0; + + for (let cursor = 0; cursor < body.length; ) { + const nextCursor = consumeCssLiteral(body, cursor, state); + if (nextCursor !== null) { + cursor = nextCursor; + continue; + } + + if (body[cursor] === ';') { + declarations.push(body.slice(start, cursor)); + start = cursor + 1; + } + cursor += 1; + } + + declarations.push(body.slice(start)); + return declarations; +} + +function declarationProperty(declaration: string): string { + const cleaned = declaration.replace(CSS_BLOCK_COMMENT_REGEX, ' '); + const colonIndex = cleaned.indexOf(':'); + if (colonIndex === -1) return ''; + return cleaned.slice(0, colonIndex).trim().toLowerCase(); +} + +function findDeclarationValue( + declarations: string[], + property: string, +): string | null { + for (const declaration of declarations) { + if (declarationProperty(declaration) !== property) continue; + const cleaned = declaration.replace(CSS_BLOCK_COMMENT_REGEX, ' '); + return cleaned.slice(cleaned.indexOf(':') + 1).trim(); + } + return null; +} + function createValidationResult( status: WebFontCssValidationStatus, options?: { detectedFontFamily?: string | null; familyNames?: string[]; + detectedWeights?: WebFontWeightRange[]; }, ): WebFontCssValidationResult { return { status, detectedFontFamily: options?.detectedFontFamily ?? null, familyNames: options?.familyNames ?? [], + detectedWeights: options?.detectedWeights ?? [], }; } @@ -382,25 +470,25 @@ export function validateWebFontFaceCss( } const familyMap = new Map(); + const weightMap = new Map(); for (const body of bodies) { - const bodyWithoutComments = body.replace(CSS_BLOCK_COMMENT_REGEX, ' '); - const familyMatch = bodyWithoutComments.match( - FONT_FAMILY_DECLARATION_REGEX, - ); - if (!familyMatch) { + const declarations = splitTopLevelDeclarations(body); + + const familyValue = findDeclarationValue(declarations, 'font-family'); + if (familyValue === null) { return createValidationResult('missingFontFamily'); } - const rawFamily = familyMatch[1].split(',')[0] || ''; + const rawFamily = familyValue.split(',')[0] || ''; const familyName = stripOuterQuotes(rawFamily).trim(); const normalizedFamilyName = normalizeFontFamilyName(familyName); if (!normalizedFamilyName) { return createValidationResult('missingFontFamily'); } - const srcMatch = bodyWithoutComments.match(SRC_DECLARATION_REGEX); - if (!srcMatch || !/url\s*\(/i.test(srcMatch[1])) { + const srcValue = findDeclarationValue(declarations, 'src'); + if (srcValue === null || !/url\s*\(/i.test(srcValue)) { return createValidationResult('missingSrc', { familyNames: Array.from(familyMap.values()), }); @@ -409,6 +497,11 @@ export function validateWebFontFaceCss( if (!familyMap.has(normalizedFamilyName)) { familyMap.set(normalizedFamilyName, familyName); } + + const weightRange = parseFontWeightValue( + findDeclarationValue(declarations, 'font-weight'), + ); + weightMap.set(`${weightRange.min}:${weightRange.max}`, weightRange); } const familyNames = Array.from(familyMap.values()); @@ -416,9 +509,14 @@ export function validateWebFontFaceCss( return createValidationResult('multipleFamilies', { familyNames }); } + const detectedWeights = Array.from(weightMap.values()).sort( + (a, b) => a.min - b.min || a.max - b.max, + ); + return createValidationResult('ready', { detectedFontFamily: familyNames[0] || null, familyNames, + detectedWeights, }); } @@ -427,6 +525,35 @@ export function extractFontFamilyFromCSS(css: string): string | null { return validateWebFontFaceCss(css).detectedFontFamily; } +// 미리보기 초안 CSS 생성 — 최상위 @font-face 블록만 추출해 family를 초안 이름으로 치환 +// 블록 밖 규칙은 버려서 미리보기 주입이 앱 전역 스타일을 오염시키지 못함. +// 문법 허용 범위는 validator와 동일 — 렉서·선언 파서를 공유하고, +// 치환은 선언 단위라 src: local(...) 등 문자열 값 안의 텍스트를 건드리지 않음 +export function buildDraftPreviewCss(css: string, draftFamily: string): string { + const trimmed = css.trim(); + if (!trimmed || !isCssStructurallyBalanced(trimmed)) { + return ''; + } + + const { bodies, malformed } = extractFontFaceBodies(trimmed); + if (malformed) { + return ''; + } + + return bodies + .map((body) => { + const replaced = splitTopLevelDeclarations(body) + .map((declaration) => + declarationProperty(declaration) === 'font-family' + ? ` font-family: '${draftFamily}'` + : declaration, + ) + .join(';'); + return `@font-face {${replaced}}`; + }) + .join('\n'); +} + // 고유 ID 생성 export function generateFontId(): string { return `font_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; From b49fab02d7c5b05531dd1f0feec131e55895e966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 13 Jul 2026 06:07:10 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=EB=AA=A8=EC=85=98=20=EC=97=90?= =?UTF-8?q?=EB=94=94=ED=84=B0=EB=A5=BC=20=EC=A0=84=EB=A9=B4=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=C2=B7=ED=92=80=EB=B8=94=EB=A6=AC=EB=93=9C=20=EC=BA=94?= =?UTF-8?q?=EB=B2=84=EC=8A=A4=EB=A1=9C=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 커브 캔버스가 카드 전체를 채우도록 뷰박스를 종횡비 기반으로 재계산, 핸들·히트 영역 화면 크기는 캔버스 실측 기준으로 유지 - 격자를 line 요소 162개에서 path 2개로 줄이고, 그리드·커브·핸들 색을 라인/액센트 토큰으로 통일 (보조 격자용 --ui-line-faint 추가) - 파라미터(이름·프리셋·베지어·스케일·시간)를 하단 데크 한 줄로 재배치, 미리보기 무대를 풀하이트로 확장 - 포인터 핸들러 매 렌더 재등록 제거로 드래그 렉 개선 --- .../editors/CounterAnimationEditorModal.tsx | 773 +++++++++--------- src/renderer/locales/en.json | 9 +- src/renderer/locales/ko.json | 9 +- src/renderer/locales/ru.json | 9 +- src/renderer/locales/zh-Hant.json | 9 +- src/renderer/locales/zh-cn.json | 9 +- src/renderer/styles/tokens.css | 2 + 7 files changed, 432 insertions(+), 388 deletions(-) diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx index 3ad03ebd..21a082b3 100644 --- a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx @@ -1,10 +1,10 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { CounterAnimationBezier, KeyCounterSettings, } from '@src/types/key/keys'; import type { CounterAnimationPreset } from '@src/types/key/counterAnimation'; -import Modal from '@components/main/Modal/Modal'; +import FullSurfaceModalLayout from '@components/main/Modal/FullSurfaceModalLayout'; import Dropdown from '@components/main/common/Dropdown'; import { TextInput, @@ -65,9 +65,17 @@ const EDITOR_SIZE = 110; const EDITOR_PADDING = 20; const TOTAL_SIZE = EDITOR_SIZE + EDITOR_PADDING * 2; const GRID_SUB = EDITOR_SIZE / 4; -const GRID_EXTENT = 24; +const GRID_EXTENT = 40; +// 캔버스 그리드 색 — 커브 에디터와 미리보기 스테이지가 공유 +// 흰색 알파 토큰이라 반투명 인셋 웰(글래스) 위에서 배경 톤을 따라 자연 합성됨 +const GRID_MAJOR_COLOR = 'var(--ui-line)'; +const GRID_MINOR_COLOR = 'var(--ui-line-faint)'; const HANDLE_RADIUS = 6; const HANDLE_HIT_RADIUS = 10; +// 기준 렌더 크기 — 핸들·코너 화면 크기의 기준값 (실제 렌더는 캔버스 실측) +// 뷰박스 세로는 TOTAL_SIZE 기준, 가로는 캔버스 종횡비만큼 넓어지는 풀블리드 캔버스 +// 오프셋 (0,0) = 커브 정사각이 뷰 중앙, 포인터 수학은 비율 좌표라 크기 변화에 안전 +const EDITOR_RENDER_SIZE = 220; const PAN_MARGIN = 14; const MAX_DURATION = 5000; const MIN_ZOOM = 0.15; @@ -76,6 +84,34 @@ const ZOOM_SENSITIVITY = 0.002; const AUTO_FIT_MARGIN = 14; const AUTO_FIT_DURATION = 260; +// 격자 경로 — 월드 좌표가 정적이라 모듈에서 1회 생성, 162개 대신 2개 +const buildGridPath = (major: boolean) => { + const far = GRID_EXTENT * GRID_SUB; + const start = EDITOR_PADDING - far; + const end = EDITOR_PADDING + far; + const segments: string[] = []; + for (let i = -GRID_EXTENT; i <= GRID_EXTENT; i++) { + if ((i % 4 === 0) !== major) continue; + const pos = EDITOR_PADDING + i * GRID_SUB; + segments.push(`M ${pos} ${start} V ${end}`); + segments.push(`M ${start} ${pos} H ${end}`); + } + return segments.join(' '); +}; +const GRID_PATH_MAJOR = buildGridPath(true); +const GRID_PATH_MINOR = buildGridPath(false); + +// 뷰박스 치수 — 커브 정사각(TOTAL_SIZE)이 짧은 변에 맞고, 긴 변은 종횡비만큼 넓어짐 +const viewDims = (scale: number, aspect: number) => { + const base = TOTAL_SIZE / scale; + const safeAspect = Math.max(aspect, 0.01); + return { + base, + vbW: base * Math.max(safeAspect, 1), + vbH: base * Math.max(1 / safeAspect, 1), + }; +}; + const normalizeScale = (value: number) => { if (!Number.isFinite(value)) return 1.1; return value; @@ -164,8 +200,18 @@ const CounterAnimationEditorModal = ({ const pinchStartOffsetRef = useRef({ x: 0, y: 0 }); const pinchStartMidFracRef = useRef({ x: 0, y: 0 }); const autoFitRafRef = useRef(null); + const editorAreaRef = useRef(null); + const editorSizeRef = useRef({ + width: EDITOR_RENDER_SIZE, + height: EDITOR_RENDER_SIZE, + }); const [nameInput, setNameInput] = useState(''); + // 캔버스 실측 — 뷰박스 종횡비와 핸들 화면 크기 계산용 + const [editorSize, setEditorSize] = useState({ + width: EDITOR_RENDER_SIZE, + height: EDITOR_RENDER_SIZE, + }); const [localBezier, setLocalBezier] = useState([ 0.25, 0.46, 0.45, 0.94, ]); @@ -220,13 +266,18 @@ const CounterAnimationEditorModal = ({ const minY = Math.min(ptsMinY, EDITOR_PADDING); const maxY = Math.max(ptsMaxY, EDITOR_PADDING + EDITOR_SIZE); - const defaultBox = { minX: 0, minY: 0, maxX: TOTAL_SIZE, maxY: TOTAL_SIZE }; + // 기본 뷰의 긴 변 허용 범위는 종횡비만큼 넓다 (측정 전에는 정사각 취급) + const { width: canvasW, height: canvasH } = editorSizeRef.current; + const aspect = canvasH > 0 ? canvasW / canvasH : 1; + const defaultView = viewDims(1, aspect); + const defaultExtraW = (defaultView.vbW - TOTAL_SIZE) / 2; + const defaultExtraH = (defaultView.vbH - TOTAL_SIZE) / 2; if ( - minX >= defaultBox.minX && - maxX <= defaultBox.maxX && - minY >= defaultBox.minY && - maxY <= defaultBox.maxY + minX >= -defaultExtraW && + maxX <= TOTAL_SIZE + defaultExtraW && + minY >= -defaultExtraH && + maxY <= TOTAL_SIZE + defaultExtraH ) { return { offset: { x: 0, y: 0 }, scale: 1 }; } @@ -244,7 +295,12 @@ const CounterAnimationEditorModal = ({ const needW = fitMaxX - fitMinX; const needH = fitMaxY - fitMinY; - const needSize = Math.max(needW, needH, TOTAL_SIZE); + // 긴 변은 종횡비만큼 더 보이므로 짧은 변 기준 크기로 환산해 맞춤 + const needSize = Math.max( + needW / Math.max(aspect, 1), + needH * Math.min(Math.max(aspect, 0.01), 1), + TOTAL_SIZE, + ); const maxVB = TOTAL_SIZE / MIN_ZOOM; const vbSize = Math.min(needSize, maxVB); @@ -296,6 +352,28 @@ const CounterAnimationEditorModal = ({ useEffect(() => () => cancelAutoFit(), []); + // 캔버스 리사이즈 추적 — 첫 페인트 전에 실측해야 초기 aspect 불일치로 + // 커브가 늘어났다 복귀하는 프레임이 없음 (preserveAspectRatio=none) + useLayoutEffect(() => { + if (!isOpen) return; + const area = editorAreaRef.current; + if (!area) return; + + const measure = () => { + const rect = area.getBoundingClientRect(); + const width = Math.floor(rect.width); + const height = Math.floor(rect.height); + if (width <= 0 || height <= 0) return; + editorSizeRef.current = { width, height }; + setEditorSize({ width, height }); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(area); + return () => observer.disconnect(); + }, [isOpen]); + useEffect(() => { if (!isOpen) return; @@ -434,10 +512,15 @@ const CounterAnimationEditorModal = ({ const scale = viewScaleRef.current; const offset = viewOffsetRef.current; - const vbSize = TOTAL_SIZE / scale; + const { base, vbW, vbH } = viewDims( + scale, + rect.width / Math.max(rect.height, 1), + ); + const extraW = (vbW - base) / 2; + const extraH = (vbH - base) / 2; - const worldX = offset.x + fracX * vbSize; - const worldY = offset.y + fracY * vbSize; + const worldX = offset.x - extraW + fracX * vbW; + const worldY = offset.y - extraH + fracY * vbH; const bezierX = (worldX - EDITOR_PADDING) / EDITOR_SIZE; const bezierY = 1 - (worldY - EDITOR_PADDING) / EDITOR_SIZE; @@ -477,10 +560,12 @@ const CounterAnimationEditorModal = ({ let nx = offset.x; let ny = offset.y; - if (hx < offset.x + margin) nx = hx - margin; - else if (hx > offset.x + vbSize - margin) nx = hx - vbSize + margin; - if (hy < offset.y + margin) ny = hy - margin; - else if (hy > offset.y + vbSize - margin) ny = hy - vbSize + margin; + const viewLeft = offset.x - extraW; + const viewTop = offset.y - extraH; + if (hx < viewLeft + margin) nx = hx - margin + extraW; + else if (hx > viewLeft + vbW - margin) nx = hx - vbW + margin + extraW; + if (hy < viewTop + margin) ny = hy - margin + extraH; + else if (hy > viewTop + vbH - margin) ny = hy - vbH + margin + extraH; if (nx !== offset.x || ny !== offset.y) { const next = { x: nx, y: ny }; @@ -517,16 +602,20 @@ const CounterAnimationEditorModal = ({ MAX_ZOOM, ); + const rect = svg.getBoundingClientRect(); + const aspect = rect.width / Math.max(rect.height, 1); const fracX = pinchStartMidFracRef.current.x; const fracY = pinchStartMidFracRef.current.y; - const oldVB = TOTAL_SIZE / oldScale; - const newVB = TOTAL_SIZE / newScale; + const oldView = viewDims(oldScale, aspect); + const newView = viewDims(newScale, aspect); const startOff = pinchStartOffsetRef.current; - const worldX = startOff.x + fracX * oldVB; - const worldY = startOff.y + fracY * oldVB; + const worldX = + startOff.x - (oldView.vbW - oldView.base) / 2 + fracX * oldView.vbW; + const worldY = + startOff.y - (oldView.vbH - oldView.base) / 2 + fracY * oldView.vbH; const newOff = { - x: worldX - fracX * newVB, - y: worldY - fracY * newVB, + x: worldX + (newView.vbW - newView.base) / 2 - fracX * newView.vbW, + y: worldY + (newView.vbH - newView.base) / 2 - fracY * newView.vbH, }; applyView(newOff, newScale); @@ -538,11 +627,14 @@ const CounterAnimationEditorModal = ({ if (!svg) return; const rect = svg.getBoundingClientRect(); const scale = viewScaleRef.current; - const vbSize = TOTAL_SIZE / scale; + const { vbW, vbH } = viewDims( + scale, + rect.width / Math.max(rect.height, 1), + ); const dxClient = event.clientX - panStartRef.current.clientX; const dyClient = event.clientY - panStartRef.current.clientY; - const dxWorld = -(dxClient / rect.width) * vbSize; - const dyWorld = -(dyClient / rect.height) * vbSize; + const dxWorld = -(dxClient / rect.width) * vbW; + const dyWorld = -(dyClient / rect.height) * vbH; const newOffset = { x: panStartRef.current.offsetX + dxWorld, @@ -594,7 +686,9 @@ const CounterAnimationEditorModal = ({ window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; - }); + // 핸들러는 ref만 읽어서 재구독 불필요 — 매 렌더 재등록이 드래그 렉을 만듦 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); const handlePointPointerDown = ( event: React.PointerEvent, @@ -616,24 +710,24 @@ const CounterAnimationEditorModal = ({ const rect = svg.getBoundingClientRect(); const scale = viewScaleRef.current; - const vbSize = TOTAL_SIZE / scale; + const aspect = rect.width / Math.max(rect.height, 1); + const { base, vbW, vbH } = viewDims(scale, aspect); if (event.ctrlKey || event.metaKey) { const fracX = (event.clientX - rect.left) / rect.width; const fracY = (event.clientY - rect.top) / rect.height; - const oldVB = vbSize; const delta = -event.deltaY * ZOOM_SENSITIVITY; const factor = Math.exp(delta); const newScale = Math.min(Math.max(scale * factor, MIN_ZOOM), MAX_ZOOM); - const newVB = TOTAL_SIZE / newScale; + const newView = viewDims(newScale, aspect); const off = viewOffsetRef.current; - const worldX = off.x + fracX * oldVB; - const worldY = off.y + fracY * oldVB; + const worldX = off.x - (vbW - base) / 2 + fracX * vbW; + const worldY = off.y - (vbH - base) / 2 + fracY * vbH; const newOff = { - x: worldX - fracX * newVB, - y: worldY - fracY * newVB, + x: worldX + (newView.vbW - newView.base) / 2 - fracX * newView.vbW, + y: worldY + (newView.vbH - newView.base) / 2 - fracY * newView.vbH, }; applyView(newOff, newScale); @@ -644,10 +738,10 @@ const CounterAnimationEditorModal = ({ const dx = ((event.shiftKey ? event.deltaX || event.deltaY : event.deltaX) / rect.width) * - vbSize; + vbW; const dy = ((event.shiftKey && !event.deltaX ? 0 : event.deltaY) / rect.height) * - vbSize; + vbH; const newOff = { x: off.x + dx, y: off.y + dy }; viewOffsetRef.current = newOff; setViewOffset(newOff); @@ -730,41 +824,6 @@ const CounterAnimationEditorModal = ({ applyView({ x: 0, y: 0 }, 1); }; - const gridLines = (() => { - const lines: React.ReactElement[] = []; - const far = GRID_EXTENT * GRID_SUB; - for (let i = -GRID_EXTENT; i <= GRID_EXTENT; i++) { - const pos = EDITOR_PADDING + i * GRID_SUB; - const isMajor = i % 4 === 0; - const color = isMajor ? '#3A3943' : '#2D2D35'; - lines.push( - , - ); - lines.push( - , - ); - } - return lines; - })(); - const parsedScale = (() => { const parsed = parseNumber(scaleInput); const normalized = normalizeScale(parsed ?? 1.1); @@ -831,9 +890,18 @@ const CounterAnimationEditorModal = ({ const startW = { x: P, y: P + S }; const endW = { x: P + S, y: P }; - const vbSize = TOTAL_SIZE / viewScale; - const viewBoxStr = `${viewOffset.x} ${viewOffset.y} ${vbSize} ${vbSize}`; + const renderAspect = + editorSize.height > 0 ? editorSize.width / editorSize.height : 1; + const { base: vbBase, vbW, vbH } = viewDims(viewScale, renderAspect); + const viewLeft = viewOffset.x - (vbW - vbBase) / 2; + const viewTop = viewOffset.y - (vbH - vbBase) / 2; + const viewBoxStr = `${viewLeft} ${viewTop} ${vbW} ${vbH}`; const ns = 1 / viewScale; + // 캔버스가 커져도 핸들·코너는 기준 렌더 크기의 화면 크기 유지 (짧은 변 기준) + const uns = + ns * + (EDITOR_RENDER_SIZE / + Math.max(Math.min(editorSize.width, editorSize.height), 1)); const headerTitle = mode === 'edit' @@ -841,275 +909,178 @@ const CounterAnimationEditorModal = ({ : t('counterSetting.createAnimationTitle') || '모션 추가'; return ( - -
event.stopPropagation()} - > -
-
- - Motion - - - {headerTitle} - -
+ + + + + + {t('counterSetting.motionPerformanceNotice') || + '모션 효과는 시스템 리소스를 추가로 사용합니다'} +
- -
-
-
- setNameInput(event.target.value)} - placeholder={ - t('counterSetting.animationNamePlaceholder') || '모션 이름' - } - className="w-full h-[32px] px-[12px] rounded-[8px] bg-app text-body leading-[16px] text-fg placeholder-fg-faint outline-none focus:ring-1 focus:ring-accent/20 transition-all font-medium shadow-inner" - /> -
- -
-
-
-
- - - - {gridLines} - - - - - - handlePointPointerDown(e, 'p1')} - /> - - handlePointPointerDown(e, 'p2')} - /> - - -
-
-
- -
-
- - handlePresetChange(String(val))} - fullWidth - /> -
- -
- - { - setBezierInput(raw); - const parsed = parseBezierInput(raw); - if (!parsed) return; - localBezierRef.current = parsed; - setLocalBezier(parsed); - }} - onBlur={() => { - const parsed = parseBezierInput(bezierInput); - if (!parsed) { - setBezierInput( - formatBezierInput(localBezierRef.current), - ); - return; - } - localBezierRef.current = parsed; - setLocalBezier(parsed); - setBezierInput(formatBezierInput(parsed)); - }} - placeholder="0.25, 0.46, 0.45, 0.94" - width="100%" - /> -
- -
-
- - setScaleInput(String(val))} - onBlur={() => { - const parsed = parseNumber(scaleInput); - const normalized = normalizeScale(parsed ?? 1.1); - setScaleInput( - String(Math.round(normalized * 100) / 100), - ); - }} - allowDecimal={true} - decimalScale={2} - min={0} - max={9999} - width="100%" - /> -
- -
- - setDurationInput(String(val))} - onBlur={() => { - const parsed = parseNumber(durationInput); - const normalized = clampDuration(parsed ?? 300); - setDurationInput(String(normalized)); - }} - width="100%" - min={100} - max={5000} - /> -
-
-
+ } + submitLabel={ + isSaving + ? t('counterSetting.saving') || '저장 중...' + : t('common.save') || '저장' + } + submitDisabled={!canSave} + onSubmit={() => { + void handleSave(); + }} + cancelLabel={t('common.cancel') || '취소'} + > + {/* 본문 — 상단: 캔버스 히어로 + 미리보기 무대, 하단: 파라미터 데크 */} +
+
+ {/* 커브 캔버스 — 카드 내부를 통째로 채우는 풀블리드 캔버스 */} +
+
+ + {/* 배경 웰은 컨테이너 div(bg-inset)가 소유 — 프레임별 좌표 갱신 제거 */} + {/* 커브 작업 사각형 — 좌표 영역이라 라운딩 없이 각을 유지 */} + + {/* crispEdges — CSS 그리드(미리보기)와 같은 또렷한 1px 라인 */} + + + + + + + + handlePointPointerDown(e, 'p1')} + /> + + handlePointPointerDown(e, 'p2')} + /> + +
- - {errorText ? ( -

- {errorText} -

- ) : null}
-
+ {/* 미리보기 — 풀하이트 무대, 라벨 없이 스테이지 안 힌트만 */} +
{previewCss && (