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 735f57ad..00000000 Binary files a/GameBarOverlay/Assets/LockScreenLogo.scale-200.png and /dev/null differ diff --git a/GameBarOverlay/Assets/SplashScreen.scale-200.png b/GameBarOverlay/Assets/SplashScreen.scale-200.png deleted file mode 100644 index 88bf4a7e..00000000 Binary files a/GameBarOverlay/Assets/SplashScreen.scale-200.png and /dev/null differ diff --git a/GameBarOverlay/Assets/Square150x150Logo.scale-200.png b/GameBarOverlay/Assets/Square150x150Logo.scale-200.png deleted file mode 100644 index 72555b3b..00000000 Binary files a/GameBarOverlay/Assets/Square150x150Logo.scale-200.png and /dev/null differ diff --git a/GameBarOverlay/Assets/Square44x44Logo.scale-200.png b/GameBarOverlay/Assets/Square44x44Logo.scale-200.png deleted file mode 100644 index 13c10472..00000000 Binary files a/GameBarOverlay/Assets/Square44x44Logo.scale-200.png and /dev/null differ diff --git a/GameBarOverlay/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/GameBarOverlay/Assets/Square44x44Logo.targetsize-24_altform-unplated.png deleted file mode 100644 index debc4b89..00000000 Binary files a/GameBarOverlay/Assets/Square44x44Logo.targetsize-24_altform-unplated.png and /dev/null differ diff --git a/GameBarOverlay/Assets/StoreLogo.png b/GameBarOverlay/Assets/StoreLogo.png deleted file mode 100644 index 5772d970..00000000 Binary files a/GameBarOverlay/Assets/StoreLogo.png and /dev/null differ diff --git a/GameBarOverlay/Assets/Wide310x150Logo.scale-200.png b/GameBarOverlay/Assets/Wide310x150Logo.scale-200.png deleted file mode 100644 index 73d756e5..00000000 Binary files a/GameBarOverlay/Assets/Wide310x150Logo.scale-200.png and /dev/null differ 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..c4ed537d 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -50,3 +50,127 @@ 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. + + +apple_cursor +------------ + +DM Note incorporates modified vector path data from the apple_cursor +project by ful1e5 for its macOS resize cursors. + +Source: https://github.com/ful1e5/apple_cursor + +apple_cursor is licensed under the GNU General Public License, Version 3. +DM Note redistributes the modified cursor data under GPL-3.0-only, +consistent with DM Note's own license. The complete GNU GPL Version 3 +license text is included in the root LICENSE file. 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/scripts/verify-macos-bundle.sh b/scripts/verify-macos-bundle.sh new file mode 100755 index 00000000..6424c407 --- /dev/null +++ b/scripts/verify-macos-bundle.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +APP_BUNDLE="${1:-${REPO_ROOT}/src-tauri/target/release/bundle/macos/DM NOTE.app}" + +fail() { + echo "[FAIL] $*" >&2 + exit 1 +} + +require_file() { + [[ -f "$1" ]] || fail "파일을 찾을 수 없음: $1" +} + +read_executable_name() { + /usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$1" 2>/dev/null \ + || fail "CFBundleExecutable을 읽을 수 없음: $1" +} + +read_minos() { + local binary="$1" + local arch="$2" + xcrun vtool -show-build -arch "$arch" "$binary" 2>/dev/null \ + | awk '$1 == "minos" { print $2; exit }' +} + +read_plist_min_version() { + /usr/libexec/PlistBuddy -c "Print :LSMinimumSystemVersion" "$1" 2>/dev/null || true +} + +MIN_SYSTEM_VERSION="11.0" + +command -v lipo >/dev/null 2>&1 || fail "lipo를 찾을 수 없음" +command -v xcrun >/dev/null 2>&1 || fail "xcrun을 찾을 수 없음" +[[ -x /usr/libexec/PlistBuddy ]] || fail "PlistBuddy를 찾을 수 없음" +[[ -d "$APP_BUNDLE" ]] || fail "앱 번들을 찾을 수 없음: $APP_BUNDLE" + +MAIN_INFO="${APP_BUNDLE}/Contents/Info.plist" +HELPER_BUNDLE="${APP_BUNDLE}/Contents/Resources/DM NOTE.app" +HELPER_INFO="${HELPER_BUNDLE}/Contents/Info.plist" +NOTICES="${APP_BUNDLE}/Contents/Resources/THIRD_PARTY_NOTICES.txt" + +require_file "$MAIN_INFO" +[[ -d "$HELPER_BUNDLE" ]] || fail "helper 번들을 찾을 수 없음: $HELPER_BUNDLE" +require_file "$HELPER_INFO" +require_file "$NOTICES" + +MAIN_EXECUTABLE="${APP_BUNDLE}/Contents/MacOS/$(read_executable_name "$MAIN_INFO")" +HELPER_EXECUTABLE="${HELPER_BUNDLE}/Contents/MacOS/$(read_executable_name "$HELPER_INFO")" +require_file "$MAIN_EXECUTABLE" +require_file "$HELPER_EXECUTABLE" +[[ -x "$MAIN_EXECUTABLE" ]] || fail "main 바이너리에 실행 권한이 없음: $MAIN_EXECUTABLE" +[[ -x "$HELPER_EXECUTABLE" ]] || fail "helper 바이너리에 실행 권한이 없음: $HELPER_EXECUTABLE" + +# plist 최소 버전이 바이너리 minos와 일치하는지 비교 (표기-실행 불일치 방지) +MAIN_PLIST_MIN="$(read_plist_min_version "$MAIN_INFO")" +[[ "$MAIN_PLIST_MIN" == "$MIN_SYSTEM_VERSION" ]] \ + || fail "main LSMinimumSystemVersion이 ${MIN_SYSTEM_VERSION}이 아님: ${MAIN_PLIST_MIN:-없음}" +echo "[OK] main plist LSMinimumSystemVersion=$MAIN_PLIST_MIN" + +HELPER_PLIST_MIN="$(read_plist_min_version "$HELPER_INFO")" +[[ "$HELPER_PLIST_MIN" == "$MIN_SYSTEM_VERSION" ]] \ + || fail "helper LSMinimumSystemVersion이 ${MIN_SYSTEM_VERSION}이 아님: ${HELPER_PLIST_MIN:-없음}" +echo "[OK] helper plist LSMinimumSystemVersion=$HELPER_PLIST_MIN" + +HELPER_ARCHS="$(lipo -archs "$HELPER_EXECUTABLE")" +for arch in arm64 x86_64; do + case " $HELPER_ARCHS " in + *" $arch "*) ;; + *) fail "helper에 $arch 아키텍처가 없음: $HELPER_ARCHS" ;; + esac + + minos="$(read_minos "$HELPER_EXECUTABLE" "$arch")" + [[ "$minos" == "$MIN_SYSTEM_VERSION" ]] \ + || fail "helper $arch minos가 ${MIN_SYSTEM_VERSION}이 아님: ${minos:-없음}" + echo "[OK] helper $arch minos=$minos" +done + +MAIN_ARCHS="$(lipo -archs "$MAIN_EXECUTABLE")" +[[ -n "$MAIN_ARCHS" ]] || fail "main 바이너리 아키텍처를 읽을 수 없음" +for arch in $MAIN_ARCHS; do + minos="$(read_minos "$MAIN_EXECUTABLE" "$arch")" + [[ "$minos" == "$MIN_SYSTEM_VERSION" ]] \ + || fail "main $arch minos가 ${MIN_SYSTEM_VERSION}이 아님: ${minos:-없음}" + echo "[OK] main $arch minos=$minos" +done + +echo "[OK] helper 경로: $HELPER_BUNDLE" +echo "[OK] notices 경로: $NOTICES" +echo "[OK] macOS 번들 검증 완료" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c17e2b43..921b40a5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1165,6 +1165,7 @@ dependencies = [ "gif-dispose", "local-ip-address", "log", + "minisign-verify", "notify", "notify-debouncer-mini", "objc", @@ -1191,6 +1192,7 @@ dependencies = [ "thread-priority", "tokio", "tokio-tungstenite", + "url", "uuid", "walkdir", "webp-animation", @@ -2682,6 +2684,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 74e81a9e..168565e6 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] @@ -17,6 +18,7 @@ anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" base64 = "0.22" +minisign-verify = "0.2.5" thiserror = "1.0" parking_lot = "0.12" log = "0.4" @@ -31,6 +33,7 @@ open = "5.1" rfd = "0.14" dirs-next = "2.0" uuid = { version = "1", features = ["v4"] } +url = "2" zip = "0.6" gif = "0.13.3" gif-dispose = "5.0.1" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 09b6045f..0837cdb3 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -157,6 +157,8 @@ fn maybe_build_macos_dock_helper() { let helper_macos = helper_contents.join("MacOS"); let helper_resources = helper_contents.join("Resources"); let helper_exec = helper_macos.join("DMNoteDockHelper"); + let helper_arm64_exec = helper_macos.join("DMNoteDockHelper.arm64"); + let helper_x86_64_exec = helper_macos.join("DMNoteDockHelper.x86_64"); let helper_bundle_info = helper_contents.join("Info.plist"); let helper_icon = helper_resources.join("icon.icns"); let source_icon = PathBuf::from("icons/icon.icns"); @@ -168,6 +170,9 @@ fn maybe_build_macos_dock_helper() { if legacy_helper_bundle.exists() { let _ = fs::remove_dir_all(&legacy_helper_bundle); } + if helper_bundle.exists() { + let _ = fs::remove_dir_all(&helper_bundle); + } if let Err(err) = fs::create_dir_all(&helper_macos) { println!("cargo:warning=failed to create helper MacOS dir: {err}"); @@ -178,21 +183,66 @@ fn maybe_build_macos_dock_helper() { return; } - let status = Command::new("xcrun") - .args(["--sdk", "macosx", "swiftc"]) - .arg(&helper_src) - .args(["-O", "-framework", "AppKit", "-o"]) + let helper_slices = [ + ( + "arm64", + "arm64-apple-macos11.0", + helper_arm64_exec.as_path(), + ), + ( + "x86_64", + "x86_64-apple-macos11.0", + helper_x86_64_exec.as_path(), + ), + ]; + + for (arch, target, output) in helper_slices { + let status = Command::new("xcrun") + .args(["--sdk", "macosx", "swiftc"]) + .arg(&helper_src) + .args(["-target", target, "-O", "-framework", "AppKit", "-o"]) + .arg(output) + .status(); + + match status { + Ok(s) if s.success() => {} + Ok(s) => { + println!("cargo:warning=swiftc helper {arch} build failed with status {s}"); + let _ = fs::remove_file(&helper_arm64_exec); + let _ = fs::remove_file(&helper_x86_64_exec); + return; + } + Err(err) => { + println!("cargo:warning=failed to invoke swiftc for helper {arch} build: {err}"); + let _ = fs::remove_file(&helper_arm64_exec); + let _ = fs::remove_file(&helper_x86_64_exec); + return; + } + } + } + + let lipo_status = Command::new("xcrun") + .arg("lipo") + .arg("-create") + .arg(&helper_arm64_exec) + .arg(&helper_x86_64_exec) + .arg("-output") .arg(&helper_exec) .status(); - match status { + let _ = fs::remove_file(&helper_arm64_exec); + let _ = fs::remove_file(&helper_x86_64_exec); + + match lipo_status { Ok(s) if s.success() => {} Ok(s) => { - println!("cargo:warning=swiftc helper build failed with status {s}"); + println!("cargo:warning=lipo helper build failed with status {s}"); + let _ = fs::remove_file(&helper_exec); return; } Err(err) => { - println!("cargo:warning=failed to invoke swiftc for helper build: {err}"); + println!("cargo:warning=failed to invoke lipo for helper build: {err}"); + let _ = fs::remove_file(&helper_exec); return; } } diff --git a/src-tauri/helper/DockHelper/main.swift b/src-tauri/helper/DockHelper/main.swift index 116db1d1..387a9096 100644 --- a/src-tauri/helper/DockHelper/main.swift +++ b/src-tauri/helper/DockHelper/main.swift @@ -16,10 +16,11 @@ private func argValue(_ key: ArgKey) -> String? { } final class DockHelperAppDelegate: NSObject, NSApplicationDelegate { - private let mainPid: pid_t? + private var mainPid: pid_t? private let mainBundleId: String private let mainBundlePath: String? private var monitorTimer: Timer? + private var isQuitting = false private lazy var dockMenu: NSMenu = { let menu = NSMenu() let openItem = NSMenuItem( @@ -49,6 +50,16 @@ final class DockHelperAppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { + let currentPid = ProcessInfo.processInfo.processIdentifier + // 기존 helper 인스턴스가 있으면 종료시키고 이 인스턴스(최신 main-pid)가 대체한다. + // 반대로 새 인스턴스를 자결시키면 앱 재시작 시 구 helper도 곧 죽어 Dock 아이콘이 사라진다 + let staleHelpers = NSRunningApplication + .runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "") + .filter { $0.processIdentifier != currentPid && !$0.isTerminated } + for helper in staleHelpers { + helper.terminate() + } + NSApp.setActivationPolicy(.regular) startMainProcessMonitor() } @@ -67,14 +78,11 @@ final class DockHelperAppDelegate: NSObject, NSApplicationDelegate { } @objc private func quitMainAndHelper() { + guard !isQuitting else { return } + isQuitting = true + monitorTimer?.invalidate() terminateMainApplications() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [mainBundleId] in - let running = NSRunningApplication.runningApplications(withBundleIdentifier: mainBundleId) - for app in running where !app.isTerminated { - app.forceTerminate() - } - NSApp.terminate(nil) - } + waitForMainTermination(attempt: 0) } private func startMainProcessMonitor() { @@ -89,12 +97,32 @@ final class DockHelperAppDelegate: NSObject, NSApplicationDelegate { } private func mainIsAlive() -> Bool { - if let pid = mainPid { - return kill(pid, 0) == 0 + // kill(pid,0)은 번들 여부와 무관하게 모든 프로세스에 동작 — dev 바이너리 포함. + // NSRunningApplication은 LaunchServices에 등록된 앱만 조회돼 생존 확인엔 부적합 + // pid 재사용 위험은 원 설계와 동일하게 수용 + if let pid = mainPid, kill(pid, 0) == 0 { + return true } + // pid가 죽었으면 같은 번들 ID로 재시작된 메인에 재결합 (패키지 앱 재시작) + return currentMainApplication() != nil + } - let apps = NSRunningApplication.runningApplications(withBundleIdentifier: mainBundleId) - return apps.contains { !$0.isTerminated } + private func currentMainApplication() -> NSRunningApplication? { + // 부모가 넘겨준 pid는 신뢰한다. 번들 ID 동일성은 pid 재사용 방어용이며 + // non-nil일 때만 검사 — dev 실행 메인은 번들이 아니라 bundleIdentifier가 + // nil이므로, 동일성을 요구하면 helper가 메인 사망으로 오판해 자결한다 + if let pid = mainPid, + let app = NSRunningApplication(processIdentifier: pid), + !app.isTerminated, + app.bundleIdentifier == nil || app.bundleIdentifier == mainBundleId { + return app + } + + let replacement = NSRunningApplication + .runningApplications(withBundleIdentifier: mainBundleId) + .first { !$0.isTerminated } + mainPid = replacement?.processIdentifier + return replacement } private func terminateMainApplications() { @@ -108,23 +136,60 @@ final class DockHelperAppDelegate: NSObject, NSApplicationDelegate { } } - private func activateOrLaunchMain() { - if let pid = mainPid, let app = NSRunningApplication(processIdentifier: pid), !app.isTerminated { - app.activate(options: [.activateIgnoringOtherApps]) + private func waitForMainTermination(attempt: Int) { + let running = NSRunningApplication + .runningApplications(withBundleIdentifier: mainBundleId) + .filter { !$0.isTerminated } + if running.isEmpty { + NSApp.terminate(nil) return } - if let running = NSRunningApplication - .runningApplications(withBundleIdentifier: mainBundleId) - .first(where: { !$0.isTerminated }) { - running.activate(options: [.activateIgnoringOtherApps]) + if attempt >= 50 { + for app in running { + app.forceTerminate() + } + NSApp.terminate(nil) return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.waitForMainTermination(attempt: attempt + 1) + } + } + + private func sendReopenEvent(to pid: pid_t) { + // openApplication이 번들 앱에 보내는 reopen(aevt/rapp)과 동일한 이벤트를 pid로 직접 전송 + // — 메인의 RunEvent::Reopen 핸들러가 트레이에 숨긴 창을 복원한다. reopen은 TCC 동의 면제 + let target = NSAppleEventDescriptor(processIdentifier: pid) + let event = NSAppleEventDescriptor( + eventClass: AEEventClass(kCoreEventClass), + eventID: AEEventID(kAEReopenApplication), + targetDescriptor: target, + returnID: AEReturnID(kAutoGenerateReturnID), + transactionID: AETransactionID(kAnyTransactionID) + ) + AESendMessage(event.aeDesc, nil, AESendMode(kAENoReply), kAEDefaultTimeout) + } + + private func activateOrLaunchMain() { let workspace = NSWorkspace.shared let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true + if let running = currentMainApplication() { + // 번들 없는 프로세스(dev 바이너리)의 bundleURL은 실행 파일 경로를 그대로 반환하는데, + // 그걸 openApplication에 넘기면 LaunchServices가 Terminal로 열어 새 인스턴스가 뜬다. + // 실제 .app 번들일 때만 openApplication 사용, dev는 reopen 이벤트를 직접 전송 + if let bundleURL = running.bundleURL, bundleURL.pathExtension == "app" { + workspace.openApplication(at: bundleURL, configuration: configuration) { _, _ in } + } else { + sendReopenEvent(to: running.processIdentifier) + running.activate(options: [.activateIgnoringOtherApps]) + } + return + } + if let bundlePath = mainBundlePath { let bundleURL = URL(fileURLWithPath: bundlePath) workspace.openApplication(at: bundleURL, configuration: configuration) { _, _ in } diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index 57c6baaf..a90c7a1f 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -5,27 +5,11 @@ 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(); - let t = token.clone(); - let _ = state.store.update(|s| { - s.obs_token = Some(t.clone()); - }); - 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 = state.resolve_and_save_obs_token()?; // OBS 정적 파일 서빙 설정 if cfg!(debug_assertions) { @@ -58,10 +42,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 +77,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/app/system.rs b/src-tauri/src/commands/app/system.rs index 48f31d3f..17bd0d16 100644 --- a/src-tauri/src/commands/app/system.rs +++ b/src-tauri/src/commands/app/system.rs @@ -4,8 +4,6 @@ use crate::cursor::{get_macos_cursor_settings, rgb_to_hex}; use crate::errors::CmdResult; use crate::state::AppState; -const TRAY_ICON_ID: &str = "background-tray"; - #[tauri::command] pub fn window_minimize(app: AppHandle) -> CmdResult<()> { if let Some(window) = app.get_webview_window("main") { @@ -31,25 +29,15 @@ pub fn app_open_external(_app: AppHandle, url: String) -> CmdResult<()> { } #[tauri::command] -pub fn app_restart(app: AppHandle) -> CmdResult<()> { +pub fn app_restart(app: AppHandle, state: State<'_, AppState>) -> CmdResult<()> { + state.shutdown(); app.request_restart(); Ok(()) } #[tauri::command] pub fn window_show_main(app: AppHandle, state: State<'_, AppState>) -> CmdResult<()> { - if let Some(main) = app.get_webview_window("main") { - let _ = main.unminimize(); - main.show()?; - let _ = main.set_focus(); - } - - if app.tray_by_id(TRAY_ICON_ID).is_some() { - let _ = app.remove_tray_by_id(TRAY_ICON_ID); - } - - state.set_main_window_hidden(false)?; - + state.show_main_window(&app)?; Ok(()) } diff --git a/src-tauri/src/commands/app/update.rs b/src-tauri/src/commands/app/update.rs index cf584119..94459d4a 100644 --- a/src-tauri/src/commands/app/update.rs +++ b/src-tauri/src/commands/app/update.rs @@ -1,6 +1,15 @@ -use tauri::AppHandle; +use tauri::{AppHandle, State}; use crate::errors::{CmdResult, CommandError}; +use crate::state::AppState; + +#[cfg(any(target_os = "windows", test))] +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +#[cfg(any(target_os = "windows", test))] +use minisign_verify::{PublicKey, Signature}; + +#[cfg(any(target_os = "windows", test))] +const UPDATE_PUBLIC_KEY: &str = ""; #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -11,15 +20,20 @@ pub struct AutoUpdateResult { } #[tauri::command] -pub fn app_auto_update(app: AppHandle, tag: String) -> CmdResult { +pub fn app_auto_update( + app: AppHandle, + state: State<'_, AppState>, + tag: String, +) -> CmdResult { #[cfg(target_os = "windows")] { - app_auto_update_windows(app, &tag) + app_auto_update_windows(app, &state, &tag) } #[cfg(not(target_os = "windows"))] { let _ = app; + let _ = state; let _ = tag; Err(CommandError::msg( "auto update is only supported on Windows", @@ -28,12 +42,17 @@ pub fn app_auto_update(app: AppHandle, tag: String) -> CmdResult CmdResult { +fn app_auto_update_windows( + app: AppHandle, + state: &AppState, + tag: &str, +) -> CmdResult { use std::time::Duration; const REPO_OWNER: &str = "lee-sihun"; const REPO_NAME: &str = "DmNote"; const ASSET_NAME: &str = "DM.NOTE.exe"; + const SIGNATURE_ASSET_NAME: &str = "DM.NOTE.exe.sig"; let trimmed_tag = tag.trim(); if trimmed_tag.is_empty() { @@ -64,20 +83,27 @@ fn app_auto_update_windows(app: AppHandle, tag: &str) -> CmdResult CmdResult CmdResult CmdResult> { + let response = client + .get(url) + .send() + .map_err(|error| CommandError::msg(format!("failed to download {asset_label}: {error}")))?; + let response = response + .error_for_status() + .map_err(|error| CommandError::msg(format!("failed to download {asset_label}: {error}")))?; + let bytes = response.bytes().map_err(|error| { + CommandError::msg(format!("failed to read downloaded {asset_label}: {error}")) + })?; + Ok(bytes.to_vec()) +} + +#[cfg(any(target_os = "windows", test))] +fn verify_update_signature( + update_bytes: &[u8], + wrapped_public_key: &str, + wrapped_signature: &str, +) -> CmdResult<()> { + let public_key_text = decode_tauri_minisign_text(wrapped_public_key, "public key")?; + let signature_text = decode_tauri_minisign_text(wrapped_signature, "signature")?; + let public_key = PublicKey::decode(&public_key_text) + .map_err(|error| CommandError::msg(format!("invalid update public key: {error}")))?; + let signature = Signature::decode(&signature_text) + .map_err(|error| CommandError::msg(format!("invalid update signature: {error}")))?; + public_key + .verify(update_bytes, &signature, false) + .map_err(|error| { + CommandError::msg(format!("update signature verification failed: {error}")) + }) +} + +#[cfg(any(target_os = "windows", test))] +fn decode_tauri_minisign_text(wrapped: &str, label: &str) -> CmdResult { + let decoded = BASE64_STANDARD + .decode(wrapped.trim()) + .map_err(|error| CommandError::msg(format!("invalid base64-wrapped {label}: {error}")))?; + String::from_utf8(decoded) + .map_err(|error| CommandError::msg(format!("decoded {label} is not UTF-8: {error}"))) +} + #[cfg(target_os = "windows")] fn parse_semver_version(raw: &str) -> CmdResult { let normalized = raw.trim().trim_start_matches(['v', 'V']); @@ -107,3 +181,40 @@ fn is_safe_tag(tag: &str) -> bool { tag.chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '+')) } + +#[cfg(test)] +mod tests { + use super::{verify_update_signature, UPDATE_PUBLIC_KEY}; + + // 테스트 전용 일회성 키로 생성한 픽스처 — 프로덕션 키와 무관 + const TEST_PAYLOAD: &[u8] = b"DM NOTE isolated update signature test fixture\n"; + const TEST_PUBLIC_KEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0QkI0QkQ0MUVERDNDMzMKUldRelBOMGUxRXU3UlBVUWx2Z21zWmlHWnkwRUsvcy9rOTJHNXhqemRuTjAxS0I3cWduZUNITzIK"; + const TEST_MISMATCH_PUBLIC_KEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDI2MjU5MzY2NjdDOTJDMzMKUldRekxNbG5acE1sSnZBMmJPZlFFd3VydTg3TGsrUjNBdjVyRFNUSnFFeFlNQjYySzRnd1pqYjQK"; + const TEST_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRelBOMGUxRXU3UkhkdGprNFZPVTgyQlMvTkF6ZFBmd1h1SzdKbFVIMkhOUlFKUG5tR1dlVndXaWJGR3ZvN2s2QS85NU9UZmNhb0haREwrYWNkc2k0U0tKQ0RvQTBzSUFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzgzOTI0NDE2CWZpbGU6dXBkYXRlX3NpZ25hdHVyZV90ZXN0X3BheWxvYWQuYmluCldRa0E4UkRDRU1oZHl1TEZPQnpwUTkxeHR0WnRyUVUrUFU4cWk0dzVjVGZJRVE2WWJkKzE0VzdFamVvQWFDRVJrNm5PQjgzRlVUVG1wVlQvSzdySUNRPT0K"; + + #[test] + fn production_public_key_gate_stays_disabled() { + assert!(UPDATE_PUBLIC_KEY.is_empty()); + } + + #[test] + fn update_signature_fixture_verifies() { + verify_update_signature(TEST_PAYLOAD, TEST_PUBLIC_KEY, TEST_SIGNATURE).unwrap(); + } + + #[test] + fn update_signature_rejects_tampered_payload() { + let mut tampered = TEST_PAYLOAD.to_vec(); + tampered[0] ^= 1; + + assert!(verify_update_signature(&tampered, TEST_PUBLIC_KEY, TEST_SIGNATURE).is_err()); + } + + #[test] + fn update_signature_rejects_mismatched_key() { + assert!( + verify_update_signature(TEST_PAYLOAD, TEST_MISMATCH_PUBLIC_KEY, TEST_SIGNATURE) + .is_err() + ); + } +} diff --git a/src-tauri/src/commands/keys/key_sound.rs b/src-tauri/src/commands/keys/key_sound.rs index 11b04c07..4a83b205 100644 --- a/src-tauri/src/commands/keys/key_sound.rs +++ b/src-tauri/src/commands/keys/key_sound.rs @@ -64,7 +64,7 @@ pub fn key_sound_set_output_backend( state: State<'_, AppState>, backend: KeySoundOutputBackend, ) -> CmdResult { - Ok(state.key_sound_set_output_backend(backend)) + Ok(state.key_sound_set_output_backend(backend)?) } #[tauri::command] diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index 67cc9779..7eccc77c 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -7,14 +7,163 @@ use crate::{ defaults::{default_keys, default_positions}, errors::CmdResult, models::{ - CustomCssPatch, CustomTab, KeyCounters, KeyMappings, KeyPositions, LayerGroups, - NoteSettings, NoteSettingsPatch, SettingsPatchInput, + AppStoreData, CustomCssPatch, CustomTab, KeyCounters, KeyMappings, KeyPositions, + LayerGroups, NoteSettings, NoteSettingsPatch, SettingsPatchInput, }, state::AppState, }; const MAX_CUSTOM_TABS: usize = 30; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModeResetKind { + Default, + Custom, +} + +struct CustomTabDeletePlan { + custom_tabs: Vec, + next_selected: String, +} + +fn zeroed_counters(keys: &KeyMappings) -> KeyCounters { + keys.iter() + .map(|(mode, mode_keys)| { + ( + mode.clone(), + mode_keys.iter().map(|key| (key.clone(), 0)).collect(), + ) + }) + .collect() +} + +fn reset_all_editor_data(store: &mut AppStoreData, keys: &KeyMappings, positions: &KeyPositions) { + store.keys = keys.clone(); + store.key_positions = positions.clone(); + store.stat_positions.clear(); + store.graph_positions.clear(); + store.knob_positions.clear(); + store.layer_groups.clear(); + store.key_counters = zeroed_counters(keys); + store.custom_tabs.clear(); + store.selected_key_type = "4key".to_string(); + store.tab_note_overrides.clear(); + store.tab_css_overrides.clear(); +} + +fn reset_mode_kind(store: &AppStoreData, mode: &str) -> Option { + if default_keys().contains_key(mode) { + Some(ModeResetKind::Default) + } else if store.custom_tabs.iter().any(|tab| tab.id == mode) { + Some(ModeResetKind::Custom) + } else { + None + } +} + +fn is_selectable_mode(store: &AppStoreData, mode: &str) -> bool { + default_keys().contains_key(mode) + || (store.keys.contains_key(mode) && store.custom_tabs.iter().any(|tab| tab.id == mode)) +} + +fn set_mode_with( + store: &AppStoreData, + requested: String, + commit: Commit, + apply_runtime: ApplyRuntime, +) -> CmdResult +where + Commit: FnOnce(String) -> CmdResult, + ApplyRuntime: FnOnce(&str) -> CmdResult<()>, +{ + if !is_selectable_mode(store, &requested) { + return Ok(ModeResponse { + success: false, + mode: store.selected_key_type.clone(), + }); + } + + let effective = commit(requested.clone())?; + apply_runtime(&effective)?; + Ok(ModeResponse { + success: effective == requested, + mode: effective, + }) +} + +fn reset_mode_data(store: &mut AppStoreData, mode: &str, kind: ModeResetKind) { + match kind { + ModeResetKind::Default => { + if let Some(keys) = default_keys().get(mode) { + store.keys.insert(mode.to_string(), keys.clone()); + } + if let Some(positions) = default_positions().get(mode) { + store + .key_positions + .insert(mode.to_string(), positions.clone()); + } + } + ModeResetKind::Custom => { + store.keys.insert(mode.to_string(), Vec::new()); + store.key_positions.insert(mode.to_string(), Vec::new()); + } + } + + store.stat_positions.insert(mode.to_string(), Vec::new()); + store.graph_positions.insert(mode.to_string(), Vec::new()); + store.knob_positions.insert(mode.to_string(), Vec::new()); + store.layer_groups.remove(mode); + store.tab_css_overrides.remove(mode); + store.tab_note_overrides.remove(mode); + + let mode_keys = store.keys.get(mode).cloned().unwrap_or_default(); + store.key_counters.insert( + mode.to_string(), + mode_keys.into_iter().map(|key| (key, 0)).collect(), + ); +} + +fn plan_custom_tab_delete(store: &AppStoreData, id: &str) -> Option { + let index = store.custom_tabs.iter().position(|tab| tab.id == id)?; + let custom_tabs: Vec = store + .custom_tabs + .iter() + .filter(|tab| tab.id != id) + .cloned() + .collect(); + let selected_tab_deleted = store.selected_key_type == id; + let next_selected = if selected_tab_deleted { + if custom_tabs.is_empty() { + "8key".to_string() + } else { + custom_tabs[if index > 0 { index - 1 } else { 0 }] + .id + .clone() + } + } else { + store.selected_key_type.clone() + }; + + Some(CustomTabDeletePlan { + custom_tabs, + next_selected, + }) +} + +fn delete_custom_tab_data(store: &mut AppStoreData, id: &str, plan: &CustomTabDeletePlan) { + store.custom_tabs = plan.custom_tabs.clone(); + store.keys.remove(id); + store.key_positions.remove(id); + store.stat_positions.remove(id); + store.graph_positions.remove(id); + store.knob_positions.remove(id); + store.layer_groups.remove(id); + store.tab_css_overrides.remove(id); + store.tab_note_overrides.remove(id); + store.key_counters.remove(id); + store.selected_key_type = plan.next_selected.clone(); +} + #[derive(Serialize)] pub struct ModeResponse { pub success: bool, @@ -80,9 +229,18 @@ pub fn keys_update( app: AppHandle, mappings: KeyMappings, ) -> CmdResult { - let updated = state.store.update_keys(mappings)?; - state.keyboard.update_mappings(updated.clone()); + let previous_mode = state.keyboard.current_mode(); + let (updated, selected_key_type) = state.store.update_keys(mappings)?; + state + .keyboard + .update_mappings_and_set_mode(updated.clone(), selected_key_type.clone()); app.emit("keys:changed", &updated)?; + if previous_mode != selected_key_type { + app.emit( + "keys:mode-changed", + &serde_json::json!({ "mode": &selected_key_type }), + )?; + } state.sync_counters_with_keys(&updated); app.emit("keys:counters", &state.snapshot_key_counters())?; state.obs_broadcast_counters(); @@ -108,25 +266,26 @@ pub fn keys_set_mode( app: AppHandle, mode: String, ) -> CmdResult { - let success = state.keyboard.set_mode(mode.clone()); - let effective = if success { - mode - } else { - state.keyboard.current_mode() - }; - - state.transfer_active_keys(&effective); - state.store.set_selected_key_type(effective.clone())?; - - app.emit( - "keys:mode-changed", - &serde_json::json!({ "mode": &effective }), - )?; - state.refresh_obs_snapshot(); - Ok(ModeResponse { - success, - mode: effective, - }) + let snapshot = state.store.snapshot(); + set_mode_with( + &snapshot, + mode, + |candidate| { + state + .store + .set_selected_key_type(candidate) + .map_err(Into::into) + }, + |effective| { + state.keyboard.set_mode(effective.to_string()); + app.emit( + "keys:mode-changed", + &serde_json::json!({ "mode": effective }), + )?; + state.refresh_obs_snapshot(); + Ok(()) + }, + ) } #[tauri::command] @@ -135,8 +294,10 @@ pub fn keys_reset_all(state: State<'_, AppState>, app: AppHandle) -> CmdResult = Vec::new(); let cleared_tab_css_ids: Vec = state @@ -148,26 +309,17 @@ pub fn keys_reset_all(state: State<'_, AppState>, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult CmdResult { - let defaults = default_keys(); - if !defaults.contains_key(&mode) { + let snapshot = state.store.snapshot(); + let Some(kind) = reset_mode_kind(&snapshot, &mode) else { return Ok(ResetModeResponse { success: false, mode, }); - } - - let default_pos = default_positions(); - - let snapshot = state.store.snapshot(); - let mut keys = snapshot.keys; - if let Some(value) = defaults.get(&mode) { - keys.insert(mode.clone(), value.clone()); - } - let mut positions = snapshot.key_positions; - if let Some(value) = default_pos.get(&mode) { - positions.insert(mode.clone(), value.clone()); - } - let mut stat_positions = snapshot.stat_positions; - stat_positions.insert(mode.clone(), Vec::new()); - let mut graph_positions = snapshot.graph_positions; - graph_positions.insert(mode.clone(), Vec::new()); - let mut layer_groups = snapshot.layer_groups; - layer_groups.remove(&mode); - let mut tab_note_overrides = snapshot.tab_note_overrides; - tab_note_overrides.remove(&mode); - let mut tab_css_overrides = snapshot.tab_css_overrides; - let cleared_tab_css = tab_css_overrides.remove(&mode).is_some(); + }; + let cleared_tab_css = snapshot.tab_css_overrides.contains_key(&mode); + let runtime_counters = state.snapshot_key_counters(); - state.store.update(|store| { - store.keys = keys.clone(); - store.key_positions = positions.clone(); - store.stat_positions = stat_positions.clone(); - store.graph_positions = graph_positions.clone(); - store.layer_groups = layer_groups.clone(); - store.tab_note_overrides = tab_note_overrides.clone(); - store.tab_css_overrides = tab_css_overrides.clone(); + let updated = state.store.update(|store| { + store.key_counters = runtime_counters.clone(); + reset_mode_data(store, &mode, kind); })?; if cleared_tab_css { state.unwatch_tab_css(&mode); } - state.keyboard.update_mappings(keys.clone()); - state.sync_counters_with_keys(&keys); - state.reset_mode_counters(&mode); - state.persist_key_counters()?; + state.keyboard.update_mappings(updated.keys.clone()); + state.commit_key_counters_mirror(updated.key_counters.clone()); - app.emit("keys:changed", &keys)?; - app.emit("positions:changed", &positions)?; - app.emit("statPositions:changed", &stat_positions)?; - app.emit("graphPositions:changed", &graph_positions)?; - app.emit("layerGroups:changed", &layer_groups)?; - app.emit("tabNote:changed_all", &tab_note_overrides)?; + app.emit("keys:changed", &updated.keys)?; + app.emit("positions:changed", &updated.key_positions)?; + app.emit("statPositions:changed", &updated.stat_positions)?; + app.emit("graphPositions:changed", &updated.graph_positions)?; + app.emit("knobPositions:changed", &updated.knob_positions)?; + app.emit("layerGroups:changed", &updated.layer_groups)?; + app.emit("tabNote:changed_all", &updated.tab_note_overrides)?; if cleared_tab_css { app.emit( "tabCss:changed", @@ -311,7 +438,7 @@ pub fn keys_reset_mode( }, )?; } - app.emit("keys:counters", &state.snapshot_key_counters())?; + app.emit("keys:counters", &updated.key_counters)?; state.obs_broadcast_counters(); state.refresh_obs_snapshot(); @@ -375,11 +502,11 @@ pub fn custom_tabs_create( store.selected_key_type = id.clone(); })?; - state.keyboard.update_mappings(keys.clone()); - state.keyboard.set_mode(id.clone()); + state + .keyboard + .update_mappings_and_set_mode(keys.clone(), id.clone()); state.sync_counters_with_keys(&keys); - state.reset_mode_counters(&id); - state.persist_key_counters()?; + let counters_snapshot = state.reset_mode_counters(&id)?; app.emit( "customTabs:changed", @@ -391,7 +518,7 @@ pub fn custom_tabs_create( app.emit("keys:changed", &keys)?; app.emit("positions:changed", &positions)?; app.emit("keys:mode-changed", &serde_json::json!({ "mode": &id }))?; - app.emit("keys:counters", &state.snapshot_key_counters())?; + app.emit("keys:counters", &counters_snapshot)?; state.obs_broadcast_counters(); state.refresh_obs_snapshot(); @@ -408,77 +535,58 @@ pub fn custom_tabs_delete( id: String, ) -> CmdResult { let snapshot = state.store.snapshot(); - if !snapshot.custom_tabs.iter().any(|tab| tab.id == id) { + let Some(plan) = plan_custom_tab_delete(&snapshot, &id) else { return Ok(CustomTabDeleteResult { success: false, selected: snapshot.selected_key_type, error: Some("not-found".to_string()), }); - } - - let custom_tabs: Vec = snapshot - .custom_tabs - .iter() - .filter(|&tab| tab.id != id) - .cloned() - .collect(); - let mut keys = snapshot.keys.clone(); - let mut positions = snapshot.key_positions.clone(); - keys.remove(&id); - positions.remove(&id); - - let next_selected = if snapshot.selected_key_type == id { - if let Some((index, _)) = snapshot - .custom_tabs - .iter() - .enumerate() - .find(|(_, tab)| tab.id == id) - { - if !custom_tabs.is_empty() { - let pick = if index > 0 { index - 1 } else { 0 }; - custom_tabs[pick].id.clone() - } else { - "8key".to_string() - } - } else { - "8key".to_string() - } - } else { - snapshot.selected_key_type.clone() }; + let runtime_counters = state.snapshot_key_counters(); - state.store.update(|store| { - store.custom_tabs = custom_tabs.clone(); - store.keys = keys.clone(); - store.key_positions = positions.clone(); - store.selected_key_type = next_selected.clone(); + let updated = state.store.update(|store| { + store.key_counters = runtime_counters.clone(); + delete_custom_tab_data(store, &id, &plan); })?; - state.keyboard.update_mappings(keys.clone()); - state.keyboard.set_mode(next_selected.clone()); - state.sync_counters_with_keys(&keys); - state.persist_key_counters()?; + state.unwatch_tab_css(&id); + state + .keyboard + .update_mappings_and_set_mode(updated.keys.clone(), updated.selected_key_type.clone()); + state.commit_key_counters_mirror(updated.key_counters.clone()); app.emit( "customTabs:changed", &CustomTabChangePayload { - custom_tabs: custom_tabs.clone(), - selected_key_type: next_selected.clone(), + custom_tabs: updated.custom_tabs.clone(), + selected_key_type: updated.selected_key_type.clone(), + }, + )?; + app.emit("keys:changed", &updated.keys)?; + app.emit("positions:changed", &updated.key_positions)?; + app.emit("statPositions:changed", &updated.stat_positions)?; + app.emit("graphPositions:changed", &updated.graph_positions)?; + app.emit("knobPositions:changed", &updated.knob_positions)?; + app.emit("layerGroups:changed", &updated.layer_groups)?; + app.emit("tabNote:changed_all", &updated.tab_note_overrides)?; + app.emit( + "tabCss:changed", + &crate::commands::editor::css::TabCssResponse { + tab_id: id, + css: None, }, )?; - app.emit("keys:changed", &keys)?; - app.emit("positions:changed", &positions)?; app.emit( "keys:mode-changed", - &serde_json::json!({ "mode": &next_selected }), + &serde_json::json!({ "mode": &updated.selected_key_type }), )?; - app.emit("keys:counters", &state.snapshot_key_counters())?; + app.emit("keys:counters", &updated.key_counters)?; state.obs_broadcast_counters(); state.refresh_obs_snapshot(); Ok(CustomTabDeleteResult { success: true, - selected: next_selected, + selected: updated.selected_key_type, error: None, }) } @@ -498,9 +606,7 @@ pub fn custom_tabs_select( id: String, ) -> CmdResult { let snapshot = state.store.snapshot(); - let defaults = default_keys(); - let exists = defaults.contains_key(&id) || snapshot.custom_tabs.iter().any(|tab| tab.id == id); - if !exists { + if !is_selectable_mode(&snapshot, &id) { return Ok(CustomTabSelectResult { success: false, selected: snapshot.selected_key_type, @@ -508,16 +614,18 @@ pub fn custom_tabs_select( }); } - state.store.set_selected_key_type(id.clone())?; - state.keyboard.set_mode(id.clone()); - state.transfer_active_keys(&id); + let selected = state.store.set_selected_key_type(id)?; + state.keyboard.set_mode(selected.clone()); - app.emit("keys:mode-changed", &serde_json::json!({ "mode": &id }))?; + app.emit( + "keys:mode-changed", + &serde_json::json!({ "mode": &selected }), + )?; state.refresh_obs_snapshot(); Ok(CustomTabSelectResult { success: true, - selected: id, + selected, error: None, }) } @@ -530,24 +638,23 @@ pub fn custom_tabs_restore( custom_tabs: Vec, selected_key_type: String, ) -> CmdResult<()> { - state.store.update(|store| { + let updated = state.store.update(|store| { store.custom_tabs = custom_tabs.clone(); store.selected_key_type = selected_key_type.clone(); })?; - state.keyboard.set_mode(selected_key_type.clone()); - state.transfer_active_keys(&selected_key_type); + state.keyboard.set_mode(updated.selected_key_type.clone()); app.emit( "customTabs:changed", &CustomTabChangePayload { - custom_tabs, - selected_key_type: selected_key_type.clone(), + custom_tabs: updated.custom_tabs, + selected_key_type: updated.selected_key_type.clone(), }, )?; app.emit( "keys:mode-changed", - &serde_json::json!({ "mode": &selected_key_type }), + &serde_json::json!({ "mode": &updated.selected_key_type }), )?; state.refresh_obs_snapshot(); Ok(()) @@ -555,8 +662,7 @@ pub fn custom_tabs_restore( #[tauri::command] pub fn keys_reset_counters(state: State<'_, AppState>, app: AppHandle) -> CmdResult { - let snapshot = state.reset_key_counters(); - state.persist_key_counters()?; + let snapshot = state.reset_key_counters()?; app.emit("keys:counters", &snapshot)?; state.obs_broadcast_counters(); Ok(snapshot) @@ -568,9 +674,7 @@ pub fn keys_reset_counters_mode( app: AppHandle, mode: String, ) -> CmdResult { - state.reset_mode_counters(&mode); - state.persist_key_counters()?; - let snapshot = state.snapshot_key_counters(); + let snapshot = state.reset_mode_counters(&mode)?; app.emit("keys:counters", &snapshot)?; state.obs_broadcast_counters(); Ok(snapshot) @@ -583,9 +687,7 @@ pub fn keys_reset_single_counter( mode: String, key: String, ) -> CmdResult { - state.reset_single_key_counter(&mode, &key); - state.persist_key_counters()?; - let snapshot = state.snapshot_key_counters(); + let snapshot = state.reset_single_key_counter(&mode, &key)?; app.emit("keys:counters", &snapshot)?; state.obs_broadcast_counters(); Ok(snapshot) @@ -649,3 +751,235 @@ pub fn raw_input_unsubscribe(state: State<'_, AppState>) -> CmdResult AppStoreData { + let position = default_positions() + .values() + .next() + .and_then(|positions| positions.first()) + .cloned() + .expect("default position fixture"); + let mut store = AppStoreData { + custom_tabs: vec![ + CustomTab { + id: "custom-before".to_string(), + name: "Before".to_string(), + }, + CustomTab { + id: TARGET_TAB.to_string(), + name: "Target".to_string(), + }, + ], + selected_key_type: TARGET_TAB.to_string(), + ..AppStoreData::default() + }; + store + .keys + .insert(TARGET_TAB.to_string(), vec!["KeyD".to_string()]); + store + .key_positions + .insert(TARGET_TAB.to_string(), vec![position.clone()]); + store.stat_positions.insert( + TARGET_TAB.to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }], + ); + store.graph_positions.insert( + TARGET_TAB.to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#ffffff".to_string(), + show_avg_line: true, + position: position.clone(), + }], + ); + store.knob_positions.insert( + TARGET_TAB.to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }], + ); + store.layer_groups.insert( + TARGET_TAB.to_string(), + vec![LayerGroupDef { + id: "group".to_string(), + name: "Group".to_string(), + }], + ); + store + .tab_css_overrides + .insert(TARGET_TAB.to_string(), TabCss::default()); + store + .tab_note_overrides + .insert(TARGET_TAB.to_string(), TabNoteSettings::default()); + store.key_counters.insert( + TARGET_TAB.to_string(), + [("KeyD".to_string(), 7)].into_iter().collect(), + ); + store + } + + #[test] + fn deleting_selected_custom_tab_clears_all_tab_scoped_data() { + let mut store = populated_custom_tab_store(); + let plan = plan_custom_tab_delete(&store, TARGET_TAB).expect("delete plan"); + + assert_eq!(plan.next_selected, "custom-before"); + delete_custom_tab_data(&mut store, TARGET_TAB, &plan); + + assert!(!store.custom_tabs.iter().any(|tab| tab.id == TARGET_TAB)); + assert!(!store.keys.contains_key(TARGET_TAB)); + assert!(!store.key_positions.contains_key(TARGET_TAB)); + assert!(!store.stat_positions.contains_key(TARGET_TAB)); + assert!(!store.graph_positions.contains_key(TARGET_TAB)); + assert!(!store.knob_positions.contains_key(TARGET_TAB)); + assert!(!store.layer_groups.contains_key(TARGET_TAB)); + assert!(!store.tab_css_overrides.contains_key(TARGET_TAB)); + assert!(!store.tab_note_overrides.contains_key(TARGET_TAB)); + assert!(!store.key_counters.contains_key(TARGET_TAB)); + assert_eq!(store.selected_key_type, "custom-before"); + } + + #[test] + fn reset_all_clears_knob_positions_and_zeroes_default_counters() { + let mut store = populated_custom_tab_store(); + reset_all_editor_data(&mut store, default_keys(), default_positions()); + + assert!(store.knob_positions.is_empty()); + assert!(store.custom_tabs.is_empty()); + assert_eq!(store.selected_key_type, "4key"); + assert!(store + .key_counters + .values() + .flat_map(|mode| mode.values()) + .all(|count| *count == 0)); + } + + #[test] + fn custom_mode_reset_is_supported_and_preserves_tab_identity() { + let mut store = populated_custom_tab_store(); + let tabs_before = store.custom_tabs.clone(); + let kind = reset_mode_kind(&store, TARGET_TAB); + + assert_eq!(kind, Some(ModeResetKind::Custom)); + reset_mode_data(&mut store, TARGET_TAB, kind.unwrap()); + + assert_eq!(store.custom_tabs, tabs_before); + assert!(store.keys[TARGET_TAB].is_empty()); + assert!(store.key_positions[TARGET_TAB].is_empty()); + assert!(store.stat_positions[TARGET_TAB].is_empty()); + assert!(store.graph_positions[TARGET_TAB].is_empty()); + assert!(store.knob_positions[TARGET_TAB].is_empty()); + assert!(!store.layer_groups.contains_key(TARGET_TAB)); + assert!(!store.tab_css_overrides.contains_key(TARGET_TAB)); + assert!(!store.tab_note_overrides.contains_key(TARGET_TAB)); + assert!(store.key_counters[TARGET_TAB].is_empty()); + } + + #[test] + fn default_mode_reset_clears_knob_positions() { + let mut store = AppStoreData::default(); + store.knob_positions.insert( + "4key".to_string(), + populated_custom_tab_store().knob_positions[TARGET_TAB].clone(), + ); + + reset_mode_data(&mut store, "4key", ModeResetKind::Default); + + assert!(store.knob_positions["4key"].is_empty()); + } + + #[test] + fn ghost_mode_request_leaves_store_keyboard_and_events_unchanged() { + let mut store = AppStoreData { + selected_key_type: "8key".to_string(), + ..AppStoreData::default() + }; + store + .keys + .insert("ghost-mode".to_string(), vec!["KeyA".to_string()]); + let keyboard = KeyboardManager::new(store.keys.clone(), "8key"); + let commit_calls = Cell::new(0); + let emit_calls = Cell::new(0); + + let response = set_mode_with( + &store, + "ghost-mode".to_string(), + |candidate| { + commit_calls.set(commit_calls.get() + 1); + Ok(candidate) + }, + |effective| { + keyboard.set_mode(effective.to_string()); + emit_calls.set(emit_calls.get() + 1); + Ok(()) + }, + ) + .unwrap(); + + assert!(!response.success); + assert_eq!(response.mode, "8key"); + assert_eq!(store.selected_key_type, "8key"); + assert_eq!(keyboard.current_mode(), "8key"); + assert_eq!(commit_calls.get(), 0); + assert_eq!(emit_calls.get(), 0); + } + + #[test] + fn absent_mode_request_remains_a_no_op() { + let store = AppStoreData { + selected_key_type: "8key".to_string(), + ..AppStoreData::default() + }; + let keyboard = KeyboardManager::new(store.keys.clone(), "8key"); + let commit_calls = Cell::new(0); + let emit_calls = Cell::new(0); + + let response = set_mode_with( + &store, + "missing-mode".to_string(), + |candidate| { + commit_calls.set(commit_calls.get() + 1); + Ok(candidate) + }, + |effective| { + keyboard.set_mode(effective.to_string()); + emit_calls.set(emit_calls.get() + 1); + Ok(()) + }, + ) + .unwrap(); + + assert!(!response.success); + assert_eq!(response.mode, "8key"); + assert_eq!(store.selected_key_type, "8key"); + assert_eq!(keyboard.current_mode(), "8key"); + assert_eq!(commit_calls.get(), 0); + assert_eq!(emit_calls.get(), 0); + } +} diff --git a/src-tauri/src/commands/keys/sound.rs b/src-tauri/src/commands/keys/sound.rs index 50ad96bb..d15316a1 100644 --- a/src-tauri/src/commands/keys/sound.rs +++ b/src-tauri/src/commands/keys/sound.rs @@ -11,12 +11,38 @@ use tauri::{Emitter, Manager, State}; use uuid::Uuid; use crate::errors::{CmdResult, CommandError}; -use crate::models::{SoundLibraryEntry, SoundSource}; -use crate::state::AppState; +use crate::models::{AppStoreData, PendingProcessedWavReplacement, SoundLibraryEntry, SoundSource}; +use crate::state::{ + atomic_file::{prepare_atomic_replace, PreparedAtomicReplace}, + store::{ + move_staged_sound_deletions_to_trash, restore_staged_sound_deletions, + stage_sound_files_for_deletion, PROCESSED_WAV_TRANSACTION_LOCK, + }, + AppState, +}; const SUPPORTED_SOUND_EXTENSIONS: [&str; 8] = ["wav", "mp3", "ogg", "flac", "m4a", "aac", "aif", "aiff"]; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SoundReferenceChangeEvent { + Key, + Stat, + Graph, + Knob, +} + +impl SoundReferenceChangeEvent { + fn name(self) -> &'static str { + match self { + Self::Key => "positions:changed", + Self::Stat => "statPositions:changed", + Self::Graph => "graphPositions:changed", + Self::Knob => "knobPositions:changed", + } + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SoundLoadResponse { @@ -152,27 +178,57 @@ pub fn sound_list( state: State<'_, AppState>, ) -> CmdResult> { let sounds_dir = ensure_sounds_dir(&app)?; + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + let recovery_complete = state.store.prepare_sound_listing_while_locked()?; let mut items = Vec::new(); let mut library = state.store.with_state(|s| s.sound_library.clone()); let mut seen_paths = HashSet::new(); let mut library_mutated = false; + let mut scan_complete = recovery_complete; let entries = fs::read_dir(&sounds_dir) .map_err(|e| CommandError::msg(format!("사운드 디렉토리 읽기 실패: {e}")))?; for entry_result in entries { - let Ok(entry) = entry_result else { - continue; + let entry = match entry_result { + Ok(entry) => entry, + Err(error) => { + scan_complete = false; + log::warn!("[Sounds] 사운드 항목 열거 실패: {error}"); + continue; + } }; let path = entry.path(); - if !path.is_file() || !is_supported_sound_file(&path) { + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(error) => { + scan_complete = false; + log::warn!( + "[Sounds] 사운드 항목 형식 확인 실패 ('{}'): {error}", + path.display() + ); + continue; + } + }; + if file_type.is_dir() || !is_supported_sound_file(&path) { continue; } - let Ok(metadata) = entry.metadata() else { - continue; + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(error) => { + scan_complete = false; + log::warn!( + "[Sounds] 사운드 메타데이터 확인 실패 ('{}'): {error}", + path.display() + ); + continue; + } }; + if !metadata.is_file() { + continue; + } let file_name = path .file_name() @@ -216,11 +272,7 @@ pub fn sound_list( }); } - let stale_keys: Vec = library - .keys() - .filter(|key| !seen_paths.contains(*key)) - .cloned() - .collect(); + let stale_keys = stale_sound_library_keys(&library, &seen_paths, scan_complete); if !stale_keys.is_empty() { for key in stale_keys { library.remove(&key); @@ -251,6 +303,21 @@ pub fn sound_list( Ok(items) } +fn stale_sound_library_keys( + library: &std::collections::HashMap, + seen_paths: &HashSet, + scan_complete: bool, +) -> Vec { + if !scan_complete { + return Vec::new(); + } + library + .keys() + .filter(|key| !seen_paths.contains(*key)) + .cloned() + .collect() +} + #[tauri::command] pub fn sound_set_hidden( app: tauri::AppHandle, @@ -293,7 +360,7 @@ fn set_sound_hidden( return Err(CommandError::msg("대상 사운드 파일이 존재하지 않습니다.")); } - let path_key = normalize_path_string(&validated_path); + let path_key = resolve_stored_sound_path_key(state, &validated_path); state.store.update(|store| { store .sound_library @@ -316,7 +383,7 @@ pub fn sound_rename( if !validated_path.exists() { return Err(CommandError::msg("대상 사운드 파일이 존재하지 않습니다.")); } - let path_key = normalize_path_string(&validated_path); + let path_key = resolve_stored_sound_path_key(state.inner(), &validated_path); let trimmed = display_name.trim(); if trimmed.is_empty() { @@ -358,8 +425,14 @@ pub fn sound_delete( sound_path: String, ) -> CmdResult { let sounds_dir = ensure_sounds_dir(&app)?; + let trash_dir = app + .path() + .app_data_dir() + .map_err(|error| CommandError::msg(format!("앱 데이터 경로 확인 실패: {error}")))? + .join("trash"); let validated_path = validate_sound_path(&sounds_dir, &sound_path)?; - let path_key = normalize_path_string(&validated_path); + let path_key = resolve_stored_sound_path_key(state.inner(), &validated_path); + let stored_path = validate_sound_path(&sounds_dir, &path_key)?; // 내장 사운드 삭제 차단 (OBS/플러그인 경유 호출 포함) let is_builtin = state.store.with_state(|s| { @@ -371,71 +444,151 @@ pub fn sound_delete( return Err(CommandError::msg("내장 사운드는 삭제할 수 없습니다.")); } - // 라이브러리에서 원본 경로를 먼저 조회 + // 라이브러리에서 원본 경로 선조회 let original_rel_path = state.store.with_state(|s| { s.sound_library .get(&path_key) .and_then(|entry| entry.original_path.clone()) }); + let original_path = original_rel_path.as_ref().and_then(|orig_rel| { + let original_path = sounds_dir.join(orig_rel); + match validate_sound_path(&sounds_dir, &original_path.to_string_lossy()) { + Ok(original_path) => Some(original_path), + Err(error) => { + log::warn!("[Sound] 잘못된 원본 사운드 경로 무시: {error}"); + None + } + } + }); + + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + let source_paths: Vec = std::iter::once(stored_path) + .chain(original_path.clone()) + .collect(); + let staged = stage_sound_files_for_deletion(&source_paths) + .map_err(|error| CommandError::msg(format!("사운드 파일 삭제 준비 실패: {error:#}")))?; + + let mut references_changed = false; + let updated = commit_staged_sound_deletion(&staged, || { + state + .store + .update(|store| { + references_changed = remove_sound_entry_and_references(store, &path_key); + }) + .map_err(Into::into) + })?; + + state.key_sound_invalidate_file_cache(&path_key); + if let Err(error) = move_staged_sound_deletions_to_trash(&staged, &trash_dir) { + // store 커밋은 끝났고 숨은 삭제 백업은 다음 시작 시 다시 trash로 이동 + log::warn!("[Sound] 삭제 파일 trash 이동 지연: {error:#}"); + } - if validated_path.exists() { - fs::remove_file(&validated_path) - .map_err(|e| CommandError::msg(format!("사운드 파일 삭제 실패: {e}")))?; + if references_changed { + emit_sound_reference_changes_with(|event| match event { + SoundReferenceChangeEvent::Key => app.emit(event.name(), &updated.key_positions), + SoundReferenceChangeEvent::Stat => app.emit(event.name(), &updated.stat_positions), + SoundReferenceChangeEvent::Graph => app.emit(event.name(), &updated.graph_positions), + SoundReferenceChangeEvent::Knob => app.emit(event.name(), &updated.knob_positions), + }); } - // 원본 파일도 삭제 - 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}"); + Ok(SoundDeleteResponse { success: true }) +} + +fn remove_sound_entry_and_references(store: &mut AppStoreData, path_key: &str) -> bool { + store.sound_library.remove(path_key); + let mut references_changed = false; + + for positions in store.key_positions.values_mut() { + for position in positions.iter_mut() { + if position.sound_path.as_deref() == Some(path_key) { + position.sound_path = None; + position.sound_enabled = Some(false); + references_changed = true; } } } - // 라이브러리 엔트리 제거 + 키 참조 정리를 하나의 트랜잭션으로 - let mut references_changed = false; - let updated = state.store.update(|store| { - store.sound_library.remove(&path_key); - - for positions in store.key_positions.values_mut() { - for position in positions.iter_mut() { - if position.sound_path.as_deref() == Some(&path_key) { - position.sound_path = None; - position.sound_enabled = Some(false); - references_changed = true; - } + for positions in store.stat_positions.values_mut() { + for stat_position in positions.iter_mut() { + if stat_position.position.sound_path.as_deref() == Some(path_key) { + stat_position.position.sound_path = None; + stat_position.position.sound_enabled = Some(false); + references_changed = true; } } + } - for positions in store.stat_positions.values_mut() { - for stat_position in positions.iter_mut() { - if stat_position.position.sound_path.as_deref() == Some(&path_key) { - stat_position.position.sound_path = None; - stat_position.position.sound_enabled = Some(false); - references_changed = true; - } + for positions in store.graph_positions.values_mut() { + for graph_position in positions.iter_mut() { + if graph_position.position.sound_path.as_deref() == Some(path_key) { + graph_position.position.sound_path = None; + graph_position.position.sound_enabled = Some(false); + references_changed = true; } } + } - for positions in store.graph_positions.values_mut() { - for graph_position in positions.iter_mut() { - if graph_position.position.sound_path.as_deref() == Some(&path_key) { - graph_position.position.sound_path = None; - graph_position.position.sound_enabled = Some(false); - references_changed = true; - } + 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)?; + references_changed +} + +fn sound_delete_rollback_error( + primary: CommandError, + rollback: anyhow::Result<()>, +) -> CommandError { + match rollback { + Ok(()) => primary, + Err(rollback) => { + CommandError::msg(format!("{primary}; 삭제 준비 파일 원복 실패: {rollback:#}")) + } } +} - Ok(SoundDeleteResponse { success: true }) +fn commit_staged_sound_deletion( + staged: &[crate::state::store::StagedSoundDeletionFile], + commit: Commit, +) -> CmdResult +where + Commit: FnOnce() -> CmdResult, +{ + match commit() { + Ok(value) => Ok(value), + Err(error) => Err(sound_delete_rollback_error( + error, + restore_staged_sound_deletions(staged), + )), + } +} + +fn emit_sound_reference_changes_with(mut emit: Emit) +where + Emit: FnMut(SoundReferenceChangeEvent) -> Result<(), Error>, + Error: std::fmt::Display, +{ + for event in [ + SoundReferenceChangeEvent::Key, + SoundReferenceChangeEvent::Stat, + SoundReferenceChangeEvent::Graph, + SoundReferenceChangeEvent::Knob, + ] { + if let Err(error) = emit(event) { + log::warn!( + "[Sound] 삭제 후 '{}' 이벤트 전송 실패: {error}", + event.name() + ); + } + } } #[tauri::command] @@ -571,7 +724,7 @@ pub fn sound_load_original( ) -> CmdResult { let sounds_dir = ensure_sounds_dir(&app)?; let validated_path = validate_sound_path(&sounds_dir, &sound_path)?; - let path_key = normalize_path_string(&validated_path); + let path_key = resolve_stored_sound_path_key(state.inner(), &validated_path); let original_rel = state .store @@ -582,10 +735,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("원본 파일이 존재하지 않습니다.")); } @@ -634,7 +785,7 @@ pub fn sound_update_processed_wav( ) -> CmdResult { let sounds_dir = ensure_sounds_dir(&app)?; let validated_path = validate_sound_path(&sounds_dir, &request.sound_path)?; - let path_key = normalize_path_string(&validated_path); + let path_key = resolve_stored_sound_path_key(state.inner(), &validated_path); // 내장 사운드 덮어쓰기 차단 (OBS/플러그인 경유 호출 포함) let is_builtin = state.store.with_state(|s| { @@ -660,19 +811,46 @@ pub fn sound_update_processed_wav( }); } - fs::write(&validated_path, wav_bytes) - .map_err(|e| CommandError::msg(format!("편집된 사운드 저장 실패: {e}")))?; - - state.store.update(|s| { - if let Some(entry) = s.sound_library.get_mut(&path_key) { - entry.trim_start_ratio = request.trim_start_ratio; - entry.trim_end_ratio = request.trim_end_ratio; - if let Some(ref name) = request.display_name { - entry.display_name = Some(name.clone()); - } - } + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + state + .store + .recover_interrupted_processed_wav_replacements_while_locked()?; + ensure_existing_sound_edit_target(&validated_path)?; + let pending = PendingProcessedWavReplacement { + sound_path: normalize_path_string(&validated_path), + had_original: validated_path.exists(), + }; + state.store.update(|store| { + store.pending_processed_wav_replacement = Some(pending.clone()); })?; + let replacement_result = replace_processed_wav_with( + &validated_path, + &wav_bytes, + || { + state.store.update(|store| { + if let Some(entry) = store.sound_library.get_mut(&path_key) { + entry.trim_start_ratio = request.trim_start_ratio; + entry.trim_end_ratio = request.trim_end_ratio; + if let Some(ref name) = request.display_name { + entry.display_name = Some(name.clone()); + } + } + store.pending_processed_wav_replacement = None; + })?; + Ok(()) + }, + |path, bytes| prepare_atomic_replace(path, bytes, "processed-wav"), + PreparedAtomicReplace::commit, + |path| fs::remove_file(path), + ); + if let Err(error) = replacement_result { + // 파일 롤백 자체가 실패했을 수 있으므로 복구 표식은 다음 재시도까지 보존 + return Err(CommandError::msg(format!( + "편집된 사운드 저장 실패: {error}" + ))); + } + // 키음 엔진 캐시에서 이전 디코딩 결과 무효화 state.key_sound_invalidate_file_cache(&path_key); @@ -682,19 +860,311 @@ pub fn sound_update_processed_wav( }) } +fn ensure_existing_sound_edit_target(path: &Path) -> CmdResult<()> { + match path.try_exists() { + Ok(true) if path.is_file() => Ok(()), + Ok(true) => Err(CommandError::msg("대상 사운드 경로가 파일이 아닙니다.")), + Ok(false) => Err(CommandError::msg("편집할 사운드 파일을 찾을 수 없습니다.")), + Err(error) => Err(CommandError::msg(format!( + "편집할 사운드 파일 확인 실패: {error}" + ))), + } +} + +fn replace_processed_wav_with( + target_path: &Path, + wav_bytes: &[u8], + save_metadata: Save, + prepare: Prepare, + commit: Commit, + cleanup_backup: Cleanup, +) -> CmdResult<()> +where + Save: FnOnce() -> CmdResult<()>, + Prepare: FnOnce(&Path, &[u8]) -> anyhow::Result, + Commit: FnOnce(PreparedAtomicReplace) -> anyhow::Result<()>, + Cleanup: FnOnce(&Path) -> std::io::Result<()>, +{ + let backup_path = backup_path_for(target_path)?; + restore_interrupted_processed_wav_backup(target_path, &backup_path)?; + + if target_path.exists() && !target_path.is_file() { + return Err(CommandError::msg("대상 사운드 경로가 파일이 아닙니다.")); + } + + if backup_path.exists() { + fs::remove_file(&backup_path)?; + } + + let prepared = prepare(target_path, wav_bytes)?; + let had_original = target_path.exists(); + if had_original { + fs::rename(target_path, &backup_path)?; + } + + if let Err(error) = commit(prepared) { + let rollback_result = + restore_processed_wav(target_path, had_original.then_some(&backup_path)); + return Err(with_rollback_error(error.into(), rollback_result, None)); + } + + if let Err(error) = save_metadata() { + let file_result = restore_processed_wav(target_path, had_original.then_some(&backup_path)); + return Err(with_rollback_error(error, file_result, None)); + } + + if had_original { + if let Err(error) = cleanup_backup(&backup_path) { + // 새 파일과 메타데이터는 이미 함께 커밋됨. 백업은 종료 시 격리 청소 대상 + log::warn!( + "편집된 WAV 백업 정리 지연 ({}): {}", + backup_path.display(), + error + ); + } + } + + Ok(()) +} + +fn restore_interrupted_processed_wav_backup( + target_path: &Path, + backup_path: &Path, +) -> CmdResult<()> { + restore_interrupted_processed_wav_backup_with(target_path, backup_path, |from, to| { + fs::rename(from, to) + }) +} + +fn restore_interrupted_processed_wav_backup_with( + target_path: &Path, + backup_path: &Path, + rename: Rename, +) -> CmdResult<()> +where + Rename: FnOnce(&Path, &Path) -> std::io::Result<()>, +{ + if !target_path.exists() && backup_path.exists() { + rename(backup_path, target_path).map_err(|error| { + CommandError::msg(format!( + "중단된 WAV 백업 복구 실패 ('{}' → '{}'): {error}", + backup_path.display(), + target_path.display() + )) + })?; + } + + Ok(()) +} + +fn backup_path_for(path: &Path) -> CmdResult { + let mut file_name = path + .file_name() + .ok_or_else(|| CommandError::msg("사운드 파일명이 없습니다."))? + .to_os_string(); + file_name.push(".bak"); + Ok(path.with_file_name(file_name)) +} + +fn restore_processed_wav(target_path: &Path, backup_path: Option<&Path>) -> std::io::Result<()> { + match backup_path { + Some(backup_path) => { + if !target_path.exists() { + return fs::rename(backup_path, target_path); + } + + let rollback_path = rollback_path_for(target_path); + fs::rename(target_path, &rollback_path)?; + if let Err(error) = fs::rename(backup_path, target_path) { + return match fs::rename(&rollback_path, target_path) { + Ok(()) => Err(error), + Err(recovery_error) => Err(std::io::Error::other(format!( + "{error}; 새 WAV 재배치 실패: {recovery_error}" + ))), + }; + } + fs::remove_file(rollback_path) + } + None if target_path.exists() => { + let rollback_path = rollback_path_for(target_path); + fs::rename(target_path, &rollback_path)?; + fs::remove_file(rollback_path) + } + None => Ok(()), + } +} + +fn rollback_path_for(path: &Path) -> PathBuf { + let mut file_name = path.file_name().unwrap_or_default().to_os_string(); + file_name.push(format!(".rollback-{}", Uuid::new_v4())); + path.with_file_name(file_name) +} + +fn with_rollback_error( + primary: CommandError, + file_result: std::io::Result<()>, + metadata_result: Option>, +) -> CommandError { + let mut failures = Vec::new(); + if let Err(error) = file_result { + failures.push(format!("WAV 원복 실패: {error}")); + } + if let Some(Err(error)) = metadata_result { + failures.push(format!("메타데이터 원복 실패: {error}")); + } + + if failures.is_empty() { + primary + } else { + CommandError::msg(format!("{primary}; {}", failures.join("; "))) + } +} + +fn resolve_stored_sound_path_key(state: &AppState, validated_path: &Path) -> String { + let mut stored_keys = state + .store + .with_state(|store| store.sound_library.keys().cloned().collect::>()); + stored_keys.sort_unstable(); + resolve_sound_path_key_from_keys(validated_path, &stored_keys) +} + +fn resolve_sound_path_key_from_keys(validated_path: &Path, stored_keys: &[String]) -> String { + let input_key = normalize_path_string(validated_path); + if stored_keys + .iter() + .any(|stored_key| stored_key == &input_key) + { + return input_key; + } + + let Ok(canonical_input) = canonicalize_sound_path(validated_path) else { + return input_key; + }; + + stored_keys + .iter() + .find(|stored_key| { + canonicalize_sound_path(Path::new(stored_key)).is_ok_and(|canonical_stored| { + canonical_paths_equivalent(&canonical_input, &canonical_stored) + }) + }) + .cloned() + .unwrap_or(input_key) +} + +fn canonical_paths_equivalent(left: &Path, right: &Path) -> bool { + #[cfg(windows)] + { + windows_canonical_path_key(left) == windows_canonical_path_key(right) + } + #[cfg(not(windows))] + { + left == right + } +} + +#[cfg(windows)] +fn windows_canonical_path_key(path: &Path) -> String { + let normalized = path + .to_string_lossy() + .replace('/', "\\") + .to_ascii_lowercase(); + if let Some(rest) = normalized.strip_prefix("\\\\?\\unc\\") { + format!("\\\\{rest}") + } else { + normalized + .strip_prefix("\\\\?\\") + .unwrap_or(&normalized) + .to_string() + } +} + +fn canonicalize_sound_path(path: &Path) -> CmdResult { + match fs::canonicalize(path) { + Ok(path) => Ok(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}")) + })?; + Ok(canonical_parent.join(file_name)) + } + Err(error) => Err(CommandError::msg(format!("사운드 경로 확인 실패: {error}"))), + } +} + fn validate_sound_path(sounds_dir: &Path, sound_path: &str) -> CmdResult { let path = PathBuf::from(sound_path); if !path.is_absolute() { return Err(CommandError::msg("절대 경로만 허용됩니다.")); } - if !path.starts_with(sounds_dir) { + if contains_relative_path_component(sound_path) || contains_duplicate_path_separator(sound_path) + { + return Err(CommandError::msg( + "'.', '..' 또는 중복 경로 구분자는 허용되지 않습니다.", + )); + } + + let canonical_sounds_dir = fs::canonicalize(sounds_dir) + .map_err(|error| CommandError::msg(format!("사운드 디렉토리 확인 실패: {error}")))?; + let canonical_path = canonicalize_sound_path(&path)?; + + if !canonical_path.starts_with(&canonical_sounds_dir) { return Err(CommandError::msg( "appData/sounds 외부 경로에는 접근할 수 없습니다.", )); } + // canonical은 경계 검사 전용 — 반환은 store 키와 일치하는 원 경로 + // (Windows에서 canonicalize가 \\?\ verbatim 경로를 반환해 조회 키를 오염시키는 것 방지) Ok(path) } +fn contains_relative_path_component(path: &str) -> bool { + path.split(std::path::is_separator) + .any(|component| component == "." || component == "..") +} + +fn contains_duplicate_path_separator(path: &str) -> bool { + #[cfg(windows)] + let prefix_body = path + .strip_prefix("\\\\?\\") + .or_else(|| path.strip_prefix("\\\\")) + .or_else(|| path.strip_prefix("//")); + #[cfg(windows)] + let path = match prefix_body { + Some(body) if body.chars().next().is_some_and(std::path::is_separator) => return true, + Some(body) => body, + None => path, + }; + + let mut previous_was_separator = false; + for character in path.chars() { + let is_separator = std::path::is_separator(character); + if is_separator && previous_was_separator { + return true; + } + previous_was_separator = is_separator; + } + false +} + fn normalize_path_string(path: &Path) -> String { path.to_string_lossy().to_string() } @@ -726,3 +1196,703 @@ fn is_supported_sound_file(path: &Path) -> bool { .iter() .any(|allowed| ext.eq_ignore_ascii_case(allowed)) } + +#[cfg(test)] +mod tests { + use super::{ + backup_path_for, commit_staged_sound_deletion, contains_duplicate_path_separator, + emit_sound_reference_changes_with, ensure_existing_sound_edit_target, + remove_sound_entry_and_references, replace_processed_wav_with, + resolve_sound_path_key_from_keys, restore_interrupted_processed_wav_backup_with, + stale_sound_library_keys, validate_sound_path, PreparedAtomicReplace, + SoundReferenceChangeEvent, + }; + use crate::{ + defaults::default_positions, + errors::{CmdResult, CommandError}, + models::AppStoreData, + state::{ + atomic_file::prepare_atomic_replace, + store::{ + move_staged_sound_deletions_to_trash, stage_sound_files_for_deletion, + PROCESSED_WAV_TRANSACTION_LOCK, + }, + }, + }; + use std::{ + cell::{Cell, RefCell}, + path::Path, + sync::mpsc, + thread, + }; + + fn wav_test_path(label: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let root = std::env::temp_dir().join(format!( + "dmnote-processed-wav-{label}-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("sound.wav"); + std::fs::write(&path, b"old-wav").unwrap(); + (root, path) + } + + fn assert_wav_rollback(path: &Path, metadata: &RefCell<&'static str>) { + assert_eq!(std::fs::read(path).unwrap(), b"old-wav"); + assert_eq!(*metadata.borrow(), "old-metadata"); + assert!(!backup_path_for(path).unwrap().exists()); + assert!(!std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .any(|entry| { + entry + .ok() + .and_then(|entry| entry.file_name().into_string().ok()) + .is_some_and(|name| name.ends_with(".tmp") || name.contains(".rollback-")) + })); + } + + fn sound_delete_data(path_key: &str) -> AppStoreData { + let mut data = AppStoreData { + key_positions: default_positions().clone(), + ..Default::default() + }; + data.sound_library + .insert(path_key.to_string(), Default::default()); + let position = data + .key_positions + .get_mut("4key") + .unwrap() + .first_mut() + .unwrap(); + position.sound_path = Some(path_key.to_string()); + position.sound_enabled = Some(true); + data + } + + #[test] + fn incomplete_sound_scan_never_prunes_library_metadata() { + let library = std::collections::HashMap::from([ + ("/sounds/seen.wav".to_string(), Default::default()), + ("/sounds/unreadable.wav".to_string(), Default::default()), + ]); + let seen = std::collections::HashSet::from(["/sounds/seen.wav".to_string()]); + + assert!(stale_sound_library_keys(&library, &seen, false).is_empty()); + assert_eq!( + stale_sound_library_keys(&library, &seen, true), + vec!["/sounds/unreadable.wav".to_string()] + ); + } + + #[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 + ); + + #[cfg(windows)] + { + let verbatim_existing = format!("\\\\?\\{}", existing.display()); + assert_eq!( + validate_sound_path(&sounds_dir, &verbatim_existing).unwrap(), + std::path::PathBuf::from(verbatim_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); + } + + #[test] + fn sound_path_validation_rejects_relative_alias_components() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-alias-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(sounds_dir.join("nested")).unwrap(); + let existing = sounds_dir.join("existing.wav"); + std::fs::write(&existing, b"sound").unwrap(); + + let current_dir_alias = format!("{}/./existing.wav", sounds_dir.display()); + let parent_dir_alias = format!("{}/nested/../existing.wav", sounds_dir.display()); + + for alias in [current_dir_alias, parent_dir_alias] { + let error = validate_sound_path(&sounds_dir, &alias) + .unwrap_err() + .to_string(); + assert_eq!( + error, + "'.', '..' 또는 중복 경로 구분자는 허용되지 않습니다." + ); + } + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn sound_path_validation_rejects_duplicate_separators() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-duplicate-separator-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(); + + let separator = std::path::MAIN_SEPARATOR; + let duplicate_separator_alias = + format!("{}{separator}{separator}existing.wav", sounds_dir.display()); + #[cfg(windows)] + let aliases = [duplicate_separator_alias]; + #[cfg(not(windows))] + let aliases = [ + duplicate_separator_alias, + format!("/{}", existing.display()), + ]; + + assert!(aliases + .iter() + .all(|alias| contains_duplicate_path_separator(alias))); + + for alias in aliases { + let error = validate_sound_path(&sounds_dir, &alias) + .unwrap_err() + .to_string(); + assert_eq!( + error, + "'.', '..' 또는 중복 경로 구분자는 허용되지 않습니다." + ); + } + + assert_eq!( + validate_sound_path(&sounds_dir, &existing.to_string_lossy()).unwrap(), + existing + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn sound_path_key_resolver_uses_canonical_match_for_reference_removal() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-canonical-match-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let stored_path = sounds_dir.join("stored.wav"); + let alias_path = sounds_dir.join("alias.wav"); + std::fs::write(&stored_path, b"sound").unwrap(); + symlink(&stored_path, &alias_path).unwrap(); + + let stored_key = stored_path.to_string_lossy().to_string(); + let validated_alias = + validate_sound_path(&sounds_dir, &alias_path.to_string_lossy()).unwrap(); + let resolved_key = + resolve_sound_path_key_from_keys(&validated_alias, std::slice::from_ref(&stored_key)); + let mut data = sound_delete_data(&stored_key); + + assert_eq!(resolved_key, stored_key); + assert!(remove_sound_entry_and_references(&mut data, &resolved_key)); + assert!(!data.sound_library.contains_key(&stored_key)); + assert_eq!(data.key_positions["4key"][0].sound_path, None); + assert_eq!(data.key_positions["4key"][0].sound_enabled, Some(false)); + + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn sound_path_key_resolver_matches_missing_file_via_canonical_parent() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-missing-canonical-match-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + let stored_parent = sounds_dir.join("stored-parent"); + let alias_parent = sounds_dir.join("alias-parent"); + std::fs::create_dir_all(&stored_parent).unwrap(); + symlink(&stored_parent, &alias_parent).unwrap(); + let stored_path = stored_parent.join("missing.wav"); + let alias_path = alias_parent.join("missing.wav"); + + let stored_key = stored_path.to_string_lossy().to_string(); + let validated_alias = + validate_sound_path(&sounds_dir, &alias_path.to_string_lossy()).unwrap(); + let resolved_key = + resolve_sound_path_key_from_keys(&validated_alias, std::slice::from_ref(&stored_key)); + + assert_eq!(resolved_key, stored_key); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn sound_path_key_resolver_preserves_unmatched_input_behavior() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-no-canonical-match-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let stored_path = sounds_dir.join("stored.wav"); + let unmatched_path = sounds_dir.join("unmatched.wav"); + std::fs::write(&stored_path, b"stored").unwrap(); + std::fs::write(&unmatched_path, b"unmatched").unwrap(); + + let stored_key = stored_path.to_string_lossy().to_string(); + let unmatched_key = unmatched_path.to_string_lossy().to_string(); + let validated_unmatched = + validate_sound_path(&sounds_dir, &unmatched_path.to_string_lossy()).unwrap(); + let resolved_key = resolve_sound_path_key_from_keys( + &validated_unmatched, + std::slice::from_ref(&stored_key), + ); + let mut data = sound_delete_data(&stored_key); + + assert_eq!(resolved_key, unmatched_key); + assert!(!remove_sound_entry_and_references(&mut data, &resolved_key)); + assert!(data.sound_library.contains_key(&stored_key)); + assert_eq!( + data.key_positions["4key"][0].sound_path.as_deref(), + Some(stored_key.as_str()) + ); + assert_eq!(data.key_positions["4key"][0].sound_enabled, Some(true)); + + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn sound_path_key_resolver_matches_case_and_separator_aliases() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-windows-alias-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let stored_path = sounds_dir.join("stored.wav"); + std::fs::write(&stored_path, b"sound").unwrap(); + + let stored_key = stored_path.to_string_lossy().to_string(); + let alias = stored_key.replace('\\', "/").to_ascii_uppercase(); + let validated_alias = validate_sound_path(&sounds_dir, &alias).unwrap(); + let resolved_key = + resolve_sound_path_key_from_keys(&validated_alias, std::slice::from_ref(&stored_key)); + + assert_eq!(resolved_key, stored_key); + + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn duplicate_separator_check_preserves_windows_prefixes() { + assert!(!contains_duplicate_path_separator(r"\\server\share\x.wav")); + assert!(!contains_duplicate_path_separator(r"\\?\C:\sounds\x.wav")); + assert!(contains_duplicate_path_separator(r"C:\sounds\\x.wav")); + assert!(contains_duplicate_path_separator(r"C:\sounds/\x.wav")); + assert!(contains_duplicate_path_separator(r"\\server\share\\x.wav")); + assert!(contains_duplicate_path_separator(r"\\?\C:\sounds\\x.wav")); + assert!(contains_duplicate_path_separator(r"\\\server\share\x.wav")); + assert!(contains_duplicate_path_separator(r"///server/share/x.wav")); + } + + #[test] + fn sound_delete_store_failure_keeps_files_and_references() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-delete-store-failure-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let processed_path = root.join("sound.wav"); + let original_path = root.join("original.wav"); + std::fs::write(&processed_path, b"processed").unwrap(); + std::fs::write(&original_path, b"original").unwrap(); + let path_key = processed_path.to_string_lossy().to_string(); + let data = RefCell::new(sound_delete_data(&path_key)); + let cache_invalidated = Cell::new(false); + + let staged = + stage_sound_files_for_deletion(&[processed_path.clone(), original_path.clone()]) + .unwrap(); + let result: CmdResult<()> = commit_staged_sound_deletion(&staged, || { + let mut scratch = data.borrow().clone(); + remove_sound_entry_and_references(&mut scratch, &path_key); + Err(CommandError::msg("injected store failure")) + }); + + assert!(result.is_err()); + assert!(processed_path.exists()); + assert!(original_path.exists()); + assert!(!cache_invalidated.get()); + assert!(data.borrow().sound_library.contains_key(&path_key)); + let position = &data.borrow().key_positions["4key"][0]; + assert_eq!(position.sound_path.as_deref(), Some(path_key.as_str())); + assert_eq!(position.sound_enabled, Some(true)); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn sound_delete_stages_files_before_store_and_moves_them_to_trash_after_commit() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-delete-success-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let processed_path = root.join("sound.wav"); + let original_path = root.join("original.wav"); + std::fs::write(&processed_path, b"processed").unwrap(); + std::fs::write(&original_path, b"original").unwrap(); + let path_key = processed_path.to_string_lossy().to_string(); + let data = RefCell::new(sound_delete_data(&path_key)); + let events = RefCell::new(Vec::new()); + let trash_dir = root.join("trash"); + let staged = + stage_sound_files_for_deletion(&[processed_path.clone(), original_path.clone()]) + .unwrap(); + assert!(!processed_path.exists()); + assert!(!original_path.exists()); + + commit_staged_sound_deletion(&staged, || { + let mut scratch = data.borrow().clone(); + remove_sound_entry_and_references(&mut scratch, &path_key); + *data.borrow_mut() = scratch; + events.borrow_mut().push("store"); + Ok(()) + }) + .unwrap(); + events.borrow_mut().push("cache"); + move_staged_sound_deletions_to_trash(&staged, &trash_dir).unwrap(); + events.borrow_mut().push("trash"); + + assert_eq!(*events.borrow(), ["store", "cache", "trash"]); + assert!(!processed_path.exists()); + assert!(!original_path.exists()); + let quarantined: Vec<_> = std::fs::read_dir(&trash_dir) + .unwrap() + .flat_map(|session| std::fs::read_dir(session.unwrap().path()).unwrap()) + .flat_map(|category| std::fs::read_dir(category.unwrap().path()).unwrap()) + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert!(quarantined.contains(&"sound.wav".into())); + assert!(quarantined.contains(&"original.wav".into())); + let position = &data.borrow().key_positions["4key"][0]; + assert_eq!(position.sound_path, None); + assert_eq!(position.sound_enabled, Some(false)); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn sound_delete_event_failure_does_not_stop_remaining_notifications() { + let attempted = RefCell::new(Vec::new()); + + emit_sound_reference_changes_with(|event| { + attempted.borrow_mut().push(event.name()); + if event == SoundReferenceChangeEvent::Key { + Err("injected emit failure") + } else { + Ok(()) + } + }); + + assert_eq!( + *attempted.borrow(), + [ + "positions:changed", + "statPositions:changed", + "graphPositions:changed", + "knobPositions:changed", + ] + ); + } + + #[test] + fn processed_wav_temp_failure_keeps_file_and_metadata() { + let (root, path) = wav_test_path("temp-failure"); + let metadata = RefCell::new("old-metadata"); + + let result = replace_processed_wav_with( + &path, + b"new-wav", + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |_, _| Err(anyhow::anyhow!("injected temp failure")), + PreparedAtomicReplace::commit, + |path| std::fs::remove_file(path), + ); + + assert!(result.is_err()); + assert_wav_rollback(&path, &metadata); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn deleted_sound_cannot_be_recreated_by_a_waiting_edit() { + let root = std::env::temp_dir().join(format!( + "dmnote-processed-wav-deleted-before-edit-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("deleted.wav"); + std::fs::write(&path, b"old-wav").unwrap(); + let delete_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + let edit_path = path.clone(); + let (waiting_tx, waiting_rx) = mpsc::channel(); + let edit = thread::spawn(move || { + waiting_tx.send(()).unwrap(); + let _edit_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + ensure_existing_sound_edit_target(&edit_path) + }); + waiting_rx.recv().unwrap(); + + std::fs::remove_file(&path).unwrap(); + drop(delete_guard); + + let error = edit.join().unwrap().unwrap_err().to_string(); + + assert!(error.contains("찾을 수 없습니다")); + assert!(!path.exists()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn processed_wav_rename_failure_keeps_file_and_metadata() { + let (root, path) = wav_test_path("rename-failure"); + let metadata = RefCell::new("old-metadata"); + + let result = replace_processed_wav_with( + &path, + b"new-wav", + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |path, bytes| prepare_atomic_replace(path, bytes, "rename-failure"), + |_| Err(anyhow::anyhow!("injected rename failure")), + |path| std::fs::remove_file(path), + ); + + assert!(result.is_err()); + assert_wav_rollback(&path, &metadata); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn processed_wav_store_failure_restores_file_and_metadata() { + let (root, path) = wav_test_path("store-failure"); + let metadata = RefCell::new("old-metadata"); + + let result = replace_processed_wav_with( + &path, + b"new-wav", + || -> CmdResult<()> { Err(CommandError::msg("injected store failure")) }, + |path, bytes| prepare_atomic_replace(path, bytes, "store-failure"), + PreparedAtomicReplace::commit, + |path| std::fs::remove_file(path), + ); + + assert!(result.is_err()); + assert_wav_rollback(&path, &metadata); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn processed_wav_success_commits_and_removes_backup() { + let (root, path) = wav_test_path("success"); + let metadata = RefCell::new("old-metadata"); + + replace_processed_wav_with( + &path, + b"new-wav", + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |path, bytes| prepare_atomic_replace(path, bytes, "success"), + PreparedAtomicReplace::commit, + |path| std::fs::remove_file(path), + ) + .unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"new-wav"); + assert_eq!(*metadata.borrow(), "new-metadata"); + assert!(!backup_path_for(&path).unwrap().exists()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn processed_wav_backup_cleanup_failure_keeps_committed_file_and_metadata() { + let (root, path) = wav_test_path("cleanup-failure"); + let metadata = RefCell::new("old-metadata"); + + replace_processed_wav_with( + &path, + b"new-wav", + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |path, bytes| prepare_atomic_replace(path, bytes, "cleanup-failure"), + PreparedAtomicReplace::commit, + |_| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected cleanup failure", + )) + }, + ) + .unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"new-wav"); + assert_eq!(*metadata.borrow(), "new-metadata"); + assert_eq!( + std::fs::read(backup_path_for(&path).unwrap()).unwrap(), + b"old-wav" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn processed_wav_replacement_recovers_backup_before_retrying() { + let root = std::env::temp_dir().join(format!( + "dmnote-processed-wav-crash-retry-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("sound.wav"); + let backup_path = backup_path_for(&path).unwrap(); + let crashed_temp_path = root.join(format!( + ".sound.wav.processed-wav-{}.tmp", + uuid::Uuid::new_v4() + )); + std::fs::write(&backup_path, b"old-wav").unwrap(); + std::fs::write(&crashed_temp_path, b"crashed-new-wav").unwrap(); + let metadata = RefCell::new("old-metadata"); + + replace_processed_wav_with( + &path, + b"retried-new-wav", + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |target_path, bytes| { + assert_eq!(std::fs::read(target_path).unwrap(), b"old-wav"); + prepare_atomic_replace(target_path, bytes, "crash-retry") + }, + PreparedAtomicReplace::commit, + |path| std::fs::remove_file(path), + ) + .unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"retried-new-wav"); + assert_eq!(*metadata.borrow(), "new-metadata"); + assert!(!backup_path.exists()); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn processed_wav_backup_recovery_error_includes_operation_and_paths() { + let root = std::env::temp_dir().join(format!( + "dmnote-processed-wav-recovery-error-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let target_path = root.join("sound.wav"); + let backup_path = backup_path_for(&target_path).unwrap(); + std::fs::write(&backup_path, b"old-wav").unwrap(); + + let error = + restore_interrupted_processed_wav_backup_with(&target_path, &backup_path, |_, _| { + Err(std::io::Error::other("injected recovery failure")) + }) + .unwrap_err() + .to_string(); + + assert!(error.contains("중단된 WAV 백업 복구 실패")); + assert!(error.contains(&backup_path.display().to_string())); + assert!(error.contains(&target_path.display().to_string())); + assert!(error.contains("injected recovery failure")); + + let _ = std::fs::remove_dir_all(root); + } + + // 단독 실행: cargo test --lib commands::keys::sound::tests::processed_wav_atomic_write_survives_file_size_limit -- --ignored --exact + #[cfg(unix)] + #[test] + #[ignore = "RLIMIT_FSIZE는 프로세스 전역이므로 단독 실행"] + fn processed_wav_atomic_write_survives_file_size_limit() { + use crate::state::atomic_file::test_support::FileSizeLimit; + + let (root, path) = wav_test_path("rlimit"); + let metadata = RefCell::new("old-metadata"); + + { + let _limit = FileSizeLimit::set(1_024); + let oversized = vec![b'w'; 4_096]; + let result = replace_processed_wav_with( + &path, + &oversized, + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |path, bytes| prepare_atomic_replace(path, bytes, "rlimit"), + PreparedAtomicReplace::commit, + |path| std::fs::remove_file(path), + ); + assert!(result.is_err()); + assert_wav_rollback(&path, &metadata); + } + + 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..c781b37b 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,12 +10,15 @@ 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, + CustomCssPatch, CustomJsPatch, FontSettings, FontType, GraphPositions, KeyMappings, + KeyPositions, KnobPositions, NoteSettingsPatch, SettingsPatchInput, StatPositions, + TabCssOverrides, }, + services::settings::apply_patch_to_store, state::AppState, }; @@ -50,8 +57,11 @@ pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult CmdResult<()> { + let tab_ids: BTreeSet = previous.keys().chain(current.keys()).cloned().collect(); + + for tab_id in tab_ids { + 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>, @@ -355,9 +504,69 @@ fn choose_tab_preset_source_tab( Err(CommandError::msg("tab-preset-ambiguous-source")) } +fn merge_tab_preset_fonts( + existing_font_settings: &FontSettings, + mut imported_font_settings: FontSettings, + restore_fonts: impl FnOnce(&mut FontSettings) -> CmdResult<()>, +) -> CmdResult> { + let mut existing_names: HashSet = existing_font_settings + .custom_fonts + .iter() + .map(|font| font.name.clone()) + .collect(); + + // 같은 이름은 기존 정의 유지 — 수용한 이름도 반영해 프리셋 내부 중복 방어 + imported_font_settings + .custom_fonts + .retain(|font| existing_names.insert(font.name.clone())); + if imported_font_settings.custom_fonts.is_empty() { + return Ok(None); + } + + // 이름 필터 후 파일 복원 — 제외할 로컬 폰트의 고아 파일 생성 방지 + restore_fonts(&mut imported_font_settings)?; + + let mut existing_ids: HashSet = existing_font_settings + .custom_fonts + .iter() + .map(|font| font.id.clone()) + .collect(); + for font in imported_font_settings.custom_fonts.iter_mut() { + if existing_ids.contains(&font.id) { + font.id = Uuid::new_v4().to_string(); + } + existing_ids.insert(font.id.clone()); + } + + let mut merged = existing_font_settings.clone(); + merged + .custom_fonts + .extend(imported_font_settings.custom_fonts); + Ok(Some(merged)) +} + fn restore_preset_local_fonts( app: &AppHandle, - font_settings: &mut crate::models::FontSettings, + font_settings: &mut FontSettings, + embedded_local_fonts: Option<&[EmbeddedLocalFont]>, +) -> CmdResult<()> { + let has_local_fonts = font_settings + .custom_fonts + .iter() + .any(|font| font.font_type == FontType::Local); + if !has_local_fonts { + return Ok(()); + } + + let app_data_dir = app.path().app_data_dir()?; + let fonts_dir = app_data_dir.join("fonts"); + + restore_preset_local_fonts_in_dir(&fonts_dir, font_settings, embedded_local_fonts) +} + +fn restore_preset_local_fonts_in_dir( + fonts_dir: &Path, + font_settings: &mut FontSettings, embedded_local_fonts: Option<&[EmbeddedLocalFont]>, ) -> CmdResult<()> { let has_local_fonts = font_settings @@ -374,9 +583,7 @@ fn restore_preset_local_fonts( .map(|font| (font.font_id.as_str(), font)) .collect(); - let app_data_dir = app.path().app_data_dir()?; - let fonts_dir = app_data_dir.join("fonts"); - fs::create_dir_all(&fonts_dir)?; + fs::create_dir_all(fonts_dir)?; for font in font_settings.custom_fonts.iter_mut() { if font.font_type != FontType::Local { @@ -645,6 +852,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 +867,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 +922,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 +933,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 +941,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 +1052,146 @@ fn choose_selected_key_type( } "4key".to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + defaults::default_positions, + models::{CustomFont, KnobPosition}, + }; + + #[test] + fn tab_preset_duplicate_font_does_not_create_embedded_file() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-tab-preset-font-load-test-{}", + uuid::Uuid::new_v4() + )); + let fonts_dir = temp_dir.join("fonts"); + std::fs::create_dir_all(&fonts_dir).unwrap(); + let existing_path = fonts_dir.join("existing.ttf"); + std::fs::write(&existing_path, b"existing-font").unwrap(); + + let existing_fonts = FontSettings { + custom_fonts: vec![CustomFont { + id: "existing-id".to_string(), + font_type: FontType::Local, + name: "SharedFont".to_string(), + display_name: "Existing Font".to_string(), + enabled: true, + local_path: Some(existing_path.to_string_lossy().to_string()), + css_content: None, + }], + }; + let imported_font_id = "imported-id".to_string(); + let imported_fonts = FontSettings { + custom_fonts: vec![CustomFont { + id: imported_font_id.clone(), + font_type: FontType::Local, + name: "SharedFont".to_string(), + display_name: "Imported Font".to_string(), + enabled: true, + local_path: None, + css_content: None, + }], + }; + let embedded_fonts = vec![EmbeddedLocalFont { + font_id: imported_font_id, + extension: Some("ttf".to_string()), + data_base64: BASE64_STANDARD.encode(b"imported-font"), + }]; + let file_count_before = std::fs::read_dir(&fonts_dir).unwrap().count(); + + let merged = merge_tab_preset_fonts(&existing_fonts, imported_fonts, |filtered_fonts| { + restore_preset_local_fonts_in_dir(&fonts_dir, filtered_fonts, Some(&embedded_fonts)) + }) + .unwrap(); + + assert!(merged.is_none()); + assert_eq!( + std::fs::read_dir(&fonts_dir).unwrap().count(), + file_count_before + ); + let _ = std::fs::remove_dir_all(temp_dir); + } + + #[test] + fn legacy_percent_encoded_file_url_is_copied_on_import() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-image-url-load-test-{}", + uuid::Uuid::new_v4() + )); + let source_dir = temp_dir.join("source folder"); + let images_dir = temp_dir.join("restored-images"); + std::fs::create_dir_all(&source_dir).unwrap(); + std::fs::create_dir_all(&images_dir).unwrap(); + let source_path = source_dir.join("image with space.png"); + std::fs::write(&source_path, b"legacy-image").unwrap(); + let mut image_ref = Some(url::Url::from_file_path(&source_path).unwrap().to_string()); + + restore_position_image_reference( + &images_dir, + &HashMap::new(), + &mut HashMap::new(), + &mut image_ref, + ) + .unwrap(); + + let restored_path = Path::new(image_ref.as_deref().unwrap()); + assert!(restored_path.starts_with(&images_dir)); + assert_eq!(std::fs::read(restored_path).unwrap(), b"legacy-image"); + let _ = std::fs::remove_dir_all(temp_dir); + } + + #[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..c189a66e 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -8,8 +8,9 @@ use serde::{Deserialize, Serialize}; use crate::models::{ CustomCss, CustomJs, CustomTab, FontSettings, GraphPositions, KeyMappings, KeyPositions, - KnobPositions, NoteSettings, StatPositions, TabNoteOverrides, + KnobPositions, LayerGroups, NoteSettings, StatPositions, TabCssOverrides, TabNoteOverrides, }; +use crate::state::local_asset_path::{file_url_to_path, FileUrlPath}; #[derive(Serialize)] pub struct PresetOperationResult { @@ -58,6 +59,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>, @@ -181,18 +186,10 @@ pub(crate) fn local_source_path_from_image_ref(value: &str) -> Option { return None; } - if let Some(stripped) = trimmed - .strip_prefix("file:///") - .or_else(|| trimmed.strip_prefix("file://")) - { - let mut candidate = stripped.to_string(); - if cfg!(target_os = "windows") { - if candidate.starts_with('/') && candidate.as_bytes().get(2) == Some(&b':') { - candidate = candidate[1..].to_string(); - } - candidate = candidate.replace('/', "\\"); - } - return Some(PathBuf::from(candidate)); + match file_url_to_path(trimmed) { + FileUrlPath::Path(path) => return Some(path), + FileUrlPath::Invalid => return legacy_file_url_path(trimmed), + FileUrlPath::NotFileUrl => {} } let path = PathBuf::from(trimmed); @@ -203,6 +200,27 @@ pub(crate) fn local_source_path_from_image_ref(value: &str) -> Option { } } +fn legacy_file_url_path(value: &str) -> Option { + let scheme_end = value.find(':')?; + if !value[..scheme_end].eq_ignore_ascii_case("file") { + return None; + } + let remainder = &value[scheme_end + 1..]; + let stripped = remainder + .strip_prefix("///") + .or_else(|| remainder.strip_prefix("//"))?; + let mut candidate = stripped.to_string(); + if cfg!(target_os = "windows") { + if candidate.starts_with('/') && candidate.as_bytes().get(2) == Some(&b':') { + candidate = candidate[1..].to_string(); + } + candidate = candidate.replace('/', "\\"); + } else if remainder.starts_with("///") { + candidate.insert(0, '/'); + } + Some(PathBuf::from(candidate)) +} + pub(crate) fn is_remote_or_virtual_image_ref(value: &str) -> bool { let lower = value.trim().to_ascii_lowercase(); lower.starts_with("http://") @@ -218,3 +236,110 @@ 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::{local_source_path_from_image_ref, PresetFile}; + use crate::models::NoteColor; + 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()); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn file_url_image_source_preserves_its_absolute_path() { + assert_eq!( + local_source_path_from_image_ref("file:///tmp/dmnote-image.png"), + Some(std::path::PathBuf::from("/tmp/dmnote-image.png")) + ); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn file_url_image_source_decodes_percent_sequences_only() { + assert_eq!( + local_source_path_from_image_ref( + "file:///tmp/Application%20Support/%ED%95%9C%EA%B8%80%25+a.png" + ), + Some(std::path::PathBuf::from( + "/tmp/Application Support/한글%+a.png" + )) + ); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn invalid_file_url_uses_legacy_literal_path_for_preset_compatibility() { + assert_eq!( + local_source_path_from_image_ref("file://[invalid/path.png"), + Some(std::path::PathBuf::from("[invalid/path.png")) + ); + assert_eq!( + local_source_path_from_image_ref("file:///tmp/broken%ZZ.png"), + Some(std::path::PathBuf::from("/tmp/broken%ZZ.png")) + ); + } + + #[test] + fn preset_1_0_fixture_preserves_values_and_fills_visual_defaults() { + // 1.0.0이 실제 저장하던 프리셋 형식 — height/noteColor/noteOpacity 없음 + let fixture = r#"{ + "keys": { + "4key": ["Q"], + "5key": [], + "6key": [], + "8key": [] + }, + "keyPositions": { + "4key": [ + { + "dx": 777, + "dy": 130, + "width": 60, + "activeImage": "", + "inactiveImage": "", + "count": 42 + } + ], + "5key": [], + "6key": [], + "8key": [] + }, + "backgroundColor": "transparent" + }"#; + let preset: PresetFile = serde_json::from_str(fixture).unwrap(); + let positions = preset.key_positions.unwrap(); + let position = &positions["4key"][0]; + + assert_eq!(position.dx, 777.0); + assert_eq!(position.count, 42); + assert_eq!(position.height, 60.0); + assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); + assert_eq!(position.note_opacity, 80); + } +} diff --git a/src-tauri/src/commands/preset/save.rs b/src-tauri/src/commands/preset/save.rs index 2b1badf8..6e274be8 100644 --- a/src-tauri/src/commands/preset/save.rs +++ b/src-tauri/src/commands/preset/save.rs @@ -1,7 +1,7 @@ use std::{ collections::{HashMap, HashSet}, fs, - path::PathBuf, + path::{Path, PathBuf}, }; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; @@ -12,9 +12,9 @@ use crate::{ errors::CmdResult, models::{ FontSettings, FontType, GraphPositions, KeyMappings, KeyPositions, KnobPositions, - StatPositions, TabNoteOverrides, + LayerGroups, StatPositions, TabCssOverrides, TabNoteOverrides, }, - state::AppState, + state::{atomic_file::atomic_replace, AppState}, }; use super::{ @@ -43,6 +43,7 @@ pub fn preset_save(state: State<'_, AppState>) -> CmdResult) -> CmdResult) -> CmdResult) -> CmdResult) -> CmdResult) -> CmdResult) -> CmdResult) -> CmdResult CmdResult<()> { + atomic_replace(path, json.as_bytes(), "preset")?; + Ok(()) +} + fn collect_used_font_families( key_positions: &KeyPositions, stat_positions: &StatPositions, graph_positions: &GraphPositions, + knob_positions: &KnobPositions, ) -> HashSet { let mut used = HashSet::new(); @@ -253,6 +292,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 +542,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 +587,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 +652,123 @@ 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 image_payload_embeds_percent_encoded_file_url() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-image-url-save-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let image_path = temp_dir.join("image with space.png"); + std::fs::write(&image_path, b"image-bytes").unwrap(); + let image_url = url::Url::from_file_path(&image_path).unwrap().to_string(); + assert!(image_url.contains("%20")); + + let mut position = default_positions()["4key"][0].clone(); + position.active_image = Some(image_url); + let key_positions = KeyPositions::from([("4key".to_string(), vec![position])]); + + let (exported, _, _, _, embedded) = build_preset_image_payload( + &key_positions, + &StatPositions::new(), + &GraphPositions::new(), + &KnobPositions::new(), + ) + .unwrap(); + + assert_eq!(embedded.len(), 1); + assert_eq!( + BASE64_STANDARD.decode(&embedded[0].data_base64).unwrap(), + b"image-bytes" + ); + assert_eq!( + exported["4key"][0].active_image.as_deref(), + Some(format!("{PRESET_LOCAL_IMAGE_PREFIX}{}", embedded[0].image_id).as_str()) + ); + let _ = std::fs::remove_dir_all(temp_dir); + } + + #[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); + } + + // 단독 실행: cargo test --lib commands::preset::save::tests::preset_atomic_write_survives_file_size_limit -- --ignored --exact + #[cfg(unix)] + #[test] + #[ignore = "RLIMIT_FSIZE는 프로세스 전역이므로 단독 실행"] + fn preset_atomic_write_survives_file_size_limit() { + use crate::state::atomic_file::test_support::FileSizeLimit; + + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-rlimit-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let path = temp_dir.join("preset.json"); + let original = vec![b'o'; 512]; + std::fs::write(&path, &original).unwrap(); + + { + let _limit = FileSizeLimit::set(1_024); + let oversized = "x".repeat(4_096); + assert!(write_preset_file(&path, &oversized).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), original); + } + + assert!(!std::fs::read_dir(&temp_dir).unwrap().any(|entry| { + entry + .ok() + .and_then(|entry| entry.file_name().into_string().ok()) + .is_some_and(|name| name.ends_with(".tmp")) + })); + let _ = std::fs::remove_dir_all(temp_dir); + } +} diff --git a/src-tauri/src/keyboard/daemon/mod.rs b/src-tauri/src/keyboard/daemon/mod.rs index dfec6281..33305846 100644 --- a/src-tauri/src/keyboard/daemon/mod.rs +++ b/src-tauri/src/keyboard/daemon/mod.rs @@ -1,4 +1,7 @@ -use std::io::Write; +use std::{ + io::{self, Read, Write}, + thread, +}; #[cfg(not(any(target_os = "windows", target_os = "macos")))] use anyhow::anyhow; @@ -27,6 +30,32 @@ fn load_hotkeys_from_env() -> ShortcutsState { .unwrap_or_default() } +fn wait_for_parent_disconnect(reader: &mut impl Read) -> io::Result<()> { + let mut buffer = [0_u8; 1]; + loop { + match reader.read(&mut buffer) { + Ok(0) => return Ok(()), + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::Interrupted => {} + Err(err) => return Err(err), + } + } +} + +pub fn start_parent_liveness_watch() -> io::Result<()> { + thread::Builder::new() + .name("keyboard-parent-watch".into()) + .spawn(|| { + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + if let Err(err) = wait_for_parent_disconnect(&mut stdin) { + eprintln!("keyboard parent watch failed: {err}"); + } + std::process::exit(0); + }) + .map(|_| ()) +} + fn write_message(sink: &mut Box, message: &HookMessage) -> Result<()> { let line = to_string(message)?; sink.write_all(line.as_bytes())?; @@ -67,3 +96,23 @@ pub fn run() -> Result<()> { )) } } + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::wait_for_parent_disconnect; + + #[test] + fn parent_watch_returns_after_pipe_eof() { + let mut reader = Cursor::new(Vec::::new()); + wait_for_parent_disconnect(&mut reader).unwrap(); + } + + #[test] + fn parent_watch_ignores_bytes_until_pipe_eof() { + let mut reader = Cursor::new(b"keepalive".to_vec()); + wait_for_parent_disconnect(&mut reader).unwrap(); + assert_eq!(reader.position(), b"keepalive".len() as u64); + } +} diff --git a/src-tauri/src/keyboard/manager.rs b/src-tauri/src/keyboard/manager.rs index 92102b54..d65d518c 100644 --- a/src-tauri/src/keyboard/manager.rs +++ b/src-tauri/src/keyboard/manager.rs @@ -9,6 +9,7 @@ pub struct KeyboardManager { mappings: Arc>, current_mode: Arc>, valid_keys: Arc>>, + active_keys: Arc>>, } impl KeyboardManager { @@ -19,51 +20,311 @@ impl KeyboardManager { mappings, current_mode, valid_keys: Arc::new(RwLock::new(HashSet::new())), + active_keys: Arc::new(RwLock::new(HashSet::new())), }; manager.rebuild_valid_keys(); manager } pub fn update_mappings(&self, mappings: KeyMappings) { - *self.mappings.write() = mappings; - self.rebuild_valid_keys(); + let mut mappings_guard = self.mappings.write(); + let current_mode = self.current_mode.read(); + let next_valid_keys = Self::valid_keys_for_mode(&mappings, ¤t_mode); + let mut valid_keys = self.valid_keys.write(); + let mut active_keys = self.active_keys.write(); + + Self::retain_active_keys(&mut active_keys, ¤t_mode, &next_valid_keys); + *mappings_guard = mappings; + *valid_keys = next_valid_keys; } pub fn set_mode(&self, mode: impl Into) -> bool { let mode = mode.into(); - let exists = self.mappings.read().contains_key(&mode); - if exists { - *self.current_mode.write() = mode; - self.rebuild_valid_keys(); + let mappings = self.mappings.read(); + if !mappings.contains_key(&mode) { + return false; } - exists + + let next_valid_keys = Self::valid_keys_for_mode(&mappings, &mode); + let mut current_mode = self.current_mode.write(); + let mut valid_keys = self.valid_keys.write(); + let mut active_keys = self.active_keys.write(); + + Self::rekey_active_keys(&mut active_keys, ¤t_mode, &mode, &next_valid_keys); + *current_mode = mode; + *valid_keys = next_valid_keys; + true + } + + pub fn update_mappings_and_set_mode( + &self, + mappings: KeyMappings, + mode: impl Into, + ) -> bool { + let mode = mode.into(); + let mode_exists = mappings.contains_key(&mode); + let mut mappings_guard = self.mappings.write(); + let mut current_mode = self.current_mode.write(); + let next_mode = if mode_exists { + mode.as_str() + } else { + current_mode.as_str() + }; + let next_valid_keys = Self::valid_keys_for_mode(&mappings, next_mode); + let mut valid_keys = self.valid_keys.write(); + let mut active_keys = self.active_keys.write(); + + if mode_exists { + Self::rekey_active_keys(&mut active_keys, ¤t_mode, &mode, &next_valid_keys); + *current_mode = mode; + } else { + Self::retain_active_keys(&mut active_keys, ¤t_mode, &next_valid_keys); + } + + *mappings_guard = mappings; + *valid_keys = next_valid_keys; + mode_exists } pub fn current_mode(&self) -> String { self.current_mode.read().clone() } - pub fn match_candidate<'a>( + pub fn match_and_register<'a>( &self, candidates: impl IntoIterator, - ) -> Option { - let guard = self.valid_keys.read(); - for candidate in candidates { - if guard.contains(candidate) { - return Some(candidate.to_string()); - } + is_down: bool, + ) -> Option<(String, String, bool)> { + let current_mode = self.current_mode.read(); + let valid_keys = self.valid_keys.read(); + let key = candidates + .into_iter() + .find(|candidate| valid_keys.contains(*candidate))? + .to_string(); + let mut active_keys = self.active_keys.write(); + let active_key = Self::compose_active_key(¤t_mode, &key); + let changed = if is_down { + active_keys.insert(active_key) + } else { + active_keys.remove(&active_key) + }; + + Some((current_mode.clone(), key, changed)) + } + + #[cfg(test)] + pub fn register_key_down(&self, mode: &str, key: &str) -> bool { + let current_mode = self.current_mode.read(); + if current_mode.as_str() != mode { + return false; + } + let valid_keys = self.valid_keys.read(); + if !valid_keys.contains(key) { + return false; + } + self.active_keys + .write() + .insert(Self::compose_active_key(¤t_mode, key)) + } + + #[cfg(test)] + pub fn register_key_up(&self, mode: &str, key: &str) -> bool { + let current_mode = self.current_mode.read(); + if current_mode.as_str() != mode { + return false; + } + let valid_keys = self.valid_keys.read(); + if !valid_keys.contains(key) { + return false; } - None + self.active_keys + .write() + .remove(&Self::compose_active_key(¤t_mode, key)) + } + + pub fn clear_active_keys(&self) { + self.active_keys.write().clear(); + } + + pub fn pressed_keys(&self) -> Vec { + let current_mode = self.current_mode.read(); + let prefix = format!("{current_mode}::"); + let active_keys = self.active_keys.read(); + let mut keys: Vec = active_keys + .iter() + .filter_map(|entry| entry.strip_prefix(&prefix).map(str::to_string)) + .collect(); + keys.sort(); + keys } fn rebuild_valid_keys(&self) { let mappings = self.mappings.read(); let mode = self.current_mode.read(); - let keys = mappings.get(mode.as_str()).cloned().unwrap_or_default(); - let mut guard = self.valid_keys.write(); - guard.clear(); - for key in keys { - guard.insert(key); - } + *self.valid_keys.write() = Self::valid_keys_for_mode(&mappings, &mode); + } + + fn valid_keys_for_mode(mappings: &KeyMappings, mode: &str) -> HashSet { + mappings.get(mode).into_iter().flatten().cloned().collect() + } + + fn retain_active_keys( + active_keys: &mut HashSet, + mode: &str, + valid_keys: &HashSet, + ) { + let prefix = format!("{mode}::"); + active_keys.retain(|entry| { + entry + .strip_prefix(&prefix) + .is_some_and(|key| valid_keys.contains(key)) + }); + } + + fn rekey_active_keys( + active_keys: &mut HashSet, + current_mode: &str, + next_mode: &str, + next_valid_keys: &HashSet, + ) { + let prefix = format!("{current_mode}::"); + *active_keys = active_keys + .drain() + .filter_map(|entry| { + let key = entry.strip_prefix(&prefix)?; + next_valid_keys + .contains(key) + .then(|| Self::compose_active_key(next_mode, key)) + }) + .collect(); + } + + fn compose_active_key(mode: &str, key: &str) -> String { + format!("{mode}::{key}") + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::KeyboardManager; + + #[test] + fn pressed_keys_use_event_key_names_for_current_mode() { + let mappings = HashMap::from([ + ( + "4key".to_string(), + vec!["KeyD".to_string(), "KeyF".to_string()], + ), + ( + "8key".to_string(), + vec!["KeyD".to_string(), "KeyF".to_string()], + ), + ]); + let manager = KeyboardManager::new(mappings, "4key"); + + assert!(manager.register_key_down("4key", "KeyF")); + assert!(manager.register_key_down("4key", "KeyD")); + assert_eq!(manager.pressed_keys(), vec!["KeyD", "KeyF"]); + + assert!(manager.set_mode("8key")); + assert_eq!(manager.pressed_keys(), vec!["KeyD", "KeyF"]); + assert!(manager.register_key_up("8key", "KeyD")); + assert_eq!(manager.pressed_keys(), vec!["KeyF"]); + } + + #[test] + fn mode_switch_retains_only_shared_active_keys() { + let mappings = HashMap::from([ + ( + "source".to_string(), + vec!["KeyD".to_string(), "KeyF".to_string()], + ), + ( + "target".to_string(), + vec!["KeyF".to_string(), "KeyJ".to_string()], + ), + ]); + let manager = KeyboardManager::new(mappings, "source"); + + assert!(manager.register_key_down("source", "KeyD")); + assert!(manager.register_key_down("source", "KeyF")); + assert!(manager.set_mode("target")); + + assert_eq!(manager.pressed_keys(), vec!["KeyF"]); + assert!(!manager.register_key_up("target", "KeyD")); + assert!(manager.register_key_up("target", "KeyF")); + } + + #[test] + fn reset_to_empty_mapping_clears_active_keys() { + let manager = KeyboardManager::new( + HashMap::from([("custom".to_string(), vec!["KeyD".to_string()])]), + "custom", + ); + assert!(manager.register_key_down("custom", "KeyD")); + + manager.update_mappings(HashMap::from([("custom".to_string(), Vec::new())])); + + assert!(manager.pressed_keys().is_empty()); + assert!(!manager.register_key_up("custom", "KeyD")); + } + + #[test] + fn update_mappings_prunes_removed_active_keys() { + let manager = KeyboardManager::new( + HashMap::from([( + "4key".to_string(), + vec!["KeyD".to_string(), "KeyF".to_string()], + )]), + "4key", + ); + assert!(manager.register_key_down("4key", "KeyD")); + assert!(manager.register_key_down("4key", "KeyF")); + + manager.update_mappings(HashMap::from([( + "4key".to_string(), + vec!["KeyF".to_string(), "KeyJ".to_string()], + )])); + + assert_eq!(manager.pressed_keys(), vec!["KeyF"]); + assert!(!manager.register_key_up("4key", "KeyD")); + assert!(manager.register_key_up("4key", "KeyF")); + } + + #[test] + fn stale_mode_or_invalid_key_registration_is_rejected() { + let manager = KeyboardManager::new( + HashMap::from([ + ("source".to_string(), vec!["KeyD".to_string()]), + ("target".to_string(), vec!["KeyF".to_string()]), + ]), + "source", + ); + + assert!(manager.set_mode("target")); + assert!(!manager.register_key_down("target", "KeyD")); + assert!(!manager.register_key_down("source", "KeyD")); + assert!(manager.pressed_keys().is_empty()); + } + + #[test] + fn matching_and_registration_share_one_mode_snapshot() { + let manager = KeyboardManager::new( + HashMap::from([ + ("source".to_string(), vec!["KeyD".to_string()]), + ("target".to_string(), vec!["KeyF".to_string()]), + ]), + "source", + ); + + assert_eq!( + manager.match_and_register(["KeyD"], true), + Some(("source".to_string(), "KeyD".to_string(), true)) + ); + assert!(manager.set_mode("target")); + assert!(manager.pressed_keys().is_empty()); + assert_eq!(manager.match_and_register(["KeyD"], true), None); } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2c6916af..c74e0885 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -52,6 +52,10 @@ fn main() { } if std::env::args().any(|arg| arg == "--keyboard-daemon") { + if let Err(err) = keyboard::daemon::start_parent_liveness_watch() { + eprintln!("failed to start keyboard parent watch: {err}"); + std::process::exit(1); + } if let Err(err) = keyboard::daemon::run() { eprintln!("keyboard daemon error: {err:?}"); std::process::exit(1); @@ -71,7 +75,7 @@ fn main() { let context = tauri::generate_context!(); - tauri::Builder::default() + let app = tauri::Builder::default() .on_page_load(|webview, payload| { if matches!(payload.event(), PageLoadEvent::Finished) { let zoom = compute_compensating_zoom(); @@ -85,7 +89,11 @@ fn main() { } }) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - if let Some(window) = app.get_webview_window("main") { + if let Some(state) = app.try_state::() { + if let Err(err) = state.show_main_window(app) { + log::warn!("failed to show main window from second instance: {err}"); + } + } else if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); @@ -264,8 +272,25 @@ fn main() { commands::plugin::storage::plugin_storage_has_data, commands::plugin::storage::plugin_storage_clear_by_prefix, ]) - .run(context) - .expect("error while running tauri application"); + .build(context) + .expect("error while building tauri application"); + + app.run(|app_handle, event| match event { + tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => { + if let Some(state) = app_handle.try_state::() { + state.shutdown(); + } + } + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { .. } => { + if let Some(state) = app_handle.try_state::() { + if let Err(err) = state.show_main_window(app_handle) { + log::warn!("failed to show main window from Dock helper: {err}"); + } + } + } + _ => {} + }); } #[cfg(target_os = "windows")] @@ -534,7 +559,8 @@ fn launch_macos_dock_helper() { }; let mut cmd = Command::new("/usr/bin/open"); - cmd.arg(&helper_path) + cmd.arg("-n") + .arg(&helper_path) .arg("--args") .arg("--main-pid") .arg(std::process::id().to_string()) diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 22581232..e93592a4 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -92,6 +92,13 @@ impl Default for SoundLibraryEntry { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PendingProcessedWavReplacement { + pub sound_path: String, + pub had_original: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde( tag = "kind", @@ -178,6 +185,7 @@ pub struct KeyPosition { pub dx: f64, pub dy: f64, pub width: f64, + #[serde(default = "default_key_height")] pub height: f64, /// 레이어 표시 여부 (true면 숨김) #[serde(default)] @@ -200,9 +208,15 @@ pub struct KeyPosition { #[serde(default)] pub idle_transparent: bool, pub count: u32, + #[serde(default = "default_key_note_color")] pub note_color: NoteColor, + #[serde(default = "default_key_note_opacity")] 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 +233,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, @@ -303,6 +321,72 @@ pub struct KeyPosition { pub group_id: Option, } +impl Default for KeyPosition { + fn default() -> Self { + Self { + dx: 0.0, + dy: 0.0, + width: 60.0, + height: default_key_height(), + hidden: false, + active_image: None, + inactive_image: None, + sound_enabled: None, + sound_path: None, + sound_volume: None, + active_transparent: false, + idle_transparent: false, + count: 0, + note_color: default_key_note_color(), + note_opacity: default_key_note_opacity(), + note_opacity_top: None, + note_opacity_bottom: None, + note_border_radius: None, + note_width: None, + note_alignment: NoteAlignment::default(), + note_effect_enabled: default_note_effect_enabled(), + note_glow_enabled: default_note_glow_enabled(), + note_glow_size: default_note_glow_size(), + note_glow_opacity: default_note_glow_opacity(), + note_glow_opacity_top: None, + note_glow_opacity_bottom: None, + note_glow_color: None, + note_auto_y_correction: default_note_auto_y_correction(), + note_offset_x: None, + note_offset_y: None, + note_border_width: None, + note_border_color: None, + note_border_opacity: default_note_border_opacity(), + note_border_side: None, + class_name: None, + z_index: None, + counter: KeyCounterSettings::default(), + background_color: None, + active_background_color: None, + border_color: None, + active_border_color: None, + border_width: None, + border_radius: None, + font_size: None, + font_color: None, + active_font_color: None, + graph_animation_enabled: None, + font_family: None, + image_fit: None, + idle_image_fit: None, + active_image_fit: None, + use_inline_styles: None, + display_text: None, + font_weight: None, + font_italic: None, + font_underline: None, + font_strikethrough: None, + layer_name: None, + group_id: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum StatType { @@ -780,6 +864,15 @@ fn default_counter_enabled() -> bool { fn default_note_effect_enabled() -> bool { true } +fn default_key_height() -> f64 { + 60.0 +} +fn default_key_note_color() -> NoteColor { + NoteColor::Solid("#FFFFFF".to_string()) +} +fn default_key_note_opacity() -> u32 { + 80 +} fn default_note_glow_enabled() -> bool { false } @@ -809,7 +902,7 @@ fn default_reverse_fade_bottom_px() -> u32 { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", default)] pub struct NoteSettings { // Legacy: 전역 노트 라운딩 (개별 키 noteBorderRadius로 마이그레이션됨) #[serde(default, skip_serializing)] @@ -1306,6 +1399,9 @@ pub struct AppStoreData { /// 사운드 라이브러리 메타데이터 (키: 절대 경로, 값: 메타데이터) #[serde(default)] pub sound_library: HashMap, + /// WAV 파일과 메타데이터 커밋 사이의 크래시 복구 저널 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_processed_wav_replacement: Option, #[serde(default)] pub key_sound_output_backend: Option, /// OBS 모드 활성화 여부 @@ -1368,6 +1464,7 @@ impl Default for AppStoreData { grid_settings: GridSettings::default(), shortcuts: ShortcutsState::default(), sound_library: HashMap::new(), + pending_processed_wav_replacement: None, key_sound_output_backend: None, obs_mode_enabled: false, obs_port: default_obs_port(), @@ -1590,6 +1687,7 @@ pub struct BootstrapPayload { pub custom_tabs: Vec, pub selected_key_type: String, pub current_mode: String, + pub active_keys: Vec, pub overlay: BootstrapOverlayState, pub key_counters: KeyCounters, pub layer_groups: LayerGroups, @@ -1846,7 +1944,7 @@ pub struct SettingsPatch { #[cfg(test)] mod tests { - use super::KeyPosition; + use super::{FadePosition, KeyPosition, NoteColor, NoteSettings}; // 필수 필드만 채운 최소 KeyPosition JSON. 시각 px 필드는 호출부에서 주입 fn key_position_json(visual_px: &str) -> String { @@ -1890,4 +1988,64 @@ 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); + } + + #[test] + fn note_settings_1_3_format_still_preserves_every_field() { + // 1.3 시절 noteSettings 전체 필드 실형식 + let fixture = r#"{ + "borderRadius": 9, + "speed": 456, + "trackHeight": 222, + "reverse": true, + "fadePosition": "bottom", + "delayedNoteEnabled": true, + "shortNoteThresholdMs": 73, + "shortNoteMinLengthPx": 41 + }"#; + let settings: NoteSettings = serde_json::from_str(fixture).unwrap(); + + assert_eq!(settings.border_radius, Some(9)); + assert_eq!(settings.speed, 456); + assert_eq!(settings.track_height, 222); + assert!(settings.reverse); + assert_eq!(settings.fade_position, FadePosition::Bottom); + assert!(settings.delayed_note_enabled); + assert_eq!(settings.short_note_threshold_ms, 73); + assert_eq!(settings.short_note_min_length_px, 41); + } + + #[test] + fn key_position_1_0_missing_visual_fields_uses_legacy_defaults() { + let position: KeyPosition = + serde_json::from_str(r#"{"dx":777,"dy":12,"width":60,"count":42}"#).unwrap(); + + assert_eq!(position.dx, 777.0); + assert_eq!(position.count, 42); + assert_eq!(position.height, 60.0); + assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); + assert_eq!(position.note_opacity, 80); + } } 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/services/settings.rs b/src-tauri/src/services/settings.rs index 104322d5..ea73f164 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -1,10 +1,11 @@ use std::sync::Arc; -use anyhow::Result; +use anyhow::{Context, Result}; use crate::models::{ - CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, FontType, NoteSettings, NoteSettingsPatch, - SettingsDiff, SettingsPatch, SettingsPatchInput, SettingsState, ShortcutsState, + AppStoreData, CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, FontType, NoteSettings, + NoteSettingsPatch, SettingsDiff, SettingsPatch, SettingsPatchInput, SettingsState, + ShortcutsState, }; use crate::state::AppStore; @@ -23,39 +24,78 @@ impl SettingsService { } pub fn apply_patch(&self, patch: SettingsPatchInput) -> Result { - let current = self.snapshot(); - let normalized = normalize_patch(&patch, ¤t); - let next = apply_changes(current.clone(), &normalized); - + let mut diff = None; self.store.update(|state| { - state.hardware_acceleration = next.hardware_acceleration; - state.always_on_top = next.always_on_top; - state.overlay_locked = next.overlay_locked; - state.note_effect = next.note_effect; - state.note_settings = next.note_settings.clone(); - state.angle_mode = next.angle_mode.clone(); - state.language = next.language.clone(); - state.laboratory_enabled = next.laboratory_enabled; - state.developer_mode_enabled = next.developer_mode_enabled; - state.tray_enabled = next.tray_enabled; - state.auto_update_enabled = next.auto_update_enabled; - state.background_color = next.background_color.clone(); - state.use_custom_css = next.use_custom_css; - state.custom_css = next.custom_css.clone(); - state.font_settings = next.font_settings.clone(); - state.use_custom_js = next.use_custom_js; - state.custom_js = next.custom_js.clone(); - state.overlay_resize_anchor = next.overlay_resize_anchor.clone(); - state.key_counter_enabled = next.key_counter_enabled; - state.grid_settings = next.grid_settings.clone(); - state.shortcuts = next.shortcuts.clone(); - state.obs_mode_enabled = next.obs_mode_enabled; + diff = Some(apply_patch_to_store(state, &patch)); })?; + diff.context("settings patch did not produce a diff") + } +} + +pub(crate) fn settings_from_store(store: &AppStoreData) -> SettingsState { + let mut custom_js = store.custom_js.clone(); + let _ = custom_js.normalize(); + + SettingsState { + hardware_acceleration: store.hardware_acceleration, + always_on_top: store.always_on_top, + overlay_locked: store.overlay_locked, + note_effect: store.note_effect, + note_settings: store.note_settings.clone(), + angle_mode: store.angle_mode.clone(), + language: store.language.clone(), + laboratory_enabled: store.laboratory_enabled, + developer_mode_enabled: store.developer_mode_enabled, + tray_enabled: store.tray_enabled, + auto_update_enabled: store.auto_update_enabled, + background_color: store.background_color.clone(), + use_custom_css: store.use_custom_css, + custom_css: store.custom_css.clone(), + font_settings: store.font_settings.clone(), + use_custom_js: store.use_custom_js, + custom_js, + overlay_resize_anchor: store.overlay_resize_anchor.clone(), + key_counter_enabled: store.key_counter_enabled, + grid_settings: store.grid_settings.clone(), + shortcuts: store.shortcuts.clone(), + obs_mode_enabled: store.obs_mode_enabled, + } +} + +pub(crate) fn apply_patch_to_store( + store: &mut AppStoreData, + patch: &SettingsPatchInput, +) -> SettingsDiff { + let current = settings_from_store(store); + let normalized = normalize_patch(patch, ¤t); + let next = apply_changes(current, &normalized); + + store.hardware_acceleration = next.hardware_acceleration; + store.always_on_top = next.always_on_top; + store.overlay_locked = next.overlay_locked; + store.note_effect = next.note_effect; + store.note_settings = next.note_settings.clone(); + store.angle_mode = next.angle_mode.clone(); + store.language = next.language.clone(); + store.laboratory_enabled = next.laboratory_enabled; + store.developer_mode_enabled = next.developer_mode_enabled; + store.tray_enabled = next.tray_enabled; + store.auto_update_enabled = next.auto_update_enabled; + store.background_color = next.background_color.clone(); + store.use_custom_css = next.use_custom_css; + store.custom_css = next.custom_css.clone(); + store.font_settings = next.font_settings.clone(); + store.use_custom_js = next.use_custom_js; + store.custom_js = next.custom_js.clone(); + store.overlay_resize_anchor = next.overlay_resize_anchor.clone(); + store.key_counter_enabled = next.key_counter_enabled; + store.grid_settings = next.grid_settings.clone(); + store.shortcuts = next.shortcuts.clone(); + store.obs_mode_enabled = next.obs_mode_enabled; - Ok(SettingsDiff { - changed: normalized, - full: Some(next), - }) + SettingsDiff { + changed: normalized, + full: Some(next), } } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 0f1b3eae..3c287d27 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -1,10 +1,9 @@ use std::time::Instant; use std::{ - collections::HashSet, io::{BufRead, BufReader}, - process::{Child, Command, Stdio}, + process::{Child, ChildStdin, Command, Stdio}, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, Arc, }, thread::{self, JoinHandle}, @@ -47,6 +46,15 @@ const TRAY_MENU_QUIT_ID: &str = "tray-quit"; const DEFAULT_OVERLAY_WIDTH: f64 = 860.0; const DEFAULT_OVERLAY_HEIGHT: f64 = 320.0; const OVERLAY_MARGIN: f64 = 40.0; +const OVERLAY_BOUNDS_DEBOUNCE_MS: u64 = 400; + +fn should_create_overlay_on_startup(obs_mode_enabled: bool, overlay_visible: bool) -> bool { + !obs_mode_enabled && overlay_visible +} + +fn bootstrap_active_keys(keyboard: &KeyboardManager) -> Vec { + keyboard.pressed_keys() +} pub struct AppState { pub store: Arc, @@ -56,10 +64,10 @@ pub struct AppState { overlay_force_close: Arc, /// 오버레이 윈도우 초기화 중 Moved/Resized 이벤트에서 bounds 저장 억제 overlay_initializing: Arc, + overlay_bounds_generation: Arc, keyboard_task: RwLock>, key_counters: Arc>, key_counter_enabled: Arc, - active_keys: Arc>>, /// Raw input stream subscriber count - emit only when > 0 raw_input_subscribers: Arc, key_sound: Arc, @@ -69,20 +77,24 @@ pub struct AppState { pub obs_bridge: Arc, /// OBS 모드 시작 전 오버레이 가시성 상태 (복원용) obs_previous_overlay_visible: Arc>>, + shutdown_started: AtomicBool, } impl AppState { pub fn initialize(store: AppStore) -> Result { + if let Err(err) = store.recover_interrupted_processed_wav_replacements_now() { + log::warn!("failed to recover interrupted processed WAVs during startup: {err}"); + } let store = Arc::new(store); let snapshot = store.snapshot(); let keyboard = KeyboardManager::new(snapshot.keys.clone(), snapshot.selected_key_type.clone()); let settings = SettingsService::new(store.clone()); - let key_counters = Arc::new(RwLock::new(snapshot.key_counters.clone())); - Self::sync_counters_with_keys_impl(&key_counters, &snapshot.keys); + let mut initial_key_counters = snapshot.key_counters.clone(); + Self::sync_counters_with_keys_impl(&mut initial_key_counters, &snapshot.keys); + let key_counters = Arc::new(RwLock::new(initial_key_counters)); let key_counter_enabled = Arc::new(AtomicBool::new(snapshot.key_counter_enabled)); - let active_keys = Arc::new(RwLock::new(HashSet::new())); // 저장된 출력 백엔드로 엔진을 처음부터 초기화 → "기본 장치 → ASIO" 전환 깜빡임 제거. let initial_backend = snapshot .key_sound_output_backend @@ -99,23 +111,23 @@ impl AppState { overlay_visible: Arc::new(RwLock::new(false)), overlay_force_close: Arc::new(AtomicBool::new(false)), overlay_initializing: Arc::new(AtomicBool::new(false)), + overlay_bounds_generation: Arc::new(AtomicU64::new(0)), keyboard_task: RwLock::new(None), key_counters, key_counter_enabled, - active_keys, raw_input_subscribers: Arc::new(std::sync::atomic::AtomicU32::new(0)), key_sound, css_watcher: RwLock::new(None), obs_bridge, obs_previous_overlay_visible: Arc::new(RwLock::new(None)), + shutdown_started: AtomicBool::new(false), }) } pub fn initialize_runtime(&self, app: &AppHandle) -> Result<()> { self.attach_main_window_handlers(app); let snapshot = self.store.snapshot(); - // OBS 모드가 활성화된 상태로 부팅하면 오버레이 생성 건너뛰기 (create→destroy 낭비 방지) - if !snapshot.obs_mode_enabled { + if should_create_overlay_on_startup(snapshot.obs_mode_enabled, snapshot.overlay_visible) { self.ensure_overlay_window(app)?; } // 개발자 모드가 켜져 있으면 시작 시 DevTools 오픈 허용 및 자동 오픈 시도 @@ -161,6 +173,29 @@ impl AppState { }); } + pub fn show_main_window(&self, app: &AppHandle) -> Result<()> { + let app_handle = app.clone(); + app.run_on_main_thread(move || { + let state = app_handle.state::(); + if let Err(err) = state.show_main_window_inner(&app_handle) { + log::warn!("failed to show main window: {err}"); + } + })?; + Ok(()) + } + + fn show_main_window_inner(&self, app: &AppHandle) -> Result<()> { + if let Some(main) = app.get_webview_window("main") { + let _ = main.unminimize(); + main.show()?; + let _ = main.set_focus(); + } + + remove_tray_icon(app); + self.set_main_window_hidden(false)?; + Ok(()) + } + pub fn ensure_tray_icon_for_background(&self, app: &AppHandle) -> Result<()> { if app.tray_by_id(TRAY_ICON_ID).is_some() { return Ok(()); @@ -195,14 +230,16 @@ impl AppState { .. } = event { - if let Err(err) = show_main_window(tray.app_handle()) { + let state = tray.app_handle().state::(); + if let Err(err) = state.show_main_window(tray.app_handle()) { log::error!("failed to show main window from tray click: {err}"); } } }) .on_menu_event(move |app_handle, event| { if event.id() == TRAY_MENU_SETTINGS_ID { - if let Err(err) = show_main_window(app_handle) { + let state = app_handle.state::(); + if let Err(err) = state.show_main_window(app_handle) { log::error!("failed to show main window from tray: {err}"); } return; @@ -222,6 +259,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; })?; @@ -269,6 +313,7 @@ impl AppState { custom_tabs: state.custom_tabs.clone(), selected_key_type: state.selected_key_type.clone(), current_mode: self.keyboard.current_mode(), + active_keys: bootstrap_active_keys(&self.keyboard), overlay: BootstrapOverlayState { visible: *self.overlay_visible.read(), locked: state.overlay_locked, @@ -313,21 +358,50 @@ impl AppState { Ok(()) } + /// 저장된 토큰 재사용 또는 신규 생성 후 store에 저장 + /// 기존 토큰은 commit-after-persist로 디스크 저장이 보장되므로 재저장 생략 + pub fn resolve_and_save_obs_token(&self) -> Result { + if let Some(token) = self + .store + .with_state(|s| s.obs_token.clone()) + .filter(|token| !token.is_empty()) + { + return Ok(token); + } + + let token = uuid::Uuid::new_v4().simple().to_string(); + let t = token.clone(); + self.store.update(|s| { + s.obs_token = Some(t.clone()); + })?; + Ok(token) + } + /// 부팅 시 OBS 모드 자동 시작 (obs_mode_enabled=true일 때) fn auto_start_obs(&self, app: &AppHandle) { let bridge = self.obs_bridge.clone(); let store = self.store.clone(); - let (port, existing_token) = store.with_state(|s| (s.obs_port, s.obs_token.clone())); - // 저장된 토큰 재사용 또는 신규 생성 - let token = match existing_token { - Some(t) if !t.is_empty() => t, - _ => { - let t = uuid::Uuid::new_v4().simple().to_string(); - let tc = t.clone(); + let port = store.with_state(|s| s.obs_port); + + // 부팅 시에는 오버레이를 생성하지 않았으므로 이전 표시 상태만 저장 + // (initialize_runtime에서 obs_mode_enabled일 때 ensure_overlay_window 건너뜀) + let was_visible = store.with_state(|s| s.overlay_visible); + *self.obs_previous_overlay_visible.write() = Some(was_visible); + + // 저장 안 된 토큰으로 서버를 켜면 재부팅 후 기존 URL이 무효화되므로 시작 중단 + let token = match self.resolve_and_save_obs_token() { + Ok(token) => token, + Err(e) => { + log::error!( + "[ObsBridge] auto-start 중단: 토큰 저장 실패 ({}) — obs_mode_enabled를 false로 복구", + e + ); let _ = store.update(|s| { - s.obs_token = Some(tc.clone()); + s.obs_mode_enabled = false; }); - t + self.obs_restore_overlay(app); + let _ = app.emit("obs:status", &self.obs_bridge.status()); + return; } }; let app_handle = app.clone(); @@ -355,11 +429,6 @@ impl AppState { // Tauri 이벤트 → OBS WS 포워딩 리스너 등록 bridge.register_event_forwarding(app); - // 부팅 시에는 오버레이를 생성하지 않았으므로 상태만 저장 - // (initialize_runtime에서 obs_mode_enabled일 때 ensure_overlay_window 건너뜀) - let was_visible = self.store.with_state(|s| s.overlay_visible); - *self.obs_previous_overlay_visible.write() = Some(was_visible); - // async start를 tokio 런타임에서 실행 tauri::async_runtime::spawn(async move { match bridge.start(port, token).await { @@ -481,6 +550,13 @@ impl AppState { pub fn set_overlay_visibility(&self, app: &AppHandle, visible: bool) -> Result<()> { log::debug!("[IPC] set_overlay_visibility: visible={}", visible); + if !visible { + flush_deferred_overlay_bounds(&self.store, &self.overlay_bounds_generation)?; + } + self.store.update(|state| { + state.overlay_visible = visible; + })?; + if visible { // 오버레이를 열 때: 창이 없으면 생성하고 표시 let window = self.ensure_overlay_window(app)?; @@ -501,11 +577,6 @@ impl AppState { } *self.overlay_visible.write() = visible; - if let Err(err) = self.store.update(|state| { - state.overlay_visible = visible; - }) { - log::warn!("failed to persist overlay visibility: {err}"); - } app.emit("overlay:visibility", &json!({ "visible": visible }))?; Ok(()) } @@ -535,19 +606,23 @@ 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; } + self.overlay_bounds_generation + .fetch_add(1, Ordering::SeqCst); 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}"); + } + if let Err(err) = self.store.flush_cleanup_and_shutdown() { + log::warn!("failed to finalize store during shutdown: {err:#}"); + } } pub fn request_shutdown(&self, app_handle: AppHandle) { @@ -603,6 +678,7 @@ impl AppState { let mut new_x = position.x; let mut new_y = position.y; + let mut next_content_top_offset = None; // 초기화 중(첫 resize)에는 anchor 기반 position 재계산을 건너뛰고 // store에 저장된 위치를 사용 (빌더 position이 무시될 수 있으므로) @@ -616,9 +692,7 @@ impl AppState { // 초기화 중이라도 content_top_offset은 저장해야 다음 resize에서 delta 계산이 정확함 if let Some(offset) = content_top_offset { if offset.is_finite() { - let _ = self.store.update(|state| { - state.overlay_last_content_top_offset = Some(offset); - })?; + next_content_top_offset = Some(offset); } } } else { @@ -662,9 +736,7 @@ impl AppState { _ => new_y -= delta, } } - let _ = self.store.update(|state| { - state.overlay_last_content_top_offset = Some(offset); - })?; + next_content_top_offset = Some(offset); } } } @@ -679,10 +751,12 @@ impl AppState { height, }; - let _ = self.store.update(|state| { - state.overlay_bounds = Some(bounds.clone()); - state.overlay_bounds_are_logical = true; - })?; + defer_overlay_bounds( + &self.store, + &self.overlay_bounds_generation, + bounds.clone(), + next_content_top_offset, + )?; log::debug!( "[IPC] resize_overlay: emit overlay:resized ({}x{} at {}, {})", @@ -739,12 +813,16 @@ impl AppState { let mut child = Command::new(current_exe) .arg("--keyboard-daemon") .env("DMNOTE_HOTKEYS_V1", shortcuts_json) - .stdin(Stdio::null()) + .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("failed to spawn keyboard daemon process")?; + let parent_stdin = child + .stdin + .take() + .context("keyboard daemon stdin unavailable")?; let stdout = child .stdout .take() @@ -938,36 +1016,36 @@ impl AppState { } } - let Some(key_label) = - keyboard.match_candidate(message.labels.iter().map(|s| s.as_str())) + let is_down = state == "DOWN"; + let Some((mode, key_label, state_changed)) = keyboard.match_and_register( + message.labels.iter().map(|s| s.as_str()), + is_down, + ) else { continue; }; - let mode = keyboard.current_mode(); - let state_changed = if state == "DOWN" { - let changed = app_state.register_key_down(&mode, &key_label); - if changed { - if let Some(count) = app_state.increment_key_counter(&mode, &key_label) { - log::trace!( - "[IPC] emit keys:counter: mode={}, key={}, count={}", - mode, key_label, count - ); - if let Err(err) = app_handle.emit( - "keys:counter", - &json!({ - "mode": mode.clone(), - "key": key_label.clone(), - "count": count, - }), - ) { - error!("failed to emit keys:counter event: {err}"); - } + if is_down && state_changed { + if let Some(count) = + app_state.increment_key_counter(&mode, &key_label) + { + log::trace!( + "[IPC] emit keys:counter: mode={}, key={}, count={}", + mode, + key_label, + count + ); + if let Err(err) = app_handle.emit( + "keys:counter", + &json!({ + "mode": mode.clone(), + "key": key_label.clone(), + "count": count, + }), + ) { + error!("failed to emit keys:counter event: {err}"); } } - changed - } else { - app_state.register_key_up(&mode, &key_label) - }; + } if !state_changed { continue; } @@ -1142,6 +1220,7 @@ impl AppState { running, reader_handle: Some(reader_handle), stderr_handle, + parent_stdin: Some(parent_stdin), child: Some(child), }); Ok(()) @@ -1306,6 +1385,7 @@ impl AppState { let overlay_window = window.clone(); let force_close_flag = self.overlay_force_close.clone(); let initializing_flag = self.overlay_initializing.clone(); + let bounds_generation = self.overlay_bounds_generation.clone(); window.on_window_event(move |event| match event { WindowEvent::CloseRequested { api, .. } => { @@ -1314,15 +1394,23 @@ impl AppState { *overlay_visible.write() = false; } else { api.prevent_close(); - if let Err(err) = overlay_window.hide() { - log::error!("failed to hide overlay window on close: {err}"); + if let Err(err) = + flush_deferred_overlay_bounds(&store, &bounds_generation) + { + log::warn!("failed to flush overlay bounds on close: {err}"); + return; } - *overlay_visible.write() = false; if let Err(err) = store.update(|state| { state.overlay_visible = false; }) { log::warn!("failed to persist overlay visibility on close: {err}"); + return; + } + if let Err(err) = overlay_window.hide() { + log::error!("failed to hide overlay window on close: {err}"); + return; } + *overlay_visible.write() = false; if let Err(err) = app_handle.emit("overlay:visibility", &json!({ "visible": false })) { @@ -1345,8 +1433,12 @@ impl AppState { WindowEvent::Moved(_) | WindowEvent::Resized(_) // 윈도우 초기화 중에는 OS가 보고하는 좌표로 저장된 bounds를 덮어쓰지 않음 if !initializing_flag.load(Ordering::SeqCst) => { - if let Err(err) = persist_overlay_bounds(&overlay_window, &store) { - log::warn!("failed to persist overlay bounds: {err}"); + if let Err(err) = defer_overlay_bounds_from_window( + &overlay_window, + &store, + &bounds_generation, + ) { + log::warn!("failed to defer overlay bounds: {err}"); } } _ => {} @@ -1458,7 +1550,7 @@ impl AppState { if let Some(enabled) = diff.changed.tray_enabled { if !enabled { - let _ = app.remove_tray_by_id(TRAY_ICON_ID); + dispatch_remove_tray_icon(app)?; if let Err(err) = self.set_main_window_hidden(false) { log::warn!( "failed to clear main_window_hidden when disabling tray mode: {err}" @@ -1495,14 +1587,23 @@ impl AppState { self.key_counters.read().clone() } - pub fn reset_key_counters(&self) -> KeyCounters { - let mut counters = self.key_counters.write(); - for mode_entry in counters.values_mut() { - for value in mode_entry.values_mut() { - *value = 0; + fn update_key_counters(&self, updater: impl FnOnce(&mut KeyCounters)) -> Result { + let mut guard = self.key_counters.write(); + let mut scratch = guard.clone(); + updater(&mut scratch); + let persisted = self.store.set_key_counters(scratch)?; + *guard = persisted.clone(); + Ok(persisted) + } + + pub fn reset_key_counters(&self) -> Result { + self.update_key_counters(|counters| { + for mode_entry in counters.values_mut() { + for value in mode_entry.values_mut() { + *value = 0; + } } - } - counters.clone() + }) } pub fn replace_key_counters( @@ -1510,58 +1611,38 @@ impl AppState { counters: KeyCounters, keys: &KeyMappings, ) -> Result { - { - let mut guard = self.key_counters.write(); - *guard = counters; - } - self.sync_counters_with_keys(keys); - self.persist_key_counters() + self.update_key_counters(|scratch| { + *scratch = counters; + Self::sync_counters_with_keys_impl(scratch, keys); + }) } - pub fn reset_mode_counters(&self, mode: &str) { - let mut counters = self.key_counters.write(); - if let Some(entry) = counters.get_mut(mode) { - for value in entry.values_mut() { - *value = 0; + pub fn reset_mode_counters(&self, mode: &str) -> Result { + self.update_key_counters(|counters| { + if let Some(entry) = counters.get_mut(mode) { + for value in entry.values_mut() { + *value = 0; + } } - } + }) } - pub fn reset_single_key_counter(&self, mode: &str, key: &str) { - let mut counters = self.key_counters.write(); - if let Some(entry) = counters.get_mut(mode) { - if let Some(value) = entry.get_mut(key) { - *value = 0; + pub fn reset_single_key_counter(&self, mode: &str, key: &str) -> Result { + self.update_key_counters(|counters| { + if let Some(entry) = counters.get_mut(mode) { + if let Some(value) = entry.get_mut(key) { + *value = 0; + } } - } - } - - pub fn register_key_down(&self, mode: &str, key: &str) -> bool { - let mut guard = self.active_keys.write(); - guard.insert(Self::compose_active_key(mode, key)) + }) } - pub fn register_key_up(&self, mode: &str, key: &str) -> bool { - let mut guard = self.active_keys.write(); - guard.remove(&Self::compose_active_key(mode, key)) + pub fn clear_active_keys(&self) { + self.keyboard.clear_active_keys(); } - pub fn clear_active_keys(&self) { - self.active_keys.write().clear(); - } - - /// 모드 전환 시 active_keys의 prefix를 새 모드로 교체 - pub fn transfer_active_keys(&self, new_mode: &str) { - let mut guard = self.active_keys.write(); - let transferred: HashSet = guard - .drain() - .filter_map(|entry| { - entry - .split_once("::") - .map(|(_, key)| Self::compose_active_key(new_mode, key)) - }) - .collect(); - *guard = transferred; + pub(crate) fn commit_key_counters_mirror(&self, counters: KeyCounters) { + *self.key_counters.write() = counters; } pub fn persist_key_counters(&self) -> Result { @@ -1571,14 +1652,14 @@ impl AppState { } pub fn sync_counters_with_keys(&self, keys: &KeyMappings) { - Self::sync_counters_with_keys_impl(&self.key_counters, keys); + let mut guard = self.key_counters.write(); + Self::sync_counters_with_keys_impl(&mut guard, keys); } - fn sync_counters_with_keys_impl(target: &Arc>, keys: &KeyMappings) { - let mut guard = target.write(); - guard.retain(|mode, _| keys.contains_key(mode)); + fn sync_counters_with_keys_impl(target: &mut KeyCounters, keys: &KeyMappings) { + target.retain(|mode, _| keys.contains_key(mode)); for (mode, key_list) in keys.iter() { - let entry = guard.entry(mode.clone()).or_default(); + let entry = target.entry(mode.clone()).or_default(); entry.retain(|key, _| key_list.contains(key)); for key in key_list.iter() { entry.entry(key.clone()).or_insert(0); @@ -1586,10 +1667,6 @@ impl AppState { } } - fn compose_active_key(mode: &str, key: &str) -> String { - format!("{}::{}", mode, key) - } - /// Subscribe to raw input stream (increment subscriber count) pub fn subscribe_raw_input(&self) -> u32 { self.raw_input_subscribers.fetch_add(1, Ordering::SeqCst) + 1 @@ -1635,15 +1712,21 @@ impl AppState { pub fn key_sound_set_output_backend( &self, backend: KeySoundOutputBackend, - ) -> KeySoundOutputState { - let output_state = self.key_sound.set_output_backend(backend); - let requested = output_state.requested.clone(); - if let Err(err) = self.store.update(|state| { - state.key_sound_output_backend = Some(output_backend_to_persist(requested.clone())); - }) { - warn!("[KeySound] failed to persist output backend: {err}"); - } - output_state + ) -> Result { + let requested = match &backend { + KeySoundOutputBackend::DefaultDevice => KeySoundOutputBackend::DefaultDevice, + KeySoundOutputBackend::Asio { + driver_name, + buffer_size, + } => KeySoundOutputBackend::Asio { + driver_name: driver_name.trim().to_string(), + buffer_size: buffer_size.filter(|size| *size > 0), + }, + }; + self.store.update(|state| { + state.key_sound_output_backend = Some(output_backend_to_persist(requested)); + })?; + Ok(self.key_sound.set_output_backend(backend)) } pub fn key_sound_get_output_state(&self) -> KeySoundOutputState { @@ -1845,19 +1928,13 @@ fn tray_menu_labels(_language: &str) -> (&'static str, &'static str) { ("Settings", "Quit") } -fn show_main_window(app_handle: &AppHandle) -> Result<()> { - if let Some(main) = app_handle.get_webview_window("main") { - let _ = main.unminimize(); - main.show()?; - let _ = main.set_focus(); - } - - if app_handle.tray_by_id(TRAY_ICON_ID).is_some() { - let _ = app_handle.remove_tray_by_id(TRAY_ICON_ID); - } +fn remove_tray_icon(app: &AppHandle) { + let _ = app.remove_tray_by_id(TRAY_ICON_ID); +} - let state = app_handle.state::(); - state.set_main_window_hidden(false)?; +fn dispatch_remove_tray_icon(app: &AppHandle) -> Result<()> { + let app_handle = app.clone(); + app.run_on_main_thread(move || remove_tray_icon(&app_handle))?; Ok(()) } @@ -1881,15 +1958,20 @@ fn shutdown_application(app_handle: AppHandle, overlay_force_close: Arc Result<()> { @@ -2220,35 +2302,75 @@ impl MonitorData { } } -fn persist_overlay_bounds(window: &WebviewWindow, store: &Arc) -> Result<()> { +fn defer_overlay_bounds_from_window( + window: &WebviewWindow, + store: &Arc, + generation: &Arc, +) -> Result<()> { let scale_factor = window.scale_factor().unwrap_or(1.0); let position = window.outer_position()?.to_logical::(scale_factor); let size = window.outer_size()?.to_logical::(scale_factor); - let bounds = OverlayBounds { - x: position.x, - y: position.y, - width: size.width, - height: size.height, - }; + defer_overlay_bounds( + store, + generation, + OverlayBounds { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + }, + None, + ) +} - let _ = store.update(|state| { - state.overlay_bounds = Some(bounds.clone()); +fn defer_overlay_bounds( + store: &Arc, + generation: &Arc, + bounds: OverlayBounds, + content_top_offset: Option, +) -> Result<()> { + store.update_deferred(move |state| { + state.overlay_bounds = Some(bounds); state.overlay_bounds_are_logical = true; + if let Some(offset) = content_top_offset { + state.overlay_last_content_top_offset = Some(offset); + } })?; + let scheduled_generation = generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1); + + let store = Arc::clone(store); + let generation = Arc::clone(generation); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_millis(OVERLAY_BOUNDS_DEBOUNCE_MS)).await; + if generation.load(Ordering::SeqCst) != scheduled_generation { + return; + } + if let Err(err) = store.flush() { + log::warn!("failed to flush debounced overlay bounds: {err}"); + } + }); + Ok(()) } +fn flush_deferred_overlay_bounds(store: &Arc, generation: &Arc) -> Result<()> { + generation.fetch_add(1, Ordering::SeqCst); + store.flush() +} + struct KeyboardDaemonTask { running: Arc, reader_handle: Option>, stderr_handle: Option>, + parent_stdin: Option, child: Option, } impl Drop for KeyboardDaemonTask { fn drop(&mut self) { self.running.store(false, Ordering::SeqCst); + self.parent_stdin.take(); if let Some(child) = self.child.as_mut() { if let Err(err) = child.kill() { @@ -2274,3 +2396,30 @@ struct OverlayPosition { x: f64, y: f64, } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::{bootstrap_active_keys, should_create_overlay_on_startup}; + use crate::keyboard::KeyboardManager; + + #[test] + fn startup_overlay_creation_covers_all_visibility_and_obs_combinations() { + assert!(!should_create_overlay_on_startup(false, false)); + assert!(should_create_overlay_on_startup(false, true)); + assert!(!should_create_overlay_on_startup(true, false)); + assert!(!should_create_overlay_on_startup(true, true)); + } + + #[test] + fn bootstrap_active_keys_include_registered_event_key_names() { + let manager = KeyboardManager::new( + HashMap::from([("4key".to_string(), vec!["KeyD".to_string()])]), + "4key", + ); + + assert!(manager.register_key_down("4key", "KeyD")); + assert_eq!(bootstrap_active_keys(&manager), vec!["KeyD"]); + } +} diff --git a/src-tauri/src/state/atomic_file.rs b/src-tauri/src/state/atomic_file.rs new file mode 100644 index 00000000..ceac637d --- /dev/null +++ b/src-tauri/src/state/atomic_file.rs @@ -0,0 +1,136 @@ +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use uuid::Uuid; + +pub(crate) struct PreparedAtomicReplace { + target_path: PathBuf, + temp_path: PathBuf, + committed: bool, +} + +impl PreparedAtomicReplace { + pub(crate) fn commit(mut self) -> Result<()> { + fs::rename(&self.temp_path, &self.target_path) + .with_context(|| format!("failed to replace file at {}", self.target_path.display()))?; + self.committed = true; + Ok(()) + } +} + +impl Drop for PreparedAtomicReplace { + fn drop(&mut self) { + if !self.committed { + let _ = fs::remove_file(&self.temp_path); + } + } +} + +pub(crate) fn prepare_atomic_replace( + path: &Path, + content: &[u8], + label: &str, +) -> Result { + let parent = path + .parent() + .context("failed to resolve file parent directory")?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .context("failed to resolve file name")?; + let temp_path = parent.join(format!(".{file_name}.{label}-{}.tmp", Uuid::new_v4())); + + let result = (|| { + let mut temp = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .with_context(|| format!("failed to create temp file at {}", temp_path.display()))?; + temp.write_all(content) + .with_context(|| format!("failed to write temp file at {}", temp_path.display()))?; + temp.sync_all() + .with_context(|| format!("failed to sync temp file at {}", temp_path.display()))?; + drop(temp); + + Ok(PreparedAtomicReplace { + target_path: path.to_path_buf(), + temp_path: temp_path.clone(), + committed: false, + }) + })(); + + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +pub(crate) fn atomic_replace(path: &Path, content: &[u8], label: &str) -> Result<()> { + prepare_atomic_replace(path, content, label)?.commit() +} + +#[cfg(all(test, unix))] +pub(crate) mod test_support { + use std::os::raw::c_int; + + const RLIMIT_FSIZE: c_int = 1; + const SIGXFSZ: c_int = 25; + const SIG_IGN: usize = 1; + + #[repr(C)] + #[derive(Clone, Copy)] + struct Rlimit { + current: u64, + maximum: u64, + } + + unsafe extern "C" { + fn getrlimit(resource: c_int, limit: *mut Rlimit) -> c_int; + fn setrlimit(resource: c_int, limit: *const Rlimit) -> c_int; + fn signal(signal: c_int, handler: usize) -> usize; + } + + pub(crate) struct FileSizeLimit { + previous_limit: Rlimit, + previous_handler: usize, + } + + impl FileSizeLimit { + pub(crate) fn set(bytes: u64) -> Self { + let mut previous_limit = Rlimit { + current: 0, + maximum: 0, + }; + let get_result = unsafe { getrlimit(RLIMIT_FSIZE, &mut previous_limit) }; + assert_eq!(get_result, 0, "getrlimit(RLIMIT_FSIZE) failed"); + assert!( + bytes <= previous_limit.maximum, + "requested file limit exceeds hard limit" + ); + + let previous_handler = unsafe { signal(SIGXFSZ, SIG_IGN) }; + let limited = Rlimit { + current: bytes, + maximum: previous_limit.maximum, + }; + let set_result = unsafe { setrlimit(RLIMIT_FSIZE, &limited) }; + assert_eq!(set_result, 0, "setrlimit(RLIMIT_FSIZE) failed"); + + Self { + previous_limit, + previous_handler, + } + } + } + + impl Drop for FileSizeLimit { + fn drop(&mut self) { + let _ = unsafe { setrlimit(RLIMIT_FSIZE, &self.previous_limit) }; + let _ = unsafe { signal(SIGXFSZ, self.previous_handler) }; + } + } +} diff --git a/src-tauri/src/state/local_asset_path.rs b/src-tauri/src/state/local_asset_path.rs new file mode 100644 index 00000000..3ba4d39c --- /dev/null +++ b/src-tauri/src/state/local_asset_path.rs @@ -0,0 +1,119 @@ +use std::path::PathBuf; + +use url::Url; + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum FileUrlPath { + NotFileUrl, + Path(PathBuf), + Invalid, +} + +pub(crate) fn file_url_to_path(value: &str) -> FileUrlPath { + let trimmed = value.trim(); + let Some((scheme, _)) = trimmed.split_once(':') else { + return FileUrlPath::NotFileUrl; + }; + if !scheme.eq_ignore_ascii_case("file") { + return FileUrlPath::NotFileUrl; + } + if !has_valid_percent_encoding(trimmed) { + return FileUrlPath::Invalid; + } + + let Ok(url) = Url::parse(trimmed) else { + return FileUrlPath::Invalid; + }; + match url.to_file_path() { + Ok(path) if path.is_absolute() => FileUrlPath::Path(path), + _ => FileUrlPath::Invalid, + } +} + +fn has_valid_percent_encoding(value: &str) -> bool { + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'%' { + index += 1; + continue; + } + let Some(high) = bytes.get(index + 1).and_then(|byte| hex_value(*byte)) else { + return false; + }; + let Some(low) = bytes.get(index + 2).and_then(|byte| hex_value(*byte)) else { + return false; + }; + if high == 0 && low == 0 { + return false; + } + index += 3; + } + true +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::{file_url_to_path, FileUrlPath}; + use std::path::PathBuf; + + #[cfg(not(target_os = "windows"))] + #[test] + fn decodes_file_url_without_treating_plus_as_space() { + assert_eq!( + file_url_to_path("file:///tmp/Application%20Support/%ED%95%9C%EA%B8%80%25+a.png"), + FileUrlPath::Path(PathBuf::from("/tmp/Application Support/한글%+a.png")) + ); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn accepts_localhost_file_url() { + assert_eq!( + file_url_to_path("FILE://localhost/tmp/dmnote.wav"), + FileUrlPath::Path(PathBuf::from("/tmp/dmnote.wav")) + ); + } + + #[test] + fn distinguishes_non_file_and_invalid_file_urls() { + assert_eq!( + file_url_to_path("https://example.com/a.png"), + FileUrlPath::NotFileUrl + ); + assert_eq!( + file_url_to_path("file://[invalid/path.png"), + FileUrlPath::Invalid + ); + assert_eq!( + file_url_to_path("file:///tmp/broken%ZZ.png"), + FileUrlPath::Invalid + ); + assert_eq!( + file_url_to_path("file:///tmp/nul%00.png"), + FileUrlPath::Invalid + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn decodes_windows_drive_and_unc_file_urls() { + assert_eq!( + file_url_to_path("file:///C:/Program%20Files/DM%20NOTE/a.wav"), + FileUrlPath::Path(PathBuf::from(r"C:\Program Files\DM NOTE\a.wav")) + ); + assert_eq!( + file_url_to_path("file://server/share/a%20b.wav"), + FileUrlPath::Path(PathBuf::from(r"\\server\share\a b.wav")) + ); + } +} diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index a3161697..056e2a59 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}, }; @@ -6,29 +7,39 @@ use std::{ 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::{de::DeserializeOwned, Deserialize, Serialize}; +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, + AppStoreData, CounterAnimationPreset, CustomCss, CustomFont, CustomJs, CustomTab, FontType, + GraphPosition, GraphPositions, GraphStatType, GraphType, GridSettings, JsPlugin, + KeyCounters, KeyMappings, KeyPosition, KeyPositions, KnobPosition, KnobPositions, + LayerGroupDef, LayerGroups, NoteSettings, OverlayBounds, ShortcutsState, SoundLibraryEntry, + StatPosition, StatPositions, StatType, TabCss, TabNoteSettings, }, }; 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 +57,56 @@ 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) + let semantic_repaired = repair_semantic_identities(&mut data); + needs_persist |= semantic_repaired; + let layout_repaired = repair_custom_tab_key_layout_pairs( + &mut data, + value.get("keys"), + value.get("keyPositions"), + ); + needs_persist |= layout_repaired; + needs_persist |= !has_valid_selected_key_type(&data); + ( + normalize_state(data), + needs_persist, + layout_repaired || semantic_repaired, + ) + } + 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 { @@ -101,6 +146,49 @@ fn migrate_legacy_knob_sensitivity(data: &mut AppStoreData) -> bool { changed } +/// 타입은 맞지만 실제 식별자로 쓸 수 없는 항목을 로드 경계에서만 정리 +fn repair_semantic_identities(data: &mut AppStoreData) -> bool { + let mut changed = false; + + for positions in data.knob_positions.values_mut() { + let original_len = positions.len(); + positions.retain(|knob| !knob.axis_id.trim().is_empty()); + changed |= positions.len() != original_len; + } + + let original_font_len = data.font_settings.custom_fonts.len(); + data.font_settings + .custom_fonts + .retain(|font| !font.id.trim().is_empty() && !font.name.trim().is_empty()); + changed |= data.font_settings.custom_fonts.len() != original_font_len; + + for font in data.font_settings.custom_fonts.iter_mut() { + if font.font_type != FontType::Local { + continue; + } + + let invalid_path = font + .local_path + .as_ref() + .is_some_and(|path| path.trim().is_empty() || !Path::new(path.trim()).is_absolute()); + if invalid_path { + font.local_path = None; + changed = true; + } + + let has_embedded_source = font + .css_content + .as_deref() + .is_some_and(|content| !content.trim().is_empty()); + if font.local_path.is_none() && !has_embedded_source && font.enabled { + font.enabled = false; + changed = true; + } + } + + changed +} + /// 레거시 store 파일 경로 탐색 pub(crate) fn find_legacy_store_file() -> Option { // 고정된 레거시 경로: %APPDATA%/dm-note/config.json @@ -148,9 +236,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 +249,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 +273,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 +334,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 +388,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 } @@ -428,7 +563,7 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { } } - if !data.keys.contains_key(&data.selected_key_type) { + if !has_valid_selected_key_type(&data) { data.selected_key_type = "4key".to_string(); } @@ -437,6 +572,92 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { data } +fn has_valid_selected_key_type(data: &AppStoreData) -> bool { + default_keys().contains_key(&data.selected_key_type) + || (data.keys.contains_key(&data.selected_key_type) + && data + .custom_tabs + .iter() + .any(|tab| tab.id == data.selected_key_type)) +} + +/// 그룹 정의가 사라진 요소의 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,345 +688,2053 @@ 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, - }); - } - } - if let Some(v) = obj - .get("overlayLastContentTopOffset") - .and_then(Value::as_f64) - { - data.overlay_last_content_top_offset = Some(v); - } - if let Some(v) = obj.get("keyCounterEnabled").and_then(Value::as_bool) { - data.key_counter_enabled = v; +/// 레거시/비정상 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 source_keys = source.get("keys").cloned(); + let source_key_positions = source.get("keyPositions").cloned(); + + 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.clone()); + if serde_json::from_value::(Value::Object(recovered.clone())).is_err() { + if let Some(partial) = recover_collection_field(&field, &value) { + recovered.insert(field.clone(), partial); + if serde_json::from_value::(Value::Object(recovered.clone())).is_ok() + { + continue; + } + } + + log::warn!("[Store] Resetting invalid field to default: {field}"); + match previous { + Some(value) => { + recovered.insert(field, value); + } + None => { + recovered.remove(&field); + } + } } } - let _ = data.custom_js.normalize(); + + let mut data = + serde_json::from_value::(Value::Object(recovered)).unwrap_or_default(); + migrate_legacy_knob_sensitivity(&mut data); + repair_semantic_identities(&mut data); + repair_custom_tab_key_layout_pairs( + &mut data, + source_keys.as_ref(), + source_key_positions.as_ref(), + ); normalize_state(data) } -fn option_has_non_empty_text(value: &Option) -> bool { - value - .as_ref() - .map(|text| !text.trim().is_empty()) - .unwrap_or(false) -} +fn repair_custom_tab_key_layout_pairs( + data: &mut AppStoreData, + source_keys: Option<&Value>, + source_key_positions: Option<&Value>, +) -> bool { + let tab_ids = data + .custom_tabs + .iter() + .map(|tab| tab.id.clone()) + .collect::>(); + let mut repaired = false; -fn decode_image_data_url(value: &str) -> Option<(Vec, String)> { - let (header, payload) = value.split_once(',')?; - let header_lower = header.to_ascii_lowercase(); - if !header_lower.starts_with("data:image/") || !header_lower.contains(";base64") { - return None; + for mode in tab_ids { + let source_keys = source_mode_array(source_keys, &mode); + let source_positions = source_mode_array(source_key_positions, &mode); + + match (source_keys, source_positions) { + (Some(_), Some(_)) => {} + (None, Some(positions)) => { + log::warn!( + "[Store] Rebuilding invalid keys mode '{mode}' with {} unassigned entries during recovery", + positions.len() + ); + data.keys.insert(mode, vec![String::new(); positions.len()]); + repaired = true; + } + (Some(keys), None) => { + log::warn!( + "[Store] Rebuilding invalid keyPositions mode '{mode}' with {} default entries during recovery", + keys.len() + ); + data.key_positions + .insert(mode, vec![KeyPosition::default(); keys.len()]); + repaired = true; + } + (None, None) => { + log::warn!( + "[Store] Initializing missing keys and keyPositions modes '{mode}' during recovery" + ); + data.keys.insert(mode.clone(), Vec::new()); + data.key_positions.insert(mode, Vec::new()); + repaired = true; + } + } } - let mime = header - .split(';') - .next() - .and_then(|part| part.strip_prefix("data:")) - .unwrap_or("image/png"); - let extension = extension_from_image_mime(mime); - let bytes = BASE64_STANDARD.decode(payload.as_bytes()).ok()?; - Some((bytes, extension)) + repaired } -fn extension_from_image_mime(mime: &str) -> String { - match mime.trim().to_ascii_lowercase().as_str() { - "image/jpeg" | "image/jpg" => "jpg".to_string(), - "image/png" => "png".to_string(), - "image/webp" => "webp".to_string(), - "image/gif" => "gif".to_string(), - "image/bmp" => "bmp".to_string(), - "image/svg+xml" => "svg".to_string(), - "image/x-icon" | "image/vnd.microsoft.icon" => "ico".to_string(), - "image/avif" => "avif".to_string(), - _ => "png".to_string(), +fn source_mode_array<'a>(collection: Option<&'a Value>, mode: &str) -> Option<&'a [Value]> { + collection? + .as_object()? + .get(mode)? + .as_array() + .map(Vec::as_slice) +} + +fn recover_collection_field(field: &str, value: &Value) -> Option { + match field { + "noteSettings" => recover_object_fields::(field, value), + "customTabs" => recover_array_entries::(field, value), + "keys" => recover_key_mapping_entries(value), + "soundLibrary" => recover_sound_library_entries(value), + "keyPositions" => recover_key_position_entries(field, value), + "statPositions" => recover_key_position_backed_entries::( + field, + value, + has_valid_stat_identity, + ), + "graphPositions" => recover_key_position_backed_entries::( + field, + value, + has_valid_graph_identity, + ), + "knobPositions" => recover_key_position_backed_entries::( + field, + value, + has_valid_knob_identity, + ), + "layerGroups" => recover_position_entries::(field, value), + "keyCounters" => recover_key_counter_entries(value), + "customCss" => recover_object_fields::(field, value), + "fontSettings" => recover_font_settings(value), + "counterAnimationPresets" => recover_array_entries::(field, value), + "tabCssOverrides" => recover_map_object_entries::(field, value), + "tabNoteOverrides" => recover_map_object_entries::(field, value), + "customJs" => recover_custom_js(value), + "gridSettings" => recover_object_fields::(field, value), + "shortcuts" => recover_object_fields::(field, value), + _ => None, } } -fn normalize_image_extension(extension: Option<&str>) -> String { - match extension - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str() - { - "jpg" | "jpeg" => "jpg".to_string(), - "png" => "png".to_string(), - "webp" => "webp".to_string(), - "gif" => "gif".to_string(), - "bmp" => "bmp".to_string(), - "svg" => "svg".to_string(), - "ico" => "ico".to_string(), - "avif" => "avif".to_string(), - _ => "png".to_string(), +fn recover_object_fields(field: &str, value: &Value) -> Option +where + T: Default + DeserializeOwned + Serialize, +{ + let Value::Object(source) = value else { + return None; + }; + let Value::Object(mut recovered) = serde_json::to_value(T::default()).ok()? else { + return None; + }; + + for (name, entry) in source { + let previous = recovered.insert(name.clone(), entry.clone()); + if serde_json::from_value::(Value::Object(recovered.clone())).is_err() { + log::warn!( + "[Store] Resetting invalid {field} child '{name}' to its default during recovery" + ); + match previous { + Some(value) => { + recovered.insert(name.clone(), value); + } + None => { + recovered.remove(name); + } + } + } } + Some(Value::Object(recovered)) } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct LegacyOverlayBounds { - x: f64, - y: f64, - width: f64, - height: f64, +fn recover_array_entries(field: &str, value: &Value) -> Option +where + T: DeserializeOwned, +{ + let Value::Array(entries) = value else { + return None; + }; + + let mut recovered = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + match serde_json::from_value::(entry.clone()) { + Ok(_) => recovered.push(entry.clone()), + Err(err) => { + log::warn!( + "[Store] Removing invalid {field} entry '[{index}]' during recovery: {err}" + ); + } + } + } + Some(Value::Array(recovered)) } -#[derive(Deserialize)] -struct LegacyOverlayPosition { - x: f64, - y: f64, +fn recover_key_mapping_entries(value: &Value) -> Option { + let Value::Object(modes) = value else { + return None; + }; + + let defaults = default_keys(); + let mut recovered_modes = Map::new(); + for (mode, entries) in modes { + let recovered_entries = match entries { + Value::Array(entries) => entries + .iter() + .enumerate() + .map(|(index, entry)| match entry.as_str() { + Some(key) => Value::String(key.to_string()), + None => { + log::warn!( + "[Store] Replacing invalid keys entry '{mode}[{index}]' with an unassigned key during recovery" + ); + Value::String(String::new()) + } + }) + .collect(), + _ => { + log::warn!( + "[Store] Replacing invalid keys mode '{mode}' during recovery: expected an array" + ); + defaults + .get(mode) + .map(|keys| keys.iter().cloned().map(Value::String).collect()) + .unwrap_or_default() + } + }; + recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); + } + Some(Value::Object(recovered_modes)) } -#[cfg(test)] -mod tests { - use super::{load_store_from_path, migrate_sound_library_enabled, rgba_to_hex}; - use crate::models::AppStoreData; - use serde_json::{json, Value}; +fn recover_key_counter_entries(value: &Value) -> Option { + let Value::Object(modes) = value else { + return None; + }; - const TEST_SOUND_PATH: &str = "/tmp/test-sound.wav"; + let mut recovered_modes = Map::new(); + for (mode, counters) in modes { + let Value::Object(counters) = counters else { + log::warn!( + "[Store] Replacing invalid keyCounters mode '{mode}' with an empty map during recovery" + ); + recovered_modes.insert(mode.clone(), Value::Object(Map::new())); + continue; + }; - fn load_store_with_sound_entry(entry: Value) -> (AppStoreData, bool) { - let path = std::env::temp_dir().join(format!( - "dmnote-sound-migration-test-{}.json", + let mut recovered_counters = Map::new(); + for (key, count) in counters { + if serde_json::from_value::(count.clone()).is_ok() { + recovered_counters.insert(key.clone(), count.clone()); + } else { + log::warn!( + "[Store] Removing invalid keyCounters entry '{mode}.{key}' during recovery" + ); + } + } + recovered_modes.insert(mode.clone(), Value::Object(recovered_counters)); + } + Some(Value::Object(recovered_modes)) +} + +fn recover_custom_js(value: &Value) -> Option { + let Value::Object(settings) = value else { + return None; + }; + let mut candidate = settings.clone(); + if let Some(plugins) = settings.get("plugins") { + let recovered_plugins = recover_array_entries::("customJs.plugins", plugins) + .unwrap_or_else(|| Value::Array(Vec::new())); + candidate.insert("plugins".to_string(), recovered_plugins); + } + recover_object_fields::("customJs", &Value::Object(candidate)) +} + +fn recover_sound_library_entries(value: &Value) -> Option { + let Value::Object(entries) = value else { + return None; + }; + let default_entry = serde_json::to_value(SoundLibraryEntry::default()).ok()?; + let mut recovered = Map::new(); + + for (key, entry) in entries { + if serde_json::from_value::(entry.clone()).is_ok() { + recovered.insert(key.clone(), entry.clone()); + continue; + } + + let entry_name = format!("soundLibrary.{key}"); + if let Some(partial) = recover_object_fields::(&entry_name, entry) { + if serde_json::from_value::(partial.clone()).is_ok() { + recovered.insert(key.clone(), partial); + continue; + } + } + + if Path::new(key).is_absolute() { + log::warn!( + "[Store] Rebuilding invalid soundLibrary entry '{key}' from its absolute path during recovery" + ); + recovered.insert(key.clone(), default_entry.clone()); + } else { + log::warn!( + "[Store] Removing invalid soundLibrary entry '{key}' with a non-absolute path during recovery" + ); + } + } + + Some(Value::Object(recovered)) +} + +fn recover_map_object_entries(field: &str, value: &Value) -> Option +where + T: Default + DeserializeOwned + Serialize, +{ + let Value::Object(entries) = value else { + return None; + }; + + let mut recovered = Map::new(); + for (key, entry) in entries { + if serde_json::from_value::(entry.clone()).is_ok() { + recovered.insert(key.clone(), entry.clone()); + continue; + } + + let entry_name = format!("{field}.{key}"); + let Some(partial) = recover_object_fields::(&entry_name, entry) else { + log::warn!("[Store] Removing invalid {field} entry '{key}' during recovery"); + continue; + }; + if serde_json::from_value::(partial.clone()).is_ok() { + recovered.insert(key.clone(), partial); + } else { + log::warn!("[Store] Removing unrecoverable {field} entry '{key}' during recovery"); + } + } + Some(Value::Object(recovered)) +} + +fn recover_key_position_backed_entries( + field: &str, + value: &Value, + has_valid_identity: fn(&Value) -> bool, +) -> Option +where + T: DeserializeOwned, +{ + let Value::Object(modes) = value else { + return None; + }; + + let mut recovered_modes = Map::new(); + for (mode, entries) in modes { + let Value::Array(entries) = entries else { + log::warn!( + "[Store] Removing invalid {field} mode '{mode}' during recovery: expected an array" + ); + continue; + }; + + let mut recovered_entries = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + if serde_json::from_value::(entry.clone()).is_ok() { + recovered_entries.push(entry.clone()); + continue; + } + + if !has_valid_identity(entry) { + log::warn!( + "[Store] Removing invalid {field} entry '{mode}[{index}]' with a damaged identity during recovery" + ); + continue; + } + + let entry_name = format!("{field}.{mode}[{index}]"); + let Some(partial) = recover_object_fields::(&entry_name, entry) else { + log::warn!( + "[Store] Removing invalid {field} entry '{mode}[{index}]' during recovery" + ); + continue; + }; + if serde_json::from_value::(partial.clone()).is_ok() { + recovered_entries.push(partial); + } else { + log::warn!( + "[Store] Removing invalid {field} entry '{mode}[{index}]' with damaged non-layout fields during recovery" + ); + } + } + recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); + } + + Some(Value::Object(recovered_modes)) +} + +fn value_field_deserializes(value: &Value, field: &str) -> bool +where + T: DeserializeOwned, +{ + value + .get(field) + .is_some_and(|entry| serde_json::from_value::(entry.clone()).is_ok()) +} + +fn has_valid_stat_identity(value: &Value) -> bool { + value_field_deserializes::(value, "statType") +} + +fn has_valid_graph_identity(value: &Value) -> bool { + value_field_deserializes::(value, "statType") + && value_field_deserializes::(value, "graphType") +} + +fn has_valid_knob_identity(value: &Value) -> bool { + value + .get("axisId") + .and_then(Value::as_str) + .is_some_and(|axis_id| !axis_id.trim().is_empty()) +} + +fn recover_key_position_entries(field: &str, value: &Value) -> Option { + let Value::Object(modes) = value else { + return None; + }; + let default_position = match serde_json::to_value(KeyPosition::default()) { + Ok(value) => value, + Err(err) => { + log::warn!("[Store] Failed to serialize the default {field} entry: {err}"); + return None; + } + }; + + let mut recovered_modes = Map::new(); + for (mode, entries) in modes { + let Value::Array(entries) = entries else { + log::warn!( + "[Store] Removing invalid {field} mode '{mode}' during recovery: expected an array" + ); + continue; + }; + + let mut recovered_entries = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + match serde_json::from_value::(entry.clone()) { + Ok(_) => recovered_entries.push(entry.clone()), + Err(err) => { + let entry_name = format!("{field}.{mode}[{index}]"); + let recovered = recover_object_fields::(&entry_name, entry) + .filter(|candidate| { + serde_json::from_value::(candidate.clone()).is_ok() + }) + .unwrap_or_else(|| { + log::warn!( + "[Store] Replacing invalid {field} entry '{mode}[{index}]' with default during recovery: {err}" + ); + default_position.clone() + }); + recovered_entries.push(recovered); + } + } + } + recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); + } + Some(Value::Object(recovered_modes)) +} + +fn recover_position_entries(field: &str, value: &Value) -> Option +where + T: DeserializeOwned, +{ + let Value::Object(modes) = value else { + return None; + }; + + let mut recovered_modes = Map::new(); + for (mode, entries) in modes { + let Value::Array(entries) = entries else { + log::warn!( + "[Store] Removing invalid {field} mode '{mode}' during recovery: expected an array" + ); + continue; + }; + + let mut recovered_entries = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + match serde_json::from_value::(entry.clone()) { + Ok(_) => recovered_entries.push(entry.clone()), + Err(err) => { + log::warn!( + "[Store] Removing invalid {field} entry '{mode}[{index}]' during recovery: {err}" + ); + } + } + } + recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); + } + Some(Value::Object(recovered_modes)) +} + +fn recover_font_settings(value: &Value) -> Option { + let Value::Object(settings) = value else { + return None; + }; + let mut recovered = settings.clone(); + let Some(custom_fonts) = settings.get("customFonts") else { + return Some(Value::Object(recovered)); + }; + let Value::Array(custom_fonts) = custom_fonts else { + log::warn!( + "[Store] Removing invalid fontSettings.customFonts collection during recovery: expected an array" + ); + recovered.insert("customFonts".to_string(), Value::Array(Vec::new())); + return Some(Value::Object(recovered)); + }; + + let mut recovered_fonts = Vec::with_capacity(custom_fonts.len()); + for (index, font) in custom_fonts.iter().enumerate() { + match serde_json::from_value::(font.clone()) { + Ok(_) => recovered_fonts.push(font.clone()), + Err(err) => { + if let Some(recovered) = recover_local_font_enabled(font) { + log::warn!( + "[Store] Disabling fontSettings.customFonts entry '[{index}]' with an invalid enabled field during recovery" + ); + recovered_fonts.push(recovered); + } else { + log::warn!( + "[Store] Removing invalid fontSettings.customFonts entry '[{index}]' during recovery: {err}" + ); + } + } + } + } + recovered.insert("customFonts".to_string(), Value::Array(recovered_fonts)); + Some(Value::Object(recovered)) +} + +fn recover_local_font_enabled(value: &Value) -> Option { + let Value::Object(source) = value else { + return None; + }; + if source.get("enabled").is_some_and(Value::is_boolean) { + return None; + } + let id = source.get("id").and_then(Value::as_str)?; + let name = source.get("name").and_then(Value::as_str)?; + if id.trim().is_empty() || name.trim().is_empty() { + return None; + } + let font_type = source + .get("type") + .and_then(|font_type| serde_json::from_value::(font_type.clone()).ok())?; + if font_type != FontType::Local { + return None; + } + let local_path = source.get("localPath").and_then(Value::as_str)?; + if local_path.trim().is_empty() || !Path::new(local_path).is_absolute() { + return None; + } + + let mut candidate = source.clone(); + candidate.insert("enabled".to_string(), Value::Bool(false)); + serde_json::from_value::(Value::Object(candidate.clone())).ok()?; + Some(Value::Object(candidate)) +} + +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(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, + }), + ); + } + } +} + +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 { + value + .as_ref() + .map(|text| !text.trim().is_empty()) + .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(); + if !header_lower.starts_with("data:image/") || !header_lower.contains(";base64") { + return None; + } + + let mime = header + .split(';') + .next() + .and_then(|part| part.strip_prefix("data:")) + .unwrap_or("image/png"); + let extension = extension_from_image_mime(mime); + let bytes = BASE64_STANDARD.decode(payload.as_bytes()).ok()?; + Some((bytes, extension)) +} + +fn extension_from_image_mime(mime: &str) -> String { + match mime.trim().to_ascii_lowercase().as_str() { + "image/jpeg" | "image/jpg" => "jpg".to_string(), + "image/png" => "png".to_string(), + "image/webp" => "webp".to_string(), + "image/gif" => "gif".to_string(), + "image/bmp" => "bmp".to_string(), + "image/svg+xml" => "svg".to_string(), + "image/x-icon" | "image/vnd.microsoft.icon" => "ico".to_string(), + "image/avif" => "avif".to_string(), + _ => "png".to_string(), + } +} + +fn normalize_image_extension(extension: Option<&str>) -> String { + match extension + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "jpg" | "jpeg" => "jpg".to_string(), + "png" => "png".to_string(), + "webp" => "webp".to_string(), + "gif" => "gif".to_string(), + "bmp" => "bmp".to_string(), + "svg" => "svg".to_string(), + "ico" => "ico".to_string(), + "avif" => "avif".to_string(), + _ => "png".to_string(), + } +} + +#[derive(Deserialize)] +struct LegacyOverlayPosition { + x: f64, + y: f64, +} + +#[cfg(test)] +mod tests { + use super::{ + load_store_from_path, migrate_local_fonts_to_app_data, migrate_sound_library_enabled, + normalize_state, rgba_to_hex, LEGACY_OVERLAY_HEIGHT, LEGACY_OVERLAY_WIDTH, + }; + use crate::{ + defaults::{default_keys, default_positions}, + models::{ + AppStoreData, CustomFont, CustomTab, FontType, GraphPosition, GraphStatType, GraphType, + KeyPosition, KnobPosition, LayerGroupDef, OverlayBounds, SoundLibraryEntry, + StatPosition, StatType, 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); + } + + #[test] + fn normal_load_repairs_semantically_invalid_font_and_knob_identities() { + let path = std::env::temp_dir().join(format!( + "dmnote-semantic-identity-load-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = AppStoreData::default(); + data.font_settings.custom_fonts = vec![ + CustomFont { + id: " ".to_string(), + font_type: FontType::Web, + name: "Invalid Font".to_string(), + display_name: "Invalid Font".to_string(), + enabled: true, + local_path: None, + css_content: Some("@font-face {}".to_string()), + }, + 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("relative/font.ttf".to_string()), + css_content: None, + }, + ]; + data.knob_positions.insert( + "4key".to_string(), + vec![ + KnobPosition { + axis_id: " ".to_string(), + sensitivity: 1.0, + reverse: false, + position: default_positions()["4key"][0].clone(), + }, + KnobPosition { + axis_id: "axis-valid".to_string(), + sensitivity: 1.0, + reverse: false, + position: default_positions()["4key"][1].clone(), + }, + ], + ); + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.font_settings.custom_fonts.len(), 1); + let local = &loaded.data.font_settings.custom_fonts[0]; + assert_eq!(local.id, "local-font"); + assert!(!local.enabled); + assert_eq!(local.local_path, None); + assert_eq!(loaded.data.knob_positions["4key"].len(), 1); + assert_eq!(loaded.data.knob_positions["4key"][0].axis_id, "axis-valid"); + + let _ = std::fs::remove_file(path); + } + use serde_json::{json, Value}; + + const TEST_SOUND_PATH: &str = "/tmp/test-sound.wav"; + + fn load_store_with_sound_entry(entry: Value) -> (AppStoreData, bool) { + let path = std::env::temp_dir().join(format!( + "dmnote-sound-migration-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut value = serde_json::to_value(AppStoreData::default()).unwrap(); + value.as_object_mut().unwrap().insert( + "soundLibrary".to_string(), + json!({ TEST_SOUND_PATH: entry }), + ); + std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + (loaded.data, loaded.needs_persist) + } + + #[test] + fn sound_library_enabled_false_migrates_to_hidden_true() { + let mut value = json!({ + "soundLibrary": { + TEST_SOUND_PATH: { "enabled": false } + } + }); + assert!(migrate_sound_library_enabled(&mut value)); + let entry = &value["soundLibrary"][TEST_SOUND_PATH]; + assert_eq!(entry["hidden"], true); + assert!(entry.get("enabled").is_none()); + + let (data, needs_persist) = load_store_with_sound_entry(json!({ "enabled": false })); + assert!(data.sound_library[TEST_SOUND_PATH].hidden); + assert!(needs_persist); + } + + #[test] + fn sound_library_enabled_true_migrates_to_hidden_false() { + let (data, needs_persist) = load_store_with_sound_entry(json!({ "enabled": true })); + assert!(!data.sound_library[TEST_SOUND_PATH].hidden); + assert!(needs_persist); + } + + #[test] + fn sound_library_without_enabled_is_unchanged() { + let mut value = json!({ + "soundLibrary": { + TEST_SOUND_PATH: { "source": "local" } + } + }); + let original = value.clone(); + assert!(!migrate_sound_library_enabled(&mut value)); + assert_eq!(value, original); + + let (data, needs_persist) = load_store_with_sound_entry(json!({ "source": "local" })); + assert!(!data.sound_library[TEST_SOUND_PATH].hidden); + assert!(!needs_persist); + } + + #[test] + fn sound_library_hidden_takes_precedence_over_enabled() { + let mut value = json!({ + "soundLibrary": { + TEST_SOUND_PATH: { "hidden": false, "enabled": false } + } + }); + assert!(migrate_sound_library_enabled(&mut value)); + let entry = &value["soundLibrary"][TEST_SOUND_PATH]; + assert_eq!(entry["hidden"], false); + assert!(entry.get("enabled").is_none()); + + let (data, needs_persist) = + load_store_with_sound_entry(json!({ "hidden": false, "enabled": false })); + assert!(!data.sound_library[TEST_SOUND_PATH].hidden); + assert!(needs_persist); + } + + #[test] + fn rgba_to_hex_converts_and_drops_alpha() { + assert_eq!( + rgba_to_hex("rgba(255, 0, 167, 1)").as_deref(), + Some("#FF00A7") + ); + assert_eq!( + rgba_to_hex("rgba(18, 52, 86, 0)").as_deref(), + Some("#123456") + ); + } + + #[test] + fn rgba_to_hex_ignores_non_rgba() { + assert_eq!(rgba_to_hex("#FF00A7"), None); + 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 electron_1_2_store_preserves_tabs_keys_settings_and_window_position() { + let path = std::env::temp_dir().join(format!( + "dmnote-electron-1-2-store-test-{}.json", + uuid::Uuid::new_v4() + )); + let fixture = json!({ + "hardwareAcceleration": false, + "alwaysOnTop": false, + "overlayLocked": true, + "noteEffect": true, + "noteSettings": { + "borderRadius": 7, + "speed": 321, + "trackHeight": 123, + "reverse": true, + "fadePosition": "bottom", + "delayedNoteEnabled": true, + "shortNoteThresholdMs": 67, + "shortNoteMinLengthPx": 43 + }, + "selectedKeyType": "legacy-tab", + "customTabs": [{ "id": "legacy-tab", "name": "Legacy tab" }], + "angleMode": "d3d11", + "language": "en", + "laboratoryEnabled": true, + "keys": { "legacy-tab": ["A", "B"] }, + "keyPositions": { + "legacy-tab": [ + { + "dx": 777, + "dy": 88, + "width": 60, + "height": 60, + "activeImage": "", + "inactiveImage": "", + "count": 42, + "noteColor": "#ABCDEF", + "noteOpacity": 73 + }, + { + "dx": 888, + "dy": 99, + "width": 75, + "height": 65, + "activeImage": "", + "inactiveImage": "", + "count": 24, + "noteColor": "#FEDCBA", + "noteOpacity": 64 + } + ] + }, + "backgroundColor": "#123456", + "useCustomCSS": true, + "customCSS": { "path": "/tmp/legacy.css", "content": ".legacy {}" }, + "overlayWindowPosition": { "x": 17, "y": 29 } + }); + 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); + + assert!(loaded.repaired); + assert_eq!(loaded.data.selected_key_type, "legacy-tab"); + assert_eq!( + loaded.data.custom_tabs, + vec![CustomTab { + id: "legacy-tab".to_string(), + name: "Legacy tab".to_string(), + }] + ); + assert_eq!(loaded.data.keys["legacy-tab"], vec!["A", "B"]); + assert_eq!(loaded.data.key_positions["legacy-tab"].len(), 2); + assert_eq!(loaded.data.key_positions["legacy-tab"][0].dx, 777.0); + assert_eq!(loaded.data.key_positions["legacy-tab"][1].dx, 888.0); + assert_eq!(loaded.data.note_settings.speed, 321); + assert_eq!(loaded.data.note_settings.track_height, 123); + assert!(loaded.data.note_settings.reverse); + assert!(loaded.data.use_custom_css); + assert_eq!( + loaded.data.custom_css.path.as_deref(), + Some("/tmp/legacy.css") + ); + assert_eq!(loaded.data.background_color, "#123456"); + assert_eq!( + loaded.data.overlay_bounds, + Some(OverlayBounds { + x: 17.0, + y: 29.0, + width: LEGACY_OVERLAY_WIDTH, + height: LEGACY_OVERLAY_HEIGHT, + }) + ); + } + + #[test] + fn tauri_1_3_store_preserves_custom_layout_without_repair_fallback() { + let path = std::env::temp_dir().join(format!( + "dmnote-tauri-1-3-store-test-{}.json", uuid::Uuid::new_v4() )); - let mut value = serde_json::to_value(AppStoreData::default()).unwrap(); - value.as_object_mut().unwrap().insert( - "soundLibrary".to_string(), - json!({ TEST_SOUND_PATH: entry }), + let fixture = json!({ + "hardwareAcceleration": false, + "alwaysOnTop": false, + "overlayLocked": true, + "noteEffect": true, + "noteSettings": { + "borderRadius": 9, + "speed": 456, + "trackHeight": 222, + "reverse": true, + "fadePosition": "bottom", + "delayedNoteEnabled": true, + "shortNoteThresholdMs": 73, + "shortNoteMinLengthPx": 41 + }, + "selectedKeyType": "tauri-legacy-tab", + "customTabs": [{ "id": "tauri-legacy-tab", "name": "Tauri legacy" }], + "angleMode": "metal", + "language": "ko", + "laboratoryEnabled": true, + "keys": { "tauri-legacy-tab": ["Q"] }, + "keyPositions": { + "tauri-legacy-tab": [{ + "dx": 654, + "dy": 87, + "width": 91, + "height": 63, + "activeImage": "/tmp/legacy-active.png", + "inactiveImage": "/tmp/legacy-idle.png", + "count": 19, + "noteColor": "#13579B", + "noteOpacity": 76 + }] + }, + "keyCounters": { "tauri-legacy-tab": { "Q": 19 } }, + "backgroundColor": "#2468AC", + "useCustomCss": true, + "customCss": { "path": "/tmp/tauri-legacy.css", "content": ".tauri {}" }, + "overlayResizeAnchor": "top-left", + "overlayBounds": { "x": 31, "y": 47, "width": 911, "height": 333 }, + "overlayLastContentTopOffset": 12.5, + "overlayBoundsAreLogical": false, + "keyCounterEnabled": true + }); + 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); + + assert!(!loaded.repaired); + assert_eq!(loaded.data.selected_key_type, "tauri-legacy-tab"); + assert_eq!(loaded.data.keys["tauri-legacy-tab"], vec!["Q"]); + assert_eq!(loaded.data.key_positions["tauri-legacy-tab"].len(), 1); + let position = &loaded.data.key_positions["tauri-legacy-tab"][0]; + assert_eq!(position.dx, 654.0); + assert_eq!(position.width, 91.0); + assert_eq!( + position.active_image.as_deref(), + Some("/tmp/legacy-active.png") ); - std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + assert_eq!(position.note_border_radius, Some(9.0)); + assert_eq!(loaded.data.key_counters["tauri-legacy-tab"]["Q"], 19); + assert_eq!(loaded.data.note_settings.speed, 456); + assert_eq!(loaded.data.background_color, "#2468AC"); + assert!(loaded.data.use_custom_css); + } + + #[test] + fn custom_tabs_and_keys_recover_without_compacting_parallel_arrays() { + let path = std::env::temp_dir().join(format!( + "dmnote-tab-key-entry-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let alpha_tab = CustomTab { + id: "alpha-tab".to_string(), + name: "Alpha".to_string(), + }; + let beta_tab = CustomTab { + id: "beta-tab".to_string(), + name: "Beta".to_string(), + }; + let empty_mode_tab = CustomTab { + id: "empty-mode".to_string(), + name: "Empty mode".to_string(), + }; + let mut positions = vec![default_positions()["4key"][0].clone(); 5]; + for (index, position) in positions.iter_mut().enumerate() { + position.dx = ((index + 1) * 101) as f64; + } + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "customTabs".to_string(), + json!([ + serde_json::to_value(&alpha_tab).unwrap(), + 42, + serde_json::to_value(&beta_tab).unwrap(), + serde_json::to_value(&empty_mode_tab).unwrap() + ]), + ); + fields.insert("selectedKeyType".to_string(), json!("alpha-tab")); + fields.insert( + "keys".to_string(), + json!({ + "4key": 42, + "alpha-tab": [42, "A", null, "C", {}], + "beta-tab": ["D"], + "empty-mode": 42, + }), + ); + fields.insert( + "keyPositions".to_string(), + json!({ + "alpha-tab": serde_json::to_value(&positions).unwrap(), + "beta-tab": [serde_json::to_value(&positions[0]).unwrap()], + "empty-mode": [], + }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).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 + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!( + loaded.data.custom_tabs, + vec![alpha_tab, beta_tab, empty_mode_tab] + ); + assert_eq!(loaded.data.selected_key_type, "alpha-tab"); + assert_eq!( + loaded.data.keys["alpha-tab"], + vec![ + String::new(), + "A".to_string(), + String::new(), + "C".to_string(), + String::new(), + ] + ); + assert_eq!(loaded.data.keys["beta-tab"], vec!["D".to_string()]); + assert!(loaded.data.keys["empty-mode"].is_empty()); + assert_eq!(loaded.data.keys["4key"], default_keys()["4key"]); + assert_eq!( + loaded.data.key_positions["alpha-tab"] + .iter() + .map(|position| position.dx) + .collect::>(), + vec![101.0, 202.0, 303.0, 404.0, 505.0] + ); } #[test] - fn sound_library_enabled_false_migrates_to_hidden_true() { - let mut value = json!({ - "soundLibrary": { - TEST_SOUND_PATH: { "enabled": false } - } + fn custom_tab_whole_mode_damage_recovers_parallel_shape_only() { + let path = std::env::temp_dir().join(format!( + "dmnote-tab-mode-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut first_position = default_positions()["4key"][0].clone(); + first_position.dx = 111.0; + let mut second_position = default_positions()["4key"][1].clone(); + second_position.dx = 222.0; + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "customTabs".to_string(), + json!([ + { "id": "keys-damaged", "name": "Keys damaged" }, + { "id": "positions-damaged", "name": "Positions damaged" }, + { "id": "valid-mismatch", "name": "Valid mismatch" }, + { "id": "missing-both", "name": "Missing both" } + ]), + ); + fields.insert("selectedKeyType".to_string(), json!("positions-damaged")); + fields.insert( + "keys".to_string(), + json!({ + "keys-damaged": 42, + "positions-damaged": ["A", "B", "C"], + "valid-mismatch": ["Q"] + }), + ); + fields.insert( + "keyPositions".to_string(), + json!({ + "keys-damaged": [ + serde_json::to_value(&first_position).unwrap(), + serde_json::to_value(&second_position).unwrap() + ], + "positions-damaged": 42, + "valid-mismatch": [ + serde_json::to_value(&first_position).unwrap(), + serde_json::to_value(&second_position).unwrap() + ] + }), + ); + 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); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.selected_key_type, "positions-damaged"); + assert_eq!( + loaded.data.keys["keys-damaged"], + vec![String::new(), String::new()] + ); + assert_eq!( + loaded.data.key_positions["keys-damaged"] + .iter() + .map(|position| position.dx) + .collect::>(), + vec![111.0, 222.0] + ); + assert_eq!(loaded.data.keys["positions-damaged"], vec!["A", "B", "C"]); + assert_eq!( + loaded.data.key_positions["positions-damaged"], + vec![KeyPosition::default(); 3] + ); + assert_eq!(loaded.data.keys["valid-mismatch"], vec!["Q"]); + assert_eq!(loaded.data.key_positions["valid-mismatch"].len(), 2); + assert!(loaded.data.keys["missing-both"].is_empty()); + assert!(loaded.data.key_positions["missing-both"].is_empty()); + } + + #[test] + fn custom_tab_missing_modes_are_repaired_on_an_otherwise_valid_store() { + let path = std::env::temp_dir().join(format!( + "dmnote-tab-missing-modes-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut fixture = AppStoreData::default(); + fixture.custom_tabs.push(CustomTab { + id: "missing-modes".to_string(), + name: "Missing modes".to_string(), }); - assert!(migrate_sound_library_enabled(&mut value)); - let entry = &value["soundLibrary"][TEST_SOUND_PATH]; - assert_eq!(entry["hidden"], true); - assert!(entry.get("enabled").is_none()); + fixture.selected_key_type = "missing-modes".to_string(); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - let (data, needs_persist) = load_store_with_sound_entry(json!({ "enabled": false })); - assert!(data.sound_library[TEST_SOUND_PATH].hidden); - assert!(needs_persist); + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.selected_key_type, "missing-modes"); + assert!(loaded.data.keys["missing-modes"].is_empty()); + assert!(loaded.data.key_positions["missing-modes"].is_empty()); } #[test] - fn sound_library_enabled_true_migrates_to_hidden_false() { - let (data, needs_persist) = load_store_with_sound_entry(json!({ "enabled": true })); - assert!(!data.sound_library[TEST_SOUND_PATH].hidden); - assert!(needs_persist); + fn normal_load_rejects_selected_mode_without_a_matching_tab() { + let path = std::env::temp_dir().join(format!( + "dmnote-ghost-selection-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut fixture = AppStoreData { + selected_key_type: "ghost-tab".to_string(), + ..AppStoreData::default() + }; + fixture + .keys + .insert("ghost-tab".to_string(), vec!["G".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); + + assert!(!loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.selected_key_type, "4key"); + assert_eq!(loaded.data.keys["ghost-tab"], vec!["G"]); } #[test] - fn sound_library_without_enabled_is_unchanged() { - let mut value = json!({ - "soundLibrary": { - TEST_SOUND_PATH: { "source": "local" } - } - }); - let original = value.clone(); - assert!(!migrate_sound_library_enabled(&mut value)); - assert_eq!(value, original); + fn asset_entries_recover_only_from_valid_identity_fields() { + let path = std::env::temp_dir().join(format!( + "dmnote-asset-identity-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let font_path = std::env::temp_dir().join(format!( + "dmnote-recovered-font-{}.ttf", + uuid::Uuid::new_v4() + )); + let sound_path = std::env::temp_dir().join(format!( + "dmnote-recovered-sound-{}.wav", + uuid::Uuid::new_v4() + )); + let valid_font = CustomFont { + id: "recoverable-font".to_string(), + font_type: FontType::Local, + name: "Recoverable Font".to_string(), + display_name: "Recoverable Font".to_string(), + enabled: true, + local_path: Some(font_path.to_string_lossy().to_string()), + css_content: None, + }; + let mut recoverable_font = serde_json::to_value(&valid_font).unwrap(); + recoverable_font + .as_object_mut() + .unwrap() + .insert("enabled".to_string(), json!("invalid")); + let mut invalid_identity_font = recoverable_font.clone(); + invalid_identity_font + .as_object_mut() + .unwrap() + .insert("id".to_string(), json!("")); + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "fontSettings".to_string(), + json!({ "customFonts": [recoverable_font, invalid_identity_font] }), + ); + fields.insert( + "soundLibrary".to_string(), + json!({ + sound_path.to_string_lossy().to_string(): 42, + "relative.wav": 42 + }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - let (data, needs_persist) = load_store_with_sound_entry(json!({ "source": "local" })); - assert!(!data.sound_library[TEST_SOUND_PATH].hidden); - assert!(!needs_persist); + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert_eq!(loaded.data.font_settings.custom_fonts.len(), 1); + let recovered_font = &loaded.data.font_settings.custom_fonts[0]; + assert_eq!(recovered_font.id, "recoverable-font"); + assert!(!recovered_font.enabled); + assert_eq!(recovered_font.local_path, valid_font.local_path); + let sound_key = sound_path.to_string_lossy().to_string(); + assert_eq!( + loaded.data.sound_library[&sound_key], + SoundLibraryEntry::default() + ); + assert!(!loaded.data.sound_library.contains_key("relative.wav")); } #[test] - fn sound_library_hidden_takes_precedence_over_enabled() { - let mut value = json!({ - "soundLibrary": { - TEST_SOUND_PATH: { "hidden": false, "enabled": false } - } - }); - assert!(migrate_sound_library_enabled(&mut value)); - let entry = &value["soundLibrary"][TEST_SOUND_PATH]; - assert_eq!(entry["hidden"], false); - assert!(entry.get("enabled").is_none()); + fn position_widgets_recover_layout_only_with_valid_identity() { + fn corrupt_height(mut value: Value) -> Value { + value + .as_object_mut() + .unwrap() + .insert("height".to_string(), json!("invalid")); + value + } - let (data, needs_persist) = - load_store_with_sound_entry(json!({ "hidden": false, "enabled": false })); - assert!(!data.sound_library[TEST_SOUND_PATH].hidden); - assert!(needs_persist); + let path = std::env::temp_dir().join(format!( + "dmnote-widget-layout-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut stat_position = default_positions()["4key"][0].clone(); + stat_position.sound_path = Some("/tmp/recovered-stat.wav".to_string()); + let mut graph_position = default_positions()["4key"][1].clone(); + graph_position.sound_path = Some("/tmp/recovered-graph.wav".to_string()); + let mut knob_position = default_positions()["4key"][2].clone(); + knob_position.sound_path = Some("/tmp/recovered-knob.wav".to_string()); + + let recoverable_stat = corrupt_height( + serde_json::to_value(StatPosition { + stat_type: StatType::Kps, + position: stat_position.clone(), + }) + .unwrap(), + ); + let mut invalid_stat = recoverable_stat.clone(); + invalid_stat + .as_object_mut() + .unwrap() + .insert("statType".to_string(), json!("invalid")); + + let recoverable_graph = corrupt_height( + serde_json::to_value(GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#FFFFFF".to_string(), + show_avg_line: true, + position: graph_position.clone(), + }) + .unwrap(), + ); + let mut invalid_graph = recoverable_graph.clone(); + invalid_graph + .as_object_mut() + .unwrap() + .insert("graphType".to_string(), json!("invalid")); + let mut invalid_graph_setting = recoverable_graph.clone(); + invalid_graph_setting + .as_object_mut() + .unwrap() + .insert("graphSpeed".to_string(), json!("invalid")); + + let recoverable_knob = corrupt_height( + serde_json::to_value(KnobPosition { + axis_id: "axis-recoverable".to_string(), + sensitivity: 1.0, + reverse: false, + position: knob_position.clone(), + }) + .unwrap(), + ); + let mut invalid_knob = recoverable_knob.clone(); + invalid_knob + .as_object_mut() + .unwrap() + .insert("axisId".to_string(), json!("")); + let mut invalid_knob_setting = recoverable_knob.clone(); + invalid_knob_setting + .as_object_mut() + .unwrap() + .insert("sensitivity".to_string(), json!("invalid")); + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "statPositions".to_string(), + json!({ "recovery-mode": [recoverable_stat, invalid_stat] }), + ); + fields.insert( + "graphPositions".to_string(), + json!({ + "recovery-mode": [recoverable_graph, invalid_graph, invalid_graph_setting] + }), + ); + fields.insert( + "knobPositions".to_string(), + json!({ + "recovery-mode": [recoverable_knob, invalid_knob, invalid_knob_setting] + }), + ); + 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); + + assert_eq!(loaded.data.stat_positions["recovery-mode"].len(), 1); + assert_eq!(loaded.data.graph_positions["recovery-mode"].len(), 1); + assert_eq!(loaded.data.knob_positions["recovery-mode"].len(), 1); + let stat = &loaded.data.stat_positions["recovery-mode"][0]; + let graph = &loaded.data.graph_positions["recovery-mode"][0]; + let knob = &loaded.data.knob_positions["recovery-mode"][0]; + assert_eq!(stat.stat_type, StatType::Kps); + assert_eq!(graph.graph_type, GraphType::Line); + assert_eq!(knob.axis_id, "axis-recoverable"); + assert_eq!(stat.position.height, KeyPosition::default().height); + assert_eq!(graph.position.height, KeyPosition::default().height); + assert_eq!(knob.position.height, KeyPosition::default().height); + assert_eq!(stat.position.sound_path, stat_position.sound_path); + assert_eq!(graph.position.sound_path, graph_position.sound_path); + assert_eq!(knob.position.sound_path, knob_position.sound_path); } #[test] - fn rgba_to_hex_converts_and_drops_alpha() { + fn repaired_selection_falls_back_without_deleting_orphaned_mode_data() { + let path = std::env::temp_dir().join(format!( + "dmnote-selected-tab-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "customTabs".to_string(), + json!([{ "id": "ghost-tab", "name": 42 }]), + ); + fields.insert("selectedKeyType".to_string(), json!("ghost-tab")); + fields.insert("keys".to_string(), json!({ "ghost-tab": ["G"] })); + 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); + + assert!(loaded.repaired); + assert!(loaded.data.custom_tabs.is_empty()); + assert_eq!(loaded.data.selected_key_type, "4key"); + assert_eq!(loaded.data.keys["ghost-tab"], vec!["G".to_string()]); + } + + #[test] + fn nested_user_settings_recover_valid_siblings_only() { + let path = std::env::temp_dir().join(format!( + "dmnote-nested-settings-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + + let note_settings = fields["noteSettings"].as_object_mut().unwrap(); + note_settings.insert("speed".to_string(), json!(987)); + note_settings.insert("trackHeight".to_string(), json!("invalid")); + + fields.insert( + "customTabs".to_string(), + json!([{ "id": "alpha-tab", "name": "Alpha" }]), + ); + fields.insert("keys".to_string(), json!({ "alpha-tab": ["A", "B", "C"] })); + fields.insert( + "layerGroups".to_string(), + json!({ + "alpha-tab": [ + { "id": "group-a", "name": "Group A" }, + 42, + { "id": "group-c", "name": "Group C" } + ], + "invalid-mode": 42 + }), + ); + fields.insert( + "keyCounters".to_string(), + json!({ + "alpha-tab": { "A": 7, "B": "invalid", "C": 9 }, + "invalid-mode": 42 + }), + ); + fields.insert( + "customCss".to_string(), + json!({ "path": "/tmp/sentinel.css", "content": 42 }), + ); + fields.insert( + "counterAnimationPresets".to_string(), + json!([ + { + "id": "animation-a", + "name": "Animation A", + "source": "user", + "bezier": [0.25, 0.46, 0.45, 0.94], + "scale": 1.1, + "durationMs": 300 + }, + 42, + { + "id": "animation-c", + "name": "Animation C", + "source": "user", + "bezier": [0.1, 0.2, 0.3, 0.4], + "scale": 1.2, + "durationMs": 400 + } + ]), + ); + fields.insert( + "tabCssOverrides".to_string(), + json!({ + "alpha-tab": { "path": null, "content": ".alpha {}", "enabled": true }, + "repaired-tab": { "path": "/tmp/repaired.css", "content": ".repaired {}", "enabled": 42 }, + "invalid-tab": 42 + }), + ); + fields.insert( + "tabNoteOverrides".to_string(), + json!({ + "alpha-tab": { "speed": 654 }, + "repaired-tab": { "speed": 777, "reverse": "invalid" }, + "invalid-tab": 42 + }), + ); + fields.insert( + "customJs".to_string(), + json!({ + "path": "/tmp/sentinel.js", + "content": "globalThis.sentinel = true;", + "plugins": [ + { "id": "plugin-a", "name": "Plugin A", "path": null, "content": "a", "enabled": true }, + 42, + { "id": "plugin-c", "name": "Plugin C", "path": null, "content": "c", "enabled": false } + ] + }), + ); + + let grid_settings = fields["gridSettings"].as_object_mut().unwrap(); + grid_settings.insert("alignmentGuides".to_string(), json!(false)); + grid_settings.insert("gridSnapSize".to_string(), json!("invalid")); + grid_settings.insert("overlayPadding".to_string(), json!(17)); + + let shortcuts = fields["shortcuts"].as_object_mut().unwrap(); + shortcuts.insert("toggleOverlay".to_string(), json!(42)); + shortcuts.insert( + "toggleAlwaysOnTop".to_string(), + json!({ "key": "F12", "ctrl": true, "shift": false, "alt": false, "meta": false }), + ); + + fields.insert( + "soundLibrary".to_string(), + json!({ + "/tmp/repaired.wav": { + "source": 42, + "displayName": "Recovered sound", + "trimStartRatio": 0.2 + }, + "/tmp/path-only.wav": 42 + }), + ); + + 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); + + assert!(loaded.repaired); + assert_eq!(loaded.data.note_settings.speed, 987); assert_eq!( - rgba_to_hex("rgba(255, 0, 167, 1)").as_deref(), - Some("#FF00A7") + loaded.data.note_settings.track_height, + AppStoreData::default().note_settings.track_height ); assert_eq!( - rgba_to_hex("rgba(18, 52, 86, 0)").as_deref(), - Some("#123456") + loaded.data.layer_groups["alpha-tab"] + .iter() + .map(|group| group.id.as_str()) + .collect::>(), + vec!["group-a", "group-c"] + ); + assert!(!loaded.data.layer_groups.contains_key("invalid-mode")); + assert_eq!(loaded.data.key_counters["alpha-tab"]["A"], 7); + assert_eq!(loaded.data.key_counters["alpha-tab"]["B"], 0); + assert_eq!(loaded.data.key_counters["alpha-tab"]["C"], 9); + assert!(!loaded.data.key_counters.contains_key("invalid-mode")); + assert_eq!( + loaded.data.custom_css.path.as_deref(), + Some("/tmp/sentinel.css") + ); + assert!(loaded.data.custom_css.content.is_empty()); + assert_eq!( + loaded + .data + .counter_animation_presets + .iter() + .map(|preset| preset.id.as_str()) + .collect::>(), + vec!["animation-a", "animation-c"] + ); + assert_eq!( + loaded.data.tab_css_overrides["alpha-tab"].content, + ".alpha {}" + ); + assert_eq!( + loaded.data.tab_css_overrides["repaired-tab"].content, + ".repaired {}" + ); + assert!(loaded.data.tab_css_overrides["repaired-tab"].enabled); + assert!(!loaded.data.tab_css_overrides.contains_key("invalid-tab")); + assert_eq!(loaded.data.tab_note_overrides["alpha-tab"].speed, Some(654)); + assert_eq!( + loaded.data.tab_note_overrides["repaired-tab"].speed, + Some(777) + ); + assert_eq!(loaded.data.tab_note_overrides["repaired-tab"].reverse, None); + assert!(!loaded.data.tab_note_overrides.contains_key("invalid-tab")); + assert_eq!( + loaded.data.custom_js.path.as_deref(), + Some("/tmp/sentinel.js") + ); + assert_eq!( + loaded + .data + .custom_js + .plugins + .iter() + .map(|plugin| plugin.id.as_str()) + .collect::>(), + vec!["plugin-a", "plugin-c"] + ); + assert!(!loaded.data.grid_settings.alignment_guides); + assert_eq!( + loaded.data.grid_settings.grid_snap_size, + AppStoreData::default().grid_settings.grid_snap_size + ); + assert_eq!(loaded.data.grid_settings.overlay_padding, 17); + assert_eq!(loaded.data.shortcuts.toggle_always_on_top.key, "F12"); + assert_eq!( + loaded.data.shortcuts.toggle_overlay, + AppStoreData::default().shortcuts.toggle_overlay + ); + assert_eq!( + loaded.data.sound_library["/tmp/repaired.wav"] + .display_name + .as_deref(), + Some("Recovered sound") + ); + assert_eq!( + loaded.data.sound_library["/tmp/repaired.wav"].trim_start_ratio, + Some(0.2) + ); + assert_eq!( + loaded.data.sound_library["/tmp/repaired.wav"].source, + crate::models::SoundSource::Local + ); + assert_eq!( + loaded.data.sound_library["/tmp/path-only.wav"], + SoundLibraryEntry::default() ); } #[test] - fn rgba_to_hex_ignores_non_rgba() { - assert_eq!(rgba_to_hex("#FF00A7"), None); - assert_eq!(rgba_to_hex("garbage"), None); - assert_eq!(rgba_to_hex("rgba(300, 0, 0, 1)"), None); // u8 범위 초과 + fn asset_position_collections_recover_valid_entries_only() { + let path = std::env::temp_dir().join(format!( + "dmnote-position-entry-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let position = default_positions()["4key"][0].clone(); + let mut third_position = default_positions()["4key"][1].clone(); + third_position.dx += 17.0; + let mut partial_position = default_positions()["4key"][0].clone(); + partial_position.dx = 246.0; + partial_position.width = 88.0; + partial_position.active_image = Some("/tmp/recovered-image.png".to_string()); + partial_position.sound_path = Some("/tmp/recovered-sound.wav".to_string()); + let mut partial_position_value = serde_json::to_value(&partial_position).unwrap(); + partial_position_value + .as_object_mut() + .unwrap() + .insert("height".to_string(), json!("invalid")); + let stat = StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }; + let graph = GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#FFFFFF".to_string(), + show_avg_line: true, + position: position.clone(), + }; + let knob = KnobPosition { + axis_id: "axis-sentinel".to_string(), + sensitivity: 1.0, + reverse: false, + position: position.clone(), + }; + let font = CustomFont { + id: "font-sentinel".to_string(), + font_type: FontType::Local, + name: "Font Sentinel".to_string(), + display_name: "Font Sentinel".to_string(), + enabled: true, + local_path: Some("/tmp/font-sentinel.ttf".to_string()), + css_content: None, + }; + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "keyPositions".to_string(), + json!({ + "partial-mode": [ + serde_json::to_value(&position).unwrap(), + partial_position_value, + serde_json::to_value(&third_position).unwrap(), + 42 + ], + "invalid-mode": 42, + }), + ); + fields.insert( + "statPositions".to_string(), + json!({ + "partial-mode": [serde_json::to_value(&stat).unwrap(), 42], + "invalid-mode": 42, + }), + ); + fields.insert( + "graphPositions".to_string(), + json!({ + "partial-mode": [serde_json::to_value(&graph).unwrap(), 42], + "invalid-mode": 42, + }), + ); + fields.insert( + "knobPositions".to_string(), + json!({ + "partial-mode": [serde_json::to_value(&knob).unwrap(), 42], + "invalid-mode": 42, + }), + ); + fields.insert( + "fontSettings".to_string(), + json!({ + "customFonts": [serde_json::to_value(&font).unwrap(), 42], + }), + ); + 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); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + let recovered_key_positions = &loaded.data.key_positions["partial-mode"]; + assert_eq!(recovered_key_positions.len(), 4); + assert_eq!(recovered_key_positions[0], position); + assert_eq!(recovered_key_positions[1].dx, partial_position.dx); + assert_eq!(recovered_key_positions[1].width, partial_position.width); + assert_eq!( + recovered_key_positions[1].height, + KeyPosition::default().height + ); + assert_eq!( + recovered_key_positions[1].active_image, + partial_position.active_image + ); + assert_eq!( + recovered_key_positions[1].sound_path, + partial_position.sound_path + ); + assert_eq!(recovered_key_positions[2], third_position); + assert_eq!(recovered_key_positions[3], KeyPosition::default()); + assert_eq!(recovered_key_positions[3].width, 60.0); + assert_eq!(loaded.data.stat_positions["partial-mode"], vec![stat]); + assert_eq!(loaded.data.graph_positions["partial-mode"], vec![graph]); + assert_eq!(loaded.data.knob_positions["partial-mode"], vec![knob]); + assert!(!loaded.data.key_positions.contains_key("invalid-mode")); + assert!(!loaded.data.stat_positions.contains_key("invalid-mode")); + assert!(!loaded.data.graph_positions.contains_key("invalid-mode")); + assert!(!loaded.data.knob_positions.contains_key("invalid-mode")); + assert_eq!(loaded.data.font_settings.custom_fonts, vec![font]); + } + + #[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/mod.rs b/src-tauri/src/state/mod.rs index 96bd2b65..183983f5 100644 --- a/src-tauri/src/state/mod.rs +++ b/src-tauri/src/state/mod.rs @@ -1,5 +1,7 @@ pub mod app_state; +pub(crate) mod atomic_file; pub(crate) mod builtin_sounds; +pub(crate) mod local_asset_path; pub(crate) mod migration; pub mod store; diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 35feb4a7..12e31585 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -1,30 +1,98 @@ use std::{ collections::HashSet, fs, - io::Write, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, + sync::mpsc, + thread::{self, JoinHandle}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; -use anyhow::{Context, Result}; -use parking_lot::RwLock; +use crate::models::{ + AppStoreData, FontType, GraphPositions, KeyCounters, KeyMappings, KeyPosition, KeyPositions, + KnobPositions, LayerGroups, SettingsState, StatPositions, +}; +use anyhow::{anyhow, Context, Result}; +use parking_lot::{Mutex, RwLock, RwLockWriteGuard}; use serde_json::Value; use tauri::path::PathResolver; use tauri::Runtime; -use crate::models::{ - AppStoreData, FontType, GraphPositions, KeyCounters, KeyMappings, KeyPositions, KnobPositions, - LayerGroups, SettingsState, StatPositions, -}; - +use super::atomic_file::atomic_replace; use super::builtin_sounds::seed_builtin_sounds; +use super::local_asset_path::{file_url_to_path, FileUrlPath}; use super::migration::{ find_legacy_store_file, load_store_from_path, migrate_key_images_to_app_data, migrate_local_fonts_to_app_data, normalize_state, }; +const TRASH_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60); +const SOUND_DELETE_BACKUP_MARKER: &str = ".delete-backup-"; +pub(crate) static PROCESSED_WAV_TRANSACTION_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Debug)] +pub(crate) struct StagedSoundDeletionFile { + source_path: PathBuf, + backup_path: PathBuf, +} + +struct SoundRecoveryOutcome { + protected_keys: HashSet, + complete: bool, +} + +impl SoundRecoveryOutcome { + fn complete() -> Self { + Self { + protected_keys: HashSet::new(), + complete: true, + } + } +} + pub struct AppStore { path: PathBuf, - state: RwLock, + state: RwLock, + writer: StoreWriter, + skip_asset_sweep: bool, +} + +struct VersionedStoreState { + data: AppStoreData, + revision: u64, + dirty: bool, + 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, + #[cfg(test)] + force_failure: bool, + }, + Shutdown { + completion_tx: mpsc::Sender<()>, + }, +} + +struct StoreWriter { + sender: Mutex>>, + handle: Mutex>>, + #[cfg(test)] + fail_next_persist: std::sync::atomic::AtomicBool, + #[cfg(test)] + persist_count: std::sync::atomic::AtomicUsize, } impl AppStore { @@ -32,47 +100,55 @@ 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 (path, mut state, mut needs_persist, skip_asset_sweep) = if default_path.exists() { + let loaded = load_store_from_path(&default_path)?; + if loaded.repaired { + backup_store_file(&default_path)?; + } + ( + default_path.clone(), + loaded.data, + loaded.needs_persist, + loaded.repaired, + ) } 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, true) } else { - (default_path, initialize_default_state(), true) + (default_path, initialize_default_state(), true, false) }; // 마이그레이션: 로컬 폰트 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, skip_asset_sweep)?; 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 +159,220 @@ impl AppStore { Ok(store) } + fn new(path: PathBuf, state: AppStoreData, skip_asset_sweep: bool) -> Result { + Ok(Self { + writer: StoreWriter::start(path.clone())?, + path, + skip_asset_sweep, + state: RwLock::new(VersionedStoreState { + data: state, + revision: 0, + dirty: false, + 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 commit_locked( + &self, + state: &mut VersionedStoreState, + scratch: AppStoreData, + result: T, + ) -> Result { + let revision = state + .revision + .checked_add(1) + .context("store revision overflow")?; + let ticket = self.writer.enqueue(revision, scratch.clone())?; + ticket.wait()?; + state.data = scratch; + state.revision = revision; + state.dirty = false; + Ok(result) + } + + fn update_committed( + &self, + updater: impl FnOnce(&mut AppStoreData), + reader: impl FnOnce(&AppStoreData) -> T, + ) -> Result { + let mut guard = self.lock_for_update()?; + let mut scratch = guard.data.clone(); + updater(&mut scratch); + scratch = normalize_state(scratch); + let result = reader(&scratch); + self.commit_locked(&mut guard, scratch, result) + } + + fn persist_current(&self) -> Result<()> { + let mut guard = self.lock_for_update()?; + let scratch = guard.data.clone(); + self.commit_locked(&mut guard, scratch, ()) + } + + /// `overlay_bounds`, `overlay_bounds_are_logical`, `overlay_last_content_top_offset` 전용 + /// 다른 데이터에는 일반 update를 사용해 성공한 저장만 committed로 공개해야 함 + pub(crate) fn update_deferred(&self, updater: impl FnOnce(&mut AppStoreData)) -> Result<()> { + let mut guard = self.lock_for_update()?; + updater(&mut guard.data); + guard.dirty = true; + Ok(()) + } + + pub(crate) fn flush(&self) -> Result<()> { + let mut guard = self.state.write(); + self.flush_locked(&mut guard) + } + + fn flush_locked(&self, guard: &mut VersionedStoreState) -> Result<()> { + if !guard.dirty { + return Ok(()); + } + if !guard.accepting_writes { + return Err(anyhow!("store writer is shut down")); + } + + let scratch = guard.data.clone(); + let result = self.commit_locked(guard, scratch, ()); + if let Err(err) = &result { + log::warn!("failed to flush deferred store state: {err:#}"); + } + result + } + + pub(crate) fn flush_and_shutdown(&self) -> Result<()> { + let mut guard = self.state.write(); + self.flush_locked(&mut guard)?; + 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()) + crate::services::settings::settings_from_store(&self.state.read().data) } - pub fn update(&self, mut updater: F) -> Result + + pub fn update(&self, updater: F) -> Result where - F: FnMut(&mut AppStoreData), + F: FnOnce(&mut AppStoreData), { - let mut guard = self.state.write(); - updater(&mut guard); - *guard = normalize_state(guard.clone()); - self.persist_locked(&guard)?; - Ok(guard.clone()) + self.update_committed(updater, AppStoreData::clone) } - 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()) + pub fn update_keys(&self, mappings: KeyMappings) -> Result<(KeyMappings, String)> { + self.update_committed( + move |state| state.keys = mappings, + |state| (state.keys.clone(), state.selected_key_type.clone()), + ) } pub fn 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()) + self.update_committed( + move |state| state.key_positions = positions, + |state| state.key_positions.clone(), + ) } 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)?; - Ok(result) + self.update_committed( + move |state| state.layer_groups = groups, + |state| state.layer_groups.clone(), + ) } 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()) + self.update_committed( + move |state| state.stat_positions = positions, + |state| state.stat_positions.clone(), + ) } 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()) + self.update_committed( + move |state| state.graph_positions = positions, + |state| state.graph_positions.clone(), + ) } 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()) + self.update_committed( + move |state| state.knob_positions = positions, + |state| state.knob_positions.clone(), + ) } 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()) + self.update_committed( + move |state| state.key_counters = counters, + |state| state.key_counters.clone(), + ) } 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()) + self.update_committed( + move |state| state.selected_key_type = key, + |state| state.selected_key_type.clone(), + ) } // 플러그인 데이터 관련 메서드 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 mut guard = self.lock_for_update()?; + let mut scratch = guard.data.clone(); + scratch.plugin_data.insert(key.to_string(), value); + self.commit_locked(&mut guard, scratch, ()) } 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 mut guard = self.lock_for_update()?; + let mut scratch = guard.data.clone(); + scratch.plugin_data.remove(key); + self.commit_locked(&mut guard, scratch, ()) } pub fn clear_all_plugin_data(&self) -> Result<()> { - let mut guard = self.state.write(); - guard.plugin_data.clear(); - self.persist_locked(&guard)?; - Ok(()) + let mut guard = self.lock_for_update()?; + let mut scratch = guard.data.clone(); + scratch.plugin_data.clear(); + self.commit_locked(&mut guard, scratch, ()) } 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 파일을 삭제 + /// 현재 store에서 참조하지 않는 자산은 30일 보관되는 trash 세션으로 격리 + #[cfg(test)] pub fn cleanup_orphan_assets_now(&self) -> Result<()> { - let snapshot = self.state.read().clone(); + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + self.cleanup_orphan_assets_while_locked() + } + + fn cleanup_orphan_assets_while_locked(&self) -> Result<()> { let app_data_dir = self .path .parent() @@ -289,46 +381,400 @@ impl AppStore { let fonts_dir = app_data_dir.join("fonts"); let images_dir = app_data_dir.join("images"); let sounds_dir = app_data_dir.join("sounds"); + let trash_dir = app_data_dir.join("trash"); + + self.recover_pending_processed_wav_replacement(&sounds_dir)?; + let processed_recovery = recover_interrupted_processed_wav_replacements(&sounds_dir)?; + let deletion_recovery = + recover_interrupted_sound_deletions(&self.state.read().data, &sounds_dir, &trash_dir)?; + let sound_recovery_complete = processed_recovery.complete && deletion_recovery.complete; + let mut recovered_sound_keys = processed_recovery.protected_keys; + recovered_sound_keys.extend(deletion_recovery.protected_keys); + purge_expired_trash_sessions(&trash_dir)?; + + if self.skip_asset_sweep { + log::warn!( + "[Assets] Skipping orphan asset sweep for a recovered or legacy-migrated store session" + ); + return Ok(()); + } + + let snapshot = self.state.read().data.clone(); + let referenced_fonts = collect_local_font_paths(&snapshot); + let referenced_images = collect_local_image_paths(&snapshot); + let mut referenced_sounds = collect_local_sound_paths(&snapshot); + referenced_sounds.keys.extend(recovered_sound_keys); + referenced_sounds.complete &= sound_recovery_complete; + let mut trash_session = TrashSession::new(trash_dir, SystemTime::now())?; + + if referenced_fonts.complete { + sweep_unreferenced_asset_files( + "Fonts", + &fonts_dir, + &referenced_fonts.keys, + &mut trash_session, + )?; + } else { + log::warn!("[Fonts] Skipping asset sweep because a file URL could not be resolved"); + } + if referenced_images.complete { + sweep_unreferenced_asset_files( + "Images", + &images_dir, + &referenced_images.keys, + &mut trash_session, + )?; + } else { + log::warn!("[Images] Skipping asset sweep because a file URL could not be resolved"); + } + if referenced_sounds.complete { + sweep_unreferenced_asset_files( + "Sounds", + &sounds_dir, + &referenced_sounds.keys, + &mut trash_session, + )?; + } else { + log::warn!("[Sounds] Skipping asset sweep because a file URL could not be resolved"); + } + Ok(()) + } + + pub(crate) fn flush_cleanup_and_shutdown(&self) -> Result<()> { + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + + let flush_result = self.flush(); + let cleanup_result = if flush_result.is_ok() { + self.cleanup_orphan_assets_while_locked() + } else { + Ok(()) + }; + let shutdown_result = self.flush_and_shutdown(); + + match (flush_result, cleanup_result, shutdown_result) { + (Ok(()), Ok(()), Ok(())) => Ok(()), + (Err(flush), _, Err(shutdown)) => Err(anyhow!( + "failed to flush store before cleanup: {flush:#}; failed to shut down store writer: {shutdown:#}" + )), + (Err(flush), _, Ok(())) => { + Err(anyhow!("failed to flush store before asset cleanup: {flush:#}")) + } + (Ok(()), Err(cleanup), Err(shutdown)) => Err(anyhow!( + "failed to clean up assets: {cleanup:#}; failed to shut down store writer: {shutdown:#}" + )), + (Ok(()), Err(cleanup), Ok(())) => Err(cleanup), + (Ok(()), Ok(()), Err(shutdown)) => Err(shutdown), + } + } + + pub fn recover_interrupted_processed_wav_replacements_now(&self) -> Result<()> { + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + self.recover_interrupted_processed_wav_replacements_while_locked() + } + + /// `PROCESSED_WAV_TRANSACTION_LOCK`을 이미 보유한 편집 명령 전용 + pub(crate) fn recover_interrupted_processed_wav_replacements_while_locked(&self) -> Result<()> { + self.recover_interrupted_sound_assets_while_locked() + .map(|_| ()) + } + + /// `PROCESSED_WAV_TRANSACTION_LOCK`을 이미 보유한 목록 명령 전용 + pub(crate) fn prepare_sound_listing_while_locked(&self) -> Result { + self.recover_interrupted_sound_assets_while_locked() + } + + fn recover_interrupted_sound_assets_while_locked(&self) -> Result { + let app_data_dir = self + .path + .parent() + .context("failed to resolve app data directory from store path")?; + let sounds_dir = app_data_dir.join("sounds"); + let trash_dir = app_data_dir.join("trash"); + self.recover_pending_processed_wav_replacement(&sounds_dir)?; + let processed_recovery = recover_interrupted_processed_wav_replacements(&sounds_dir)?; + let deletion_recovery = + recover_interrupted_sound_deletions(&self.state.read().data, &sounds_dir, &trash_dir)?; + Ok(processed_recovery.complete && deletion_recovery.complete) + } - let referenced_font_keys = collect_local_font_path_keys(&snapshot); - let referenced_image_keys = collect_local_image_path_keys(&snapshot); - let referenced_sound_keys = collect_local_sound_path_keys(&snapshot); + fn recover_pending_processed_wav_replacement(&self, sounds_dir: &Path) -> Result<()> { + self.recover_pending_processed_wav_replacement_with(sounds_dir, Path::try_exists) + } + + fn recover_pending_processed_wav_replacement_with( + &self, + sounds_dir: &Path, + mut try_exists: TryExists, + ) -> Result<()> + where + TryExists: FnMut(&Path) -> std::io::Result, + { + let Some(pending) = self + .state + .read() + .data + .pending_processed_wav_replacement + .clone() + else { + return Ok(()); + }; + + let target_path = validate_pending_processed_wav_target(sounds_dir, &pending.sound_path)?; + let backup_path = processed_wav_backup_path(&target_path)?; + let target_exists = try_exists(&target_path).with_context(|| { + format!( + "failed to check pending WAV target at {}", + target_path.display() + ) + })?; + let backup_exists = try_exists(&backup_path).with_context(|| { + format!( + "failed to check pending WAV backup at {}", + backup_path.display() + ) + })?; + let files_missing = !target_exists && !backup_exists; + + if files_missing { + log::warn!( + "[Sounds] Clearing unrecoverable WAV transaction because target '{}' and backup '{}' are both missing", + target_path.display(), + backup_path.display() + ); + } else { + rollback_pending_processed_wav( + &target_path, + pending.had_original, + target_exists, + backup_exists, + )?; + } + self.update(|data| { + if data.pending_processed_wav_replacement.as_ref() == Some(&pending) { + data.pending_processed_wav_replacement = None; + } + })?; + if !files_missing { + log::info!( + "[Sounds] Recovered interrupted WAV transaction for '{}'", + target_path.display() + ); + } + Ok(()) + } +} + +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)), + #[cfg(test)] + fail_next_persist: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + persist_count: std::sync::atomic::AtomicUsize::new(0), + }) + } + + 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")?; + #[cfg(test)] + let force_failure = self + .fail_next_persist + .swap(false, std::sync::atomic::Ordering::SeqCst); + sender + .send(WriterMessage::Persist { + revision, + snapshot: Box::new(snapshot), + completion_tx, + #[cfg(test)] + force_failure, + }) + .with_context(|| format!("failed to enqueue store revision {revision}"))?; + #[cfg(test)] + self.persist_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(PersistTicket { + revision, + completion_rx, + }) + } + + #[cfg(test)] + fn fail_next_persist(&self) { + self.fail_next_persist + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + #[cfg(test)] + fn persist_count(&self) -> usize { + self.persist_count.load(std::sync::atomic::Ordering::SeqCst) + } + + 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(); - sweep_unreferenced_asset_files("Fonts", &fonts_dir, &referenced_font_keys)?; - sweep_unreferenced_asset_files("Images", &images_dir, &referenced_image_keys)?; - sweep_unreferenced_asset_files("Sounds", &sounds_dir, &referenced_sound_keys)?; + completion_result?; + join_result?; Ok(()) } } -fn settings_from_store(store: &AppStoreData) -> SettingsState { - let mut custom_js = store.custom_js.clone(); - let _ = custom_js.normalize(); - - SettingsState { - hardware_acceleration: store.hardware_acceleration, - always_on_top: store.always_on_top, - overlay_locked: store.overlay_locked, - note_effect: store.note_effect, - note_settings: store.note_settings.clone(), - angle_mode: store.angle_mode.clone(), - language: store.language.clone(), - laboratory_enabled: store.laboratory_enabled, - developer_mode_enabled: store.developer_mode_enabled, - tray_enabled: store.tray_enabled, - auto_update_enabled: store.auto_update_enabled, - background_color: store.background_color.clone(), - use_custom_css: store.use_custom_css, - custom_css: store.custom_css.clone(), - font_settings: store.font_settings.clone(), - use_custom_js: store.use_custom_js, - custom_js, - overlay_resize_anchor: store.overlay_resize_anchor.clone(), - key_counter_enabled: store.key_counter_enabled, - grid_settings: store.grid_settings.clone(), - shortcuts: store.shortcuts.clone(), - obs_mode_enabled: store.obs_mode_enabled, +fn run_store_writer(path: &Path, receiver: mpsc::Receiver) { + while let Ok(message) = receiver.recv() { + match message { + WriterMessage::Persist { + revision, + snapshot, + completion_tx, + #[cfg(test)] + force_failure, + } => { + #[cfg(test)] + let result = if force_failure { + Err("injected store writer failure".to_string()) + } else { + write_store_snapshot(path, revision, snapshot.as_ref()) + .map_err(|err| format!("{err:#}")) + }; + #[cfg(not(test))] + 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 initialize_default_state() -> AppStoreData { @@ -342,100 +788,121 @@ fn initialize_default_state() -> AppStoreData { normalize_state(data) } -fn collect_local_font_path_keys(data: &AppStoreData) -> HashSet { - data.font_settings - .custom_fonts - .iter() - .filter(|font| font.font_type == FontType::Local) - .filter_map(|font| font.local_path.as_ref()) - .filter_map(|path| normalize_local_asset_path(path)) - .map(|path| path_lookup_key(&path)) - .collect() +struct AssetReferencePaths { + keys: HashSet, + complete: bool, } -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()); +impl AssetReferencePaths { + fn new() -> Self { + Self { + keys: HashSet::new(), + complete: true, } } - 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(), - ); + fn collect(&mut self, value: Option<&String>) { + let Some(path) = value else { + return; + }; + match resolve_local_asset_path(path) { + LocalAssetPathResolution::Path(path) => { + self.keys.insert(path_lookup_key(&path)); + } + LocalAssetPathResolution::InvalidFileUrl => self.complete = false, + LocalAssetPathResolution::Ignored => {} } } +} - 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(), - ); - } +fn collect_local_font_paths(data: &AppStoreData) -> AssetReferencePaths { + let mut paths = AssetReferencePaths::new(); + + for font in data + .font_settings + .custom_fonts + .iter() + .filter(|font| font.font_type == FontType::Local) + { + paths.collect(font.local_path.as_ref()); } paths } -fn collect_local_sound_path_keys(data: &AppStoreData) -> HashSet { - let mut paths = HashSet::new(); +fn collect_local_image_paths(data: &AppStoreData) -> AssetReferencePaths { + let mut paths = AssetReferencePaths::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) { + paths.collect(position.active_image.as_ref()); + paths.collect(position.inactive_image.as_ref()); + } + + paths +} + +#[cfg(test)] +fn collect_local_image_path_keys(data: &AppStoreData) -> HashSet { + collect_local_image_paths(data).keys +} + +fn collect_local_sound_paths(data: &AppStoreData) -> AssetReferencePaths { + let mut paths = AssetReferencePaths::new(); + + for position in iter_all_positions(data) { + paths.collect(position.sound_path.as_ref()); } // 사운드 라이브러리에 등록된 파일도 보호 (키에 할당 안 되어도 유지) for key in data.sound_library.keys() { let normalized = PathBuf::from(key); if normalized.is_absolute() { - paths.insert(path_lookup_key(&normalized)); + paths.keys.insert(path_lookup_key(&normalized)); } } paths } -fn collect_image_path_from_option(paths: &mut HashSet, value: Option<&String>) { - let Some(path) = value else { - return; - }; - let Some(normalized) = normalize_local_asset_path(path) else { - return; - }; - paths.insert(path_lookup_key(&normalized)); +#[cfg(test)] +fn collect_local_sound_path_keys(data: &AppStoreData) -> HashSet { + collect_local_sound_paths(data).keys } -fn collect_sound_path_from_option(paths: &mut HashSet, value: Option<&String>) { - let Some(path) = value else { - return; - }; - let Some(normalized) = normalize_local_asset_path(path) else { - return; - }; - paths.insert(path_lookup_key(&normalized)); +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), + ) +} + +enum LocalAssetPathResolution { + Path(PathBuf), + InvalidFileUrl, + Ignored, } -fn normalize_local_asset_path(path: &str) -> Option { +fn resolve_local_asset_path(path: &str) -> LocalAssetPathResolution { let trimmed = path.trim(); if trimmed.is_empty() { - return None; + return LocalAssetPathResolution::Ignored; } let lower = trimmed.to_ascii_lowercase(); @@ -446,35 +913,139 @@ fn normalize_local_asset_path(path: &str) -> Option { || lower.starts_with("asset:") || lower.starts_with("tauri:") { - return None; + return LocalAssetPathResolution::Ignored; } - let normalized = if let Some(stripped) = trimmed - .strip_prefix("file:///") - .or_else(|| trimmed.strip_prefix("file://")) - { - #[cfg(target_os = "windows")] - { - let mut value = stripped.to_string(); - if value.starts_with('/') && value.as_bytes().get(2) == Some(&b':') { - value = value[1..].to_string(); + match file_url_to_path(trimmed) { + FileUrlPath::Path(path) => LocalAssetPathResolution::Path(path), + FileUrlPath::Invalid => LocalAssetPathResolution::InvalidFileUrl, + FileUrlPath::NotFileUrl => { + let path = PathBuf::from(trimmed); + if path.is_absolute() { + LocalAssetPathResolution::Path(path) + } else { + LocalAssetPathResolution::Ignored } - value = value.replace('/', "\\"); - PathBuf::from(value) - } - #[cfg(not(target_os = "windows"))] - { - PathBuf::from(stripped) } - } else { - PathBuf::from(trimmed) - }; + } +} + +fn validate_pending_processed_wav_target(sounds_dir: &Path, value: &str) -> Result { + let target = PathBuf::from(value); + if !target.is_absolute() + || target + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + return Err(anyhow!("invalid pending WAV path")); + } - if normalized.is_absolute() { - Some(normalized) + let canonical_sounds_dir = fs::canonicalize(sounds_dir).with_context(|| { + format!( + "failed to resolve sound directory during WAV recovery at {}", + sounds_dir.display() + ) + })?; + let boundary_path = (if target.exists() { + fs::canonicalize(&target).map_err(anyhow::Error::from) } else { - None + target + .parent() + .context("pending WAV path has no parent") + .and_then(|parent| fs::canonicalize(parent).map_err(anyhow::Error::from)) + }) + .with_context(|| { + format!( + "failed to resolve pending WAV boundary at {}", + target.display() + ) + })?; + + if !boundary_path.starts_with(&canonical_sounds_dir) { + return Err(anyhow!("pending WAV path escapes the sound directory")); + } + Ok(target) +} + +fn processed_wav_backup_path(path: &Path) -> Result { + let mut file_name = path + .file_name() + .context("pending WAV path has no file name")? + .to_os_string(); + file_name.push(".bak"); + Ok(path.with_file_name(file_name)) +} + +fn interrupted_wav_path(path: &Path) -> PathBuf { + let mut file_name = path.file_name().unwrap_or_default().to_os_string(); + file_name.push(format!(".interrupted-{}", uuid::Uuid::new_v4())); + path.with_file_name(file_name) +} + +fn rollback_pending_processed_wav( + target_path: &Path, + had_original: bool, + target_exists: bool, + backup_exists: bool, +) -> Result<()> { + let backup_path = processed_wav_backup_path(target_path)?; + if !had_original { + if target_exists { + let interrupted_path = interrupted_wav_path(target_path); + fs::rename(target_path, &interrupted_path).with_context(|| { + format!( + "failed to quarantine uncommitted WAV '{}' at '{}'", + target_path.display(), + interrupted_path.display() + ) + })?; + } + return Ok(()); + } + + if !backup_exists { + if target_exists { + return Ok(()); + } + return Err(anyhow!( + "both the original WAV and its backup are missing during recovery" + )); } + + if !target_exists { + fs::rename(&backup_path, target_path).with_context(|| { + format!( + "failed to restore WAV backup '{}' to '{}'", + backup_path.display(), + target_path.display() + ) + })?; + return Ok(()); + } + + let interrupted_path = interrupted_wav_path(target_path); + fs::rename(target_path, &interrupted_path).with_context(|| { + format!( + "failed to move uncommitted WAV '{}' to '{}'", + target_path.display(), + interrupted_path.display() + ) + })?; + if let Err(error) = fs::rename(&backup_path, target_path) { + return match fs::rename(&interrupted_path, target_path) { + Ok(()) => Err(error).with_context(|| { + format!( + "failed to restore WAV backup '{}' to '{}'", + backup_path.display(), + target_path.display() + ) + }), + Err(recovery_error) => Err(anyhow!( + "failed to restore WAV backup: {error}; failed to put uncommitted WAV back: {recovery_error}" + )), + }; + } + Ok(()) } fn path_lookup_key(path: &Path) -> String { @@ -490,10 +1061,611 @@ fn path_lookup_key(path: &Path) -> String { } } -fn sweep_unreferenced_asset_files( +fn recover_interrupted_processed_wav_replacements( + sounds_dir: &Path, +) -> Result { + recover_interrupted_processed_wav_replacements_with(sounds_dir, |from, to| fs::rename(from, to)) +} + +fn recover_interrupted_processed_wav_replacements_with( + sounds_dir: &Path, + mut rename: Rename, +) -> Result +where + Rename: FnMut(&Path, &Path) -> std::io::Result<()>, +{ + let mut outcome = SoundRecoveryOutcome::complete(); + if !sounds_dir.exists() { + return Ok(outcome); + } + + let read_dir = fs::read_dir(sounds_dir).with_context(|| { + format!( + "failed to read sound directory for recovery at {}", + sounds_dir.display() + ) + })?; + + for entry in read_dir { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + outcome.complete = false; + log::warn!( + "[Sounds] Failed to read an entry from '{}' during recovery: {err}", + sounds_dir.display() + ); + continue; + } + }; + let backup_path = entry.path(); + let is_file = match entry.file_type() { + Ok(file_type) => file_type.is_file(), + Err(err) => { + outcome.complete = false; + log::warn!( + "[Sounds] Failed to inspect '{}' during recovery: {err}", + backup_path.display() + ); + continue; + } + }; + if !is_file || !backup_path.starts_with(sounds_dir) { + continue; + } + + let Some(file_name) = backup_path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some(target_name) = file_name.strip_suffix(".bak") else { + continue; + }; + let target_path = backup_path.with_file_name(target_name); + if !target_path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("wav")) + { + continue; + } + + match target_path.try_exists() { + Ok(true) => continue, + Ok(false) => {} + Err(err) => { + outcome.complete = false; + outcome.protected_keys.insert(path_lookup_key(&backup_path)); + log::warn!( + "[Sounds] Failed to inspect processed WAV target '{}': {err}", + target_path.display() + ); + continue; + } + } + + if let Err(err) = rename(&backup_path, &target_path) { + outcome.complete = false; + outcome.protected_keys.insert(path_lookup_key(&backup_path)); + log::warn!( + "[Sounds] Failed to recover processed WAV '{}' from '{}': {err}", + target_path.display(), + backup_path.display() + ); + continue; + } + log::info!( + "[Sounds] Recovered processed WAV '{}'", + target_path.display() + ); + } + + Ok(outcome) +} + +struct TrashSession { + trash_dir: PathBuf, + timestamp_millis: u128, + next_suffix: u64, + session_dir: Option, +} + +impl TrashSession { + fn new(trash_dir: PathBuf, now: SystemTime) -> Result { + Ok(Self { + trash_dir, + timestamp_millis: system_time_millis(now)?, + next_suffix: 0, + session_dir: None, + }) + } + + fn destination_path(&mut self, source_dir: &Path, source_path: &Path) -> Result { + let category = source_dir.file_name().with_context(|| { + format!( + "failed to resolve asset category from {}", + source_dir.display() + ) + })?; + let file_name = source_path.file_name().with_context(|| { + format!( + "failed to resolve asset file name from {}", + source_path.display() + ) + })?; + + loop { + let session_dir = self.ensure_session_dir()?; + let category_dir = session_dir.join(category); + fs::create_dir_all(&category_dir).with_context(|| { + format!( + "failed to create trash category directory at {}", + category_dir.display() + ) + })?; + let destination = category_dir.join(file_name); + if !destination.exists() { + return Ok(destination); + } + + self.session_dir = None; + } + } + + fn ensure_session_dir(&mut self) -> Result { + if let Some(path) = self.session_dir.as_ref() { + return Ok(path.clone()); + } + + fs::create_dir_all(&self.trash_dir).with_context(|| { + format!( + "failed to create asset trash directory at {}", + self.trash_dir.display() + ) + })?; + + loop { + let suffix = self.next_suffix; + self.next_suffix = self + .next_suffix + .checked_add(1) + .context("asset trash session suffix overflow")?; + let name = if suffix == 0 { + self.timestamp_millis.to_string() + } else { + format!("{}-{suffix}", self.timestamp_millis) + }; + let candidate = self.trash_dir.join(name); + match fs::create_dir(&candidate) { + Ok(()) => { + self.session_dir = Some(candidate.clone()); + return Ok(candidate); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(err).with_context(|| { + format!( + "failed to create asset trash session at {}", + candidate.display() + ) + }); + } + } + } + } +} + +pub(crate) fn stage_sound_files_for_deletion( + source_paths: &[PathBuf], +) -> Result> { + let mut staged = Vec::new(); + let mut seen = HashSet::new(); + + for source_path in source_paths { + let key = path_lookup_key(source_path); + if !seen.insert(key) { + continue; + } + match source_path.try_exists() { + Ok(false) => continue, + Ok(true) if !source_path.is_file() => { + let error = anyhow!( + "sound deletion target is not a file at {}", + source_path.display() + ); + return rollback_staged_after_error(staged, error); + } + Ok(true) => {} + Err(error) => { + return rollback_staged_after_error( + staged, + anyhow!( + "failed to inspect sound deletion target at {}: {error}", + source_path.display() + ), + ); + } + } + + let backup_path = match sound_delete_backup_path(source_path) { + Ok(path) => path, + Err(error) => return rollback_staged_after_error(staged, error), + }; + if let Err(error) = fs::rename(source_path, &backup_path) { + return rollback_staged_after_error( + staged, + anyhow!( + "failed to stage sound deletion '{}' -> '{}': {error}", + source_path.display(), + backup_path.display() + ), + ); + } + staged.push(StagedSoundDeletionFile { + source_path: source_path.clone(), + backup_path, + }); + } + + Ok(staged) +} + +pub(crate) fn restore_staged_sound_deletions(staged: &[StagedSoundDeletionFile]) -> Result<()> { + let mut failures = Vec::new(); + for file in staged.iter().rev() { + match file.backup_path.try_exists() { + Ok(false) => continue, + Ok(true) => {} + Err(error) => { + failures.push(format!( + "failed to inspect staged backup '{}': {error}", + file.backup_path.display() + )); + continue; + } + } + match file.source_path.try_exists() { + Ok(true) => { + failures.push(format!( + "source path already exists at {}", + file.source_path.display() + )); + continue; + } + Ok(false) => {} + Err(error) => { + failures.push(format!( + "failed to inspect source path '{}': {error}", + file.source_path.display() + )); + continue; + } + } + if let Err(error) = fs::rename(&file.backup_path, &file.source_path) { + failures.push(format!( + "'{}' -> '{}': {error}", + file.backup_path.display(), + file.source_path.display() + )); + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(anyhow!( + "failed to restore staged sound deletion: {}", + failures.join("; ") + )) + } +} + +pub(crate) fn move_staged_sound_deletions_to_trash( + staged: &[StagedSoundDeletionFile], + trash_dir: &Path, +) -> Result<()> { + let mut trash_session = TrashSession::new(trash_dir.to_path_buf(), SystemTime::now())?; + let mut failures = Vec::new(); + + for file in staged { + match file.backup_path.try_exists() { + Ok(false) => continue, + Ok(true) => {} + Err(error) => { + failures.push(format!( + "failed to inspect staged backup '{}': {error}", + file.backup_path.display() + )); + continue; + } + } + let Some(source_dir) = file.source_path.parent() else { + failures.push(format!( + "missing source directory for {}", + file.source_path.display() + )); + continue; + }; + let destination = match trash_session.destination_path(source_dir, &file.source_path) { + Ok(destination) => destination, + Err(error) => { + failures.push(format!("{}: {error:#}", file.backup_path.display())); + continue; + } + }; + if let Err(error) = fs::rename(&file.backup_path, &destination) { + failures.push(format!( + "'{}' -> '{}': {error}", + file.backup_path.display(), + destination.display() + )); + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(anyhow!( + "failed to move staged sound deletion to trash: {}", + failures.join("; ") + )) + } +} + +fn rollback_staged_after_error( + staged: Vec, + primary: anyhow::Error, +) -> Result { + match restore_staged_sound_deletions(&staged) { + Ok(()) => Err(primary), + Err(rollback) => Err(anyhow!("{primary:#}; {rollback:#}")), + } +} + +fn sound_delete_backup_path(source_path: &Path) -> Result { + let file_name = source_path.file_name().with_context(|| { + format!( + "failed to resolve sound deletion file name from {}", + source_path.display() + ) + })?; + let mut backup_name = file_name.to_os_string(); + backup_name.push(SOUND_DELETE_BACKUP_MARKER); + backup_name.push(uuid::Uuid::new_v4().to_string()); + Ok(source_path.with_file_name(backup_name)) +} + +fn sound_delete_source_path(backup_path: &Path) -> Option { + let file_name = backup_path.file_name()?.to_str()?; + let (source_name, suffix) = file_name.rsplit_once(SOUND_DELETE_BACKUP_MARKER)?; + uuid::Uuid::parse_str(suffix).ok()?; + (!source_name.is_empty()).then(|| backup_path.with_file_name(source_name)) +} + +fn recover_interrupted_sound_deletions( + data: &AppStoreData, + sounds_dir: &Path, + trash_dir: &Path, +) -> Result { + recover_interrupted_sound_deletions_with(data, sounds_dir, trash_dir, |from, to| { + fs::rename(from, to) + }) +} + +fn recover_interrupted_sound_deletions_with( + data: &AppStoreData, + sounds_dir: &Path, + trash_dir: &Path, + mut rename: Rename, +) -> Result +where + Rename: FnMut(&Path, &Path) -> std::io::Result<()>, +{ + let mut backups = Vec::new(); + collect_sound_delete_backups(sounds_dir, &mut backups)?; + let mut outcome = SoundRecoveryOutcome::complete(); + if backups.is_empty() { + return Ok(outcome); + } + + let referenced_keys = collect_sound_deletion_reference_keys(data, sounds_dir); + let mut trash_session = TrashSession::new(trash_dir.to_path_buf(), SystemTime::now())?; + + for backup_path in backups { + let Some(source_path) = sound_delete_source_path(&backup_path) else { + continue; + }; + let referenced = referenced_keys.contains(&path_lookup_key(&source_path)); + let source_exists = match source_path.try_exists() { + Ok(exists) => exists, + Err(error) => { + outcome.complete = false; + outcome.protected_keys.insert(path_lookup_key(&backup_path)); + log::warn!( + "[Sounds] Failed to inspect interrupted deletion source '{}': {error}", + source_path.display() + ); + continue; + } + }; + if referenced && !source_exists { + if let Err(error) = rename(&backup_path, &source_path) { + outcome.complete = false; + outcome.protected_keys.insert(path_lookup_key(&backup_path)); + log::warn!( + "[Sounds] Failed to restore interrupted deletion '{}' -> '{}': {error}", + backup_path.display(), + source_path.display() + ); + } else { + log::info!( + "[Sounds] Restored interrupted deletion '{}'", + source_path.display() + ); + } + continue; + } + + let Some(source_dir) = source_path.parent() else { + continue; + }; + let destination = match trash_session.destination_path(source_dir, &source_path) { + Ok(destination) => destination, + Err(error) => { + log::warn!( + "[Sounds] Failed to prepare interrupted deletion trash for '{}': {error:#}", + backup_path.display() + ); + continue; + } + }; + if let Err(error) = rename(&backup_path, &destination) { + log::warn!( + "[Sounds] Failed to finish interrupted deletion '{}' -> '{}': {error}", + backup_path.display(), + destination.display() + ); + } + } + + Ok(outcome) +} + +fn collect_sound_delete_backups(directory: &Path, backups: &mut Vec) -> Result<()> { + if !directory.exists() { + return Ok(()); + } + for entry in fs::read_dir(directory) + .with_context(|| format!("failed to scan sound directory at {}", directory.display()))? + { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_sound_delete_backups(&entry.path(), backups)?; + } else if file_type.is_file() && sound_delete_source_path(&entry.path()).is_some() { + backups.push(entry.path()); + } + } + Ok(()) +} + +fn collect_sound_deletion_reference_keys( + data: &AppStoreData, + sounds_dir: &Path, +) -> HashSet { + let mut keys = collect_local_sound_paths(data).keys; + for entry in data.sound_library.values() { + let Some(original_path) = entry.original_path.as_deref() else { + continue; + }; + let relative = Path::new(original_path); + if relative.is_absolute() + || relative + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + continue; + } + keys.insert(path_lookup_key(&sounds_dir.join(relative))); + } + keys +} + +fn system_time_millis(time: SystemTime) -> Result { + time.duration_since(UNIX_EPOCH) + .context("system time is before the Unix epoch") + .map(|duration| duration.as_millis()) +} + +fn purge_expired_trash_sessions(trash_dir: &Path) -> Result<()> { + purge_expired_trash_sessions_at(trash_dir, SystemTime::now()) +} + +fn purge_expired_trash_sessions_at(trash_dir: &Path, now: SystemTime) -> Result<()> { + if !trash_dir.exists() { + return Ok(()); + } + + let now_millis = system_time_millis(now)?; + let retention_millis = TRASH_RETENTION.as_millis(); + let read_dir = fs::read_dir(trash_dir).with_context(|| { + format!( + "failed to read asset trash directory at {}", + trash_dir.display() + ) + })?; + + for entry in read_dir { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + log::warn!( + "[Assets] Failed to read an entry from trash '{}': {err}", + trash_dir.display() + ); + continue; + } + }; + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + log::warn!( + "[Assets] Failed to inspect trash entry '{}': {err}", + entry.path().display() + ); + continue; + } + }; + if !file_type.is_dir() { + continue; + } + + let Some(timestamp_millis) = entry + .file_name() + .to_str() + .and_then(parse_trash_session_timestamp) + else { + continue; + }; + if now_millis.saturating_sub(timestamp_millis) <= retention_millis { + continue; + } + + let path = entry.path(); + if let Err(err) = fs::remove_dir_all(&path) { + log::warn!( + "[Assets] Failed to purge expired trash session '{}': {err}", + path.display() + ); + } else { + log::info!("[Assets] Purged expired trash session '{}'", path.display()); + } + } + + Ok(()) +} + +fn parse_trash_session_timestamp(name: &str) -> Option { + let mut parts = name.split('-'); + let timestamp = parts.next()?.parse().ok()?; + match (parts.next(), parts.next()) { + (None, None) => {} + (Some(suffix), None) if suffix.parse::().is_ok() => {} + _ => return None, + } + Some(timestamp) +} + +fn is_temporary_asset_file(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("tmp")) +} + +fn sweep_unreferenced_asset_files( log_prefix: &str, target_dir: &Path, referenced_path_keys: &HashSet, + trash_session: &mut TrashSession, ) -> Result<()> { if !target_dir.exists() { return Ok(()); @@ -525,15 +1697,1623 @@ fn sweep_unreferenced_asset_files( continue; } - if let Err(err) = fs::remove_file(&path) { + if is_temporary_asset_file(&path) { + if let Err(err) = fs::remove_file(&path) { + log::warn!( + "[{log_prefix}] Failed to remove temporary asset '{}': {err}", + path.display() + ); + } else { + log::info!( + "[{log_prefix}] Removed temporary asset '{}'", + path.display() + ); + } + continue; + } + + let destination = match trash_session.destination_path(target_dir, &path) { + Ok(destination) => destination, + Err(err) => { + log::warn!( + "[{log_prefix}] Failed to prepare trash destination for '{}': {err:#}", + path.display() + ); + continue; + } + }; + if let Err(err) = fs::rename(&path, &destination) { log::warn!( - "[{log_prefix}] Failed to remove stale asset '{}': {err}", - path.display() + "[{log_prefix}] Failed to move stale asset '{}' to '{}': {err}", + path.display(), + destination.display() ); } else { - log::info!("[{log_prefix}] Removed stale asset '{}'", path.display()); + log::info!( + "[{log_prefix}] Moved stale asset '{}' to trash at '{}'", + path.display(), + destination.display() + ); } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ + collect_local_image_path_keys, collect_local_sound_path_keys, path_lookup_key, + purge_expired_trash_sessions_at, recover_interrupted_processed_wav_replacements_with, + recover_interrupted_sound_deletions_with, stage_sound_files_for_deletion, + sweep_unreferenced_asset_files, system_time_millis, AppStore, TrashSession, + PROCESSED_WAV_TRANSACTION_LOCK, TRASH_RETENTION, + }; + use crate::{ + defaults::default_positions, + keyboard::KeyboardManager, + models::{ + AppStoreData, CustomFont, CustomTab, FontType, GraphPosition, GraphStatType, GraphType, + KnobPosition, OverlayBounds, PendingProcessedWavReplacement, SettingsPatchInput, + SoundLibraryEntry, SoundSource, StatPosition, StatType, + }, + services::settings::apply_patch_to_store, + state::AppState, + }; + use serde_json::{json, Value}; + use std::{ + collections::HashSet, + path::Path, + sync::{mpsc, Arc, Barrier}, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + + fn test_directory(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("dmnote-{label}-{}", uuid::Uuid::new_v4())) + } + + #[test] + fn keys_update_returns_the_normalized_mode_for_runtime_sync() { + let dir = test_directory("keys-update-mode-sync-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let custom_mode = "custom-mode".to_string(); + let initialized = store + .update(|data| { + data.custom_tabs.push(CustomTab { + id: custom_mode.clone(), + name: "Custom".to_string(), + }); + data.keys + .insert(custom_mode.clone(), vec!["KeyA".to_string()]); + data.selected_key_type = custom_mode.clone(); + }) + .unwrap(); + let keyboard = KeyboardManager::new( + initialized.keys.clone(), + initialized.selected_key_type.clone(), + ); + + let mut mappings = initialized.keys; + mappings.remove(&custom_mode); + let (updated, selected_key_type) = store.update_keys(mappings).unwrap(); + keyboard.update_mappings_and_set_mode(updated, selected_key_type.clone()); + + assert_eq!(selected_key_type, "4key"); + assert_eq!(store.snapshot().selected_key_type, "4key"); + assert_eq!(keyboard.current_mode(), "4key"); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn asset_cleanup_waits_for_an_active_processed_wav_transaction() { + let dir = test_directory("processed-wav-cleanup-lock-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + let cleanup_store = store.clone(); + let cleanup_thread = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = cleanup_store.cleanup_orphan_assets_now(); + done_tx.send(result).unwrap(); + }); + + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(done_rx.recv_timeout(Duration::from_millis(50)).is_err()); + drop(transaction_guard); + done_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(); + cleanup_thread.join().unwrap(); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn shutdown_clears_a_pending_wav_journal_before_stopping_the_writer() { + let dir = test_directory("processed-wav-shutdown-order-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_path = dir.join("sounds").join("sound.wav"); + std::fs::write(&target_path, b"old-wav").unwrap(); + store + .update(|data| { + data.sound_library.insert( + target_path.to_string_lossy().to_string(), + Default::default(), + ); + data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { + sound_path: target_path.to_string_lossy().to_string(), + had_original: true, + }); + }) + .unwrap(); + + store.flush_cleanup_and_shutdown().unwrap(); + + assert_eq!(store.snapshot().pending_processed_wav_replacement, None); + assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn preset_style_layout_and_settings_commit_in_one_writer_revision() { + let dir = test_directory("preset-single-transaction-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_persist_count = store.writer.persist_count(); + + let updated = store + .update(|data| { + data.key_positions.get_mut("4key").unwrap()[0].dx = 321.0; + apply_patch_to_store( + data, + &SettingsPatchInput { + background_color: Some("#123456".to_string()), + ..SettingsPatchInput::default() + }, + ); + }) + .unwrap(); + + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + assert_eq!(updated.key_positions["4key"][0].dx, 321.0); + assert_eq!(updated.background_color, "#123456"); + + let before_failure = store.snapshot(); + let disk_before_failure = std::fs::read(dir.join("store.json")).unwrap(); + store.writer.fail_next_persist(); + let result = store.update(|data| { + data.key_positions.get_mut("4key").unwrap()[0].dx = 999.0; + apply_patch_to_store( + data, + &SettingsPatchInput { + background_color: Some("#FFFFFF".to_string()), + ..SettingsPatchInput::default() + }, + ); + }); + + assert!(result.is_err()); + assert_eq!(store.snapshot(), before_failure); + assert_eq!( + std::fs::read(dir.join("store.json")).unwrap(), + disk_before_failure + ); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + // 프론트 저장 순서 재현: 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 note_settings_1_2_four_field_store_preserves_speed_on_boot() { + let dir = test_directory("note-settings-1-2-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + // 1.2.0 시절 noteSettings 실형식 — 4필드뿐 + let legacy_note_settings = r#"{ + "borderRadius": 2, + "speed": 321, + "trackHeight": 150, + "reverse": false + }"#; + fixture.as_object_mut().unwrap().insert( + "noteSettings".to_string(), + serde_json::from_str(legacy_note_settings).unwrap(), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let settings = store.snapshot().note_settings; + assert_eq!(settings.speed, 321); + assert_eq!(settings.track_height, 150); + assert_eq!(settings.fade_top_px, 50); + assert_eq!(settings.short_note_threshold_ms, 50); + + store.flush_and_shutdown().unwrap(); + 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); + } + + fn defer_test_bounds(store: &AppStore, x: f64) { + store + .update_deferred(|state| { + state.overlay_bounds = Some(OverlayBounds { + x, + y: 20.0, + width: 800.0, + height: 300.0, + }); + state.overlay_bounds_are_logical = true; + }) + .unwrap(); + } + + #[test] + fn deferred_updates_coalesce_into_one_writer_persist() { + const UPDATE_COUNT: usize = 20; + + let dir = test_directory("store-deferred-coalesce-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_persist_count = store.writer.persist_count(); + let initial_revision = store.state.read().revision; + let initial_disk = std::fs::read(dir.join("store.json")).unwrap(); + + for index in 0..UPDATE_COUNT { + defer_test_bounds(&store, index as f64); + } + + assert_eq!(store.writer.persist_count(), initial_persist_count); + assert_eq!(store.state.read().revision, initial_revision); + assert!(store.state.read().dirty); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), initial_disk); + assert_eq!( + store.snapshot().overlay_bounds.unwrap().x, + (UPDATE_COUNT - 1) as f64 + ); + + store.flush().unwrap(); + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + assert_eq!(store.state.read().revision, initial_revision + 1); + assert!(!store.state.read().dirty); + let persisted: AppStoreData = + serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); + assert_eq!( + persisted.overlay_bounds.unwrap().x, + (UPDATE_COUNT - 1) as f64 + ); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn regular_update_commits_pending_deferred_state() { + let dir = test_directory("store-deferred-regular-update-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_persist_count = store.writer.persist_count(); + defer_test_bounds(&store, 321.0); + + store + .update(|state| { + state + .plugin_data + .insert("unrelated".to_string(), json!(true)); + }) + .unwrap(); + + assert!(!store.state.read().dirty); + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + let persisted: AppStoreData = + serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); + assert_eq!(persisted.overlay_bounds.unwrap().x, 321.0); + assert_eq!(persisted.plugin_data["unrelated"], json!(true)); + + store.flush().unwrap(); + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn failed_flush_keeps_deferred_state_dirty_and_committed_in_memory() { + let dir = test_directory("store-deferred-failure-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_persist_count = store.writer.persist_count(); + let initial_revision = store.state.read().revision; + let initial_disk = std::fs::read(dir.join("store.json")).unwrap(); + defer_test_bounds(&store, 654.0); + store.writer.fail_next_persist(); + + assert!(store.flush().is_err()); + assert!(store.state.read().dirty); + assert_eq!(store.state.read().revision, initial_revision); + assert_eq!(store.snapshot().overlay_bounds.unwrap().x, 654.0); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), initial_disk); + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + + store.flush().unwrap(); + assert!(!store.state.read().dirty); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn flush_without_dirty_state_is_idempotent() { + let dir = test_directory("store-deferred-idempotent-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_persist_count = store.writer.persist_count(); + let initial_revision = store.state.read().revision; + let initial_disk = std::fs::read(dir.join("store.json")).unwrap(); + + store.flush().unwrap(); + store.flush().unwrap(); + + assert_eq!(store.writer.persist_count(), initial_persist_count); + assert_eq!(store.state.read().revision, initial_revision); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), initial_disk); + store.flush_and_shutdown().unwrap(); + drop(store); + 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 initial_revision = store.state.read().revision; + 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:?}"); + assert_eq!( + store.state.read().revision, + initial_revision + UPDATE_COUNT as u64 + ); + + 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 writer_failure_keeps_snapshot_and_disk_unchanged_and_does_not_leak() { + let dir = test_directory("store-transaction-failure-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let path = dir.join("store.json"); + let before_snapshot = store.snapshot(); + let before_disk = std::fs::read(&path).unwrap(); + let before_revision = store.state.read().revision; + + store.writer.fail_next_persist(); + let result = store.update(|state| { + state.always_on_top = !before_snapshot.always_on_top; + }); + + assert!(result.is_err()); + assert_eq!(store.snapshot(), before_snapshot); + assert_eq!( + store.with_state(|state| state.always_on_top), + before_snapshot.always_on_top + ); + assert_eq!(std::fs::read(&path).unwrap(), before_disk); + assert_eq!(store.state.read().revision, before_revision); + + store + .update(|state| { + state + .plugin_data + .insert("unrelated".to_string(), json!(true)); + }) + .unwrap(); + let after_snapshot = store.snapshot(); + let after_disk: AppStoreData = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(after_snapshot.always_on_top, before_snapshot.always_on_top); + assert_eq!(after_disk.always_on_top, before_snapshot.always_on_top); + assert_eq!(after_snapshot.plugin_data["unrelated"], json!(true)); + assert_eq!(after_disk, after_snapshot); + assert_eq!(store.state.read().revision, before_revision + 1); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn counter_persist_failure_keeps_runtime_mirror_unchanged() { + let dir = test_directory("counter-transaction-failure-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let snapshot = store.snapshot(); + let mode = snapshot.selected_key_type.clone(); + let key = snapshot.keys[&mode][0].clone(); + let mut counters = snapshot.key_counters; + counters.entry(mode.clone()).or_default().insert(key, 42); + store.set_key_counters(counters).unwrap(); + + let state = AppState::initialize(store).unwrap(); + let before_runtime = state.snapshot_key_counters(); + let before_store = state.store.snapshot(); + let before_disk = std::fs::read(dir.join("store.json")).unwrap(); + state.store.writer.fail_next_persist(); + + assert!(state.reset_key_counters().is_err()); + assert_eq!(state.snapshot_key_counters(), before_runtime); + assert_eq!(state.store.snapshot(), before_store); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), before_disk); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn existing_obs_token_is_reused_without_persist() { + let dir = test_directory("obs-token-reuse-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let token = "persisted-obs-token".to_string(); + store + .update(|state| { + state.obs_token = Some(token.clone()); + }) + .unwrap(); + let state = AppState::initialize(store).unwrap(); + let persist_count_before = state.store.writer.persist_count(); + let disk_before = std::fs::read(dir.join("store.json")).unwrap(); + + assert_eq!(state.resolve_and_save_obs_token().unwrap(), token); + assert_eq!(state.store.writer.persist_count(), persist_count_before); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), disk_before); + + state.shutdown(); + drop(state); + 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"))))); + } + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn asset_references_preserve_the_leading_slash_in_file_urls() { + let mut data = AppStoreData::default(); + let mut position = default_positions()["4key"][0].clone(); + position.active_image = Some("file:///tmp/dmnote-file-url.png".to_string()); + position.sound_path = Some("file:///tmp/dmnote-file-url.wav".to_string()); + data.key_positions + .insert("4key".to_string(), vec![position]); + + assert!(collect_local_image_path_keys(&data) + .contains(&path_lookup_key(Path::new("/tmp/dmnote-file-url.png")))); + assert!(collect_local_sound_path_keys(&data) + .contains(&path_lookup_key(Path::new("/tmp/dmnote-file-url.wav")))); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn percent_encoded_file_url_keeps_referenced_image_out_of_trash() { + let dir = test_directory("percent-encoded-image-sweep-test"); + let image_path = dir.join("images").join("referenced image.png"); + std::fs::create_dir_all(image_path.parent().unwrap()).unwrap(); + std::fs::write(&image_path, b"image").unwrap(); + let image_url = url::Url::from_file_path(&image_path).unwrap().to_string(); + assert!(image_url.contains("%20")); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .update(|data| { + data.key_positions.get_mut("4key").unwrap()[0].active_image = Some(image_url); + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(image_path.exists()); + assert!(!dir.join("trash").exists()); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn invalid_file_url_skips_destructive_image_sweep() { + let dir = test_directory("invalid-file-url-sweep-test"); + let image_path = dir.join("images").join("unreferenced.png"); + std::fs::create_dir_all(image_path.parent().unwrap()).unwrap(); + std::fs::write(&image_path, b"image").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .update(|data| { + data.key_positions.get_mut("4key").unwrap()[0].active_image = + Some("file:///tmp/broken%ZZ.png".to_string()); + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(image_path.exists()); + assert!(!dir.join("trash").exists()); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn invalid_file_url_skips_destructive_font_sweep() { + let dir = test_directory("invalid-font-file-url-sweep-test"); + let font_path = dir.join("fonts").join("unreferenced.ttf"); + std::fs::create_dir_all(font_path.parent().unwrap()).unwrap(); + std::fs::write(&font_path, b"font").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .update(|data| { + data.font_settings.custom_fonts.push(CustomFont { + id: "invalid-url-font".to_string(), + font_type: FontType::Local, + name: "Invalid URL Font".to_string(), + display_name: "Invalid URL Font".to_string(), + enabled: true, + local_path: Some("file:///tmp/broken%ZZ.ttf".to_string()), + css_content: None, + }); + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(font_path.exists()); + assert!(!dir.join("trash").exists()); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn interrupted_sound_deletion_restores_files_still_referenced_by_store() { + let dir = test_directory("sound-delete-startup-restore-test"); + let sounds_dir = dir.join("sounds"); + let originals_dir = sounds_dir.join("originals"); + std::fs::create_dir_all(&originals_dir).unwrap(); + let sound_path = sounds_dir.join("sound.wav"); + let original_path = originals_dir.join("original.wav"); + std::fs::write(&sound_path, b"sound").unwrap(); + std::fs::write(&original_path, b"original").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let sound_key = sound_path.to_string_lossy().to_string(); + store + .update(|data| { + data.sound_library.insert( + sound_key.clone(), + SoundLibraryEntry { + original_path: Some("originals/original.wav".to_string()), + ..Default::default() + }, + ); + }) + .unwrap(); + let staged = + stage_sound_files_for_deletion(&[sound_path.clone(), original_path.clone()]).unwrap(); + assert_eq!(staged.len(), 2); + assert!(!sound_path.exists()); + assert!(!original_path.exists()); + + store + .recover_interrupted_processed_wav_replacements_now() + .unwrap(); + + assert_eq!(std::fs::read(&sound_path).unwrap(), b"sound"); + assert_eq!(std::fs::read(&original_path).unwrap(), b"original"); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn interrupted_sound_deletion_restore_failure_is_reported_incomplete() { + let dir = test_directory("sound-delete-incomplete-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let sound_path = sounds_dir.join("sound.wav"); + std::fs::write(&sound_path, b"sound").unwrap(); + + let mut data = AppStoreData::default(); + data.sound_library.insert( + sound_path.to_string_lossy().to_string(), + SoundLibraryEntry::default(), + ); + let staged = stage_sound_files_for_deletion(std::slice::from_ref(&sound_path)).unwrap(); + let backup_path = staged[0].backup_path.clone(); + + let recovery = recover_interrupted_sound_deletions_with( + &data, + &sounds_dir, + &dir.join("trash"), + |_from, _to| Err(std::io::Error::other("injected recovery failure")), + ) + .unwrap(); + + assert!(!recovery.complete); + assert!(recovery + .protected_keys + .contains(&path_lookup_key(&backup_path))); + assert!(!sound_path.exists()); + assert!(backup_path.exists()); + + std::fs::rename(backup_path, sound_path).unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn interrupted_committed_sound_deletion_finishes_in_trash() { + let dir = test_directory("sound-delete-startup-finish-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let sound_path = sounds_dir.join("sound.wav"); + std::fs::write(&sound_path, b"sound").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let sound_key = sound_path.to_string_lossy().to_string(); + store + .update(|data| { + data.sound_library + .insert(sound_key.clone(), SoundLibraryEntry::default()); + }) + .unwrap(); + stage_sound_files_for_deletion(std::slice::from_ref(&sound_path)).unwrap(); + store + .update(|data| { + data.sound_library.remove(&sound_key); + }) + .unwrap(); + + store + .recover_interrupted_processed_wav_replacements_now() + .unwrap(); + + assert!(!sound_path.exists()); + let quarantined = std::fs::read_dir(dir.join("trash")) + .unwrap() + .flat_map(|session| std::fs::read_dir(session.unwrap().path()).unwrap()) + .flat_map(|category| std::fs::read_dir(category.unwrap().path()).unwrap()) + .any(|entry| entry.unwrap().file_name() == "sound.wav"); + assert!(quarantined); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn sound_deletion_staging_failure_restores_already_staged_files() { + let dir = test_directory("sound-delete-staging-rollback-test"); + std::fs::create_dir_all(&dir).unwrap(); + let sound_path = dir.join("sound.wav"); + let invalid_path = dir.join("not-a-file.wav"); + std::fs::write(&sound_path, b"sound").unwrap(); + std::fs::create_dir(&invalid_path).unwrap(); + + let error = stage_sound_files_for_deletion(&[sound_path.clone(), invalid_path]) + .unwrap_err() + .to_string(); + + assert!(error.contains("not a file")); + assert_eq!(std::fs::read(&sound_path).unwrap(), b"sound"); + assert!(!std::fs::read_dir(&dir).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".delete-backup-") + })); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn repaired_non_utf8_store_skips_every_asset_sweep() { + let dir = test_directory("non-utf8-asset-sweep-health-gate-test"); + let sound_path = dir.join("sounds").join("unreferenced.wav"); + let font_path = dir.join("fonts").join("unreferenced.ttf"); + let image_path = dir.join("images").join("unreferenced.png"); + for path in [&sound_path, &font_path, &image_path] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"asset-fixture").unwrap(); + } + std::fs::write(dir.join("store.json"), [b'{', 0xFF, b'}']).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(store.skip_asset_sweep); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(sound_path.exists()); + assert!(font_path.exists()); + assert!(image_path.exists()); + assert!(!dir.join("trash").exists()); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn repaired_sound_library_keeps_valid_local_asset_through_cleanup() { + let dir = test_directory("sound-library-entry-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + + let builtin_hait = sounds_dir.join("builtin-hait.wav"); + let builtin_click = sounds_dir.join("builtin-click.wav"); + let valid_local = sounds_dir.join("valid-local.wav"); + let invalid_local = sounds_dir.join("invalid-local.wav"); + let wav_fixture = include_bytes!("../../assets/sounds/builtin-click.wav"); + for path in [&builtin_hait, &builtin_click, &valid_local, &invalid_local] { + std::fs::write(path, wav_fixture).unwrap(); + } + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let sound_library = fixture["soundLibrary"].as_object_mut().unwrap(); + sound_library.insert( + builtin_hait.to_string_lossy().to_string(), + json!({ "source": "builtin" }), + ); + sound_library.insert( + builtin_click.to_string_lossy().to_string(), + json!({ "source": "builtin" }), + ); + sound_library.insert( + valid_local.to_string_lossy().to_string(), + json!({ "source": "local", "displayName": "Valid local" }), + ); + sound_library.insert( + invalid_local.to_string_lossy().to_string(), + json!({ "source": 42, "displayName": "Invalid local" }), + ); + std::fs::write( + dir.join("store.json"), + serde_json::to_vec_pretty(&fixture).unwrap(), + ) + .unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let snapshot = store.snapshot(); + let valid_local_key = valid_local.to_string_lossy().to_string(); + let invalid_local_key = invalid_local.to_string_lossy().to_string(); + + assert_eq!(snapshot.sound_library.len(), 4); + assert_eq!( + snapshot.sound_library[&valid_local_key].source, + SoundSource::Local + ); + assert_eq!( + snapshot.sound_library[&invalid_local_key].source, + SoundSource::Local + ); + assert_eq!( + snapshot.sound_library[&invalid_local_key] + .display_name + .as_deref(), + Some("Invalid local") + ); + assert!(store.skip_asset_sweep); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(valid_local.exists()); + assert!(invalid_local.exists()); + assert!(!dir.join("trash").exists()); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn repaired_asset_references_survive_the_next_normal_session_sweep() { + fn corrupt_height(mut value: Value) -> Value { + value + .as_object_mut() + .unwrap() + .insert("height".to_string(), json!("invalid")); + value + } + + let dir = test_directory("repaired-asset-reference-next-session-test"); + let font_path = dir.join("fonts").join("recoverable.ttf"); + let library_sound = dir.join("sounds").join("library.wav"); + let stat_sound = dir.join("sounds").join("stat.wav"); + let graph_sound = dir.join("sounds").join("graph.wav"); + let knob_sound = dir.join("sounds").join("knob.wav"); + for path in [ + &font_path, + &library_sound, + &stat_sound, + &graph_sound, + &knob_sound, + ] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"asset-fixture").unwrap(); + } + + let mut font = serde_json::to_value(CustomFont { + id: "recoverable-font".to_string(), + font_type: FontType::Local, + name: "Recoverable Font".to_string(), + display_name: "Recoverable Font".to_string(), + enabled: true, + local_path: Some(font_path.to_string_lossy().to_string()), + css_content: None, + }) + .unwrap(); + font.as_object_mut() + .unwrap() + .insert("enabled".to_string(), json!("invalid")); + + let mut stat_position = default_positions()["4key"][0].clone(); + stat_position.sound_path = Some(stat_sound.to_string_lossy().to_string()); + let stat = corrupt_height( + serde_json::to_value(StatPosition { + stat_type: StatType::Kps, + position: stat_position, + }) + .unwrap(), + ); + let mut graph_position = default_positions()["4key"][1].clone(); + graph_position.sound_path = Some(graph_sound.to_string_lossy().to_string()); + let graph = corrupt_height( + serde_json::to_value(GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#FFFFFF".to_string(), + show_avg_line: true, + position: graph_position, + }) + .unwrap(), + ); + let mut knob_position = default_positions()["4key"][2].clone(); + knob_position.sound_path = Some(knob_sound.to_string_lossy().to_string()); + let knob = corrupt_height( + serde_json::to_value(KnobPosition { + axis_id: "axis-recoverable".to_string(), + sensitivity: 1.0, + reverse: false, + position: knob_position, + }) + .unwrap(), + ); + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert("fontSettings".to_string(), json!({ "customFonts": [font] })); + fields.insert( + "soundLibrary".to_string(), + json!({ library_sound.to_string_lossy().to_string(): 42 }), + ); + fields.insert("statPositions".to_string(), json!({ "asset-mode": [stat] })); + fields.insert( + "graphPositions".to_string(), + json!({ "asset-mode": [graph] }), + ); + fields.insert("knobPositions".to_string(), json!({ "asset-mode": [knob] })); + std::fs::write( + dir.join("store.json"), + serde_json::to_vec_pretty(&fixture).unwrap(), + ) + .unwrap(); + + let recovered = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(recovered.skip_asset_sweep); + let snapshot = recovered.snapshot(); + assert!(!snapshot.font_settings.custom_fonts[0].enabled); + assert!(snapshot + .sound_library + .contains_key(&library_sound.to_string_lossy().to_string())); + assert_eq!(snapshot.stat_positions["asset-mode"].len(), 1); + assert_eq!(snapshot.graph_positions["asset-mode"].len(), 1); + assert_eq!(snapshot.knob_positions["asset-mode"].len(), 1); + recovered.cleanup_orphan_assets_now().unwrap(); + recovered.flush_and_shutdown().unwrap(); + drop(recovered); + + let normal = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(!normal.skip_asset_sweep); + normal.cleanup_orphan_assets_now().unwrap(); + for path in [ + &font_path, + &library_sound, + &stat_sound, + &graph_sound, + &knob_sound, + ] { + assert!(path.exists(), "recovered asset was quarantined: {path:?}"); + } + + normal.flush_and_shutdown().unwrap(); + drop(normal); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn normal_session_moves_unreferenced_asset_to_trash() { + let dir = test_directory("asset-trash-quarantine-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(!store.skip_asset_sweep); + + let orphan_path = dir.join("sounds").join("orphan.wav"); + std::fs::write(&orphan_path, b"orphan-wav").unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(!orphan_path.exists()); + let trash_dir = dir.join("trash"); + let sessions: Vec<_> = std::fs::read_dir(&trash_dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!(sessions.len(), 1); + let quarantined_path = sessions[0].join("sounds").join("orphan.wav"); + assert_eq!(std::fs::read(quarantined_path).unwrap(), b"orphan-wav"); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn trash_collision_uses_another_session_and_preserves_file_name() { + let dir = test_directory("asset-trash-collision-test"); + let sounds_dir = dir.join("sounds"); + let trash_dir = dir.join("trash"); + let now = UNIX_EPOCH + Duration::from_secs(2_000_000_000); + let timestamp = system_time_millis(now).unwrap(); + let collision_path = trash_dir + .join(timestamp.to_string()) + .join("sounds") + .join("same.wav"); + let source_path = sounds_dir.join("same.wav"); + std::fs::create_dir_all(collision_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&sounds_dir).unwrap(); + std::fs::write(&collision_path, b"existing").unwrap(); + std::fs::write(&source_path, b"incoming").unwrap(); + + let mut trash_session = TrashSession::new(trash_dir.clone(), now).unwrap(); + sweep_unreferenced_asset_files("Sounds", &sounds_dir, &HashSet::new(), &mut trash_session) + .unwrap(); + + let moved_path = trash_dir + .join(format!("{timestamp}-1")) + .join("sounds") + .join("same.wav"); + assert!(!source_path.exists()); + assert_eq!(std::fs::read(collision_path).unwrap(), b"existing"); + assert_eq!(std::fs::read(moved_path).unwrap(), b"incoming"); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn trash_purge_removes_only_sessions_older_than_thirty_days() { + let dir = test_directory("asset-trash-retention-test"); + let trash_dir = dir.join("trash"); + let now = UNIX_EPOCH + Duration::from_secs(2_000_000_000); + let expired_time = now + .checked_sub(TRASH_RETENTION + Duration::from_secs(1)) + .unwrap(); + let retained_time = now + .checked_sub(Duration::from_secs(29 * 24 * 60 * 60)) + .unwrap(); + let expired_dir = + trash_dir.join(format!("{}-1", system_time_millis(expired_time).unwrap())); + let retained_dir = trash_dir.join(system_time_millis(retained_time).unwrap().to_string()); + std::fs::create_dir_all(expired_dir.join("sounds")).unwrap(); + std::fs::create_dir_all(retained_dir.join("sounds")).unwrap(); + std::fs::write(expired_dir.join("sounds").join("old.wav"), b"old").unwrap(); + std::fs::write(retained_dir.join("sounds").join("recent.wav"), b"recent").unwrap(); + + purge_expired_trash_sessions_at(&trash_dir, now).unwrap(); + + assert!(!expired_dir.exists()); + assert!(retained_dir.exists()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn orphan_sweep_recovers_processed_wav_backup_before_removing_temp() { + let dir = test_directory("processed-wav-orphan-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + + let target_path = sounds_dir.join("sound.wav"); + let backup_path = sounds_dir.join("sound.wav.bak"); + let temp_path = sounds_dir.join(format!( + ".sound.wav.processed-wav-{}.tmp", + uuid::Uuid::new_v4() + )); + std::fs::write(&backup_path, b"old-wav").unwrap(); + std::fs::write(&temp_path, b"new-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_key = target_path.to_string_lossy().to_string(); + store + .update(|data| { + data.sound_library + .insert(target_key.clone(), Default::default()); + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); + assert!(!backup_path.exists()); + assert!(!temp_path.exists()); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn app_state_initialization_recovers_processed_wav_without_sweeping_temp() { + let dir = test_directory("processed-wav-startup-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + + let target_path = sounds_dir.join("sound.wav"); + let backup_path = sounds_dir.join("sound.wav.bak"); + let temp_path = sounds_dir.join(format!( + ".sound.wav.processed-wav-{}.tmp", + uuid::Uuid::new_v4() + )); + std::fs::write(&backup_path, b"old-wav").unwrap(); + std::fs::write(&temp_path, b"interrupted-new-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(!target_path.exists()); + + let state = AppState::initialize(store).unwrap(); + + assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); + assert!(!backup_path.exists()); + assert!(temp_path.exists()); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn pending_wav_journal_rolls_back_file_before_metadata_is_committed() { + let dir = test_directory("processed-wav-journal-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let target_path = sounds_dir.join("sound.wav"); + let backup_path = sounds_dir.join("sound.wav.bak"); + std::fs::write(&target_path, b"old-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_key = target_path.to_string_lossy().to_string(); + store + .update(|data| { + data.sound_library.insert( + target_key.clone(), + crate::models::SoundLibraryEntry { + display_name: Some("old-metadata".to_string()), + ..Default::default() + }, + ); + data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { + sound_path: target_key.clone(), + had_original: true, + }); + }) + .unwrap(); + + std::fs::rename(&target_path, &backup_path).unwrap(); + std::fs::write(&target_path, b"uncommitted-new-wav").unwrap(); + store + .recover_interrupted_processed_wav_replacements_now() + .unwrap(); + + let snapshot = store.snapshot(); + assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); + assert_eq!( + snapshot.sound_library[&target_key].display_name.as_deref(), + Some("old-metadata") + ); + assert_eq!(snapshot.pending_processed_wav_replacement, None); + assert!(!backup_path.exists()); + assert!(std::fs::read_dir(&sounds_dir).unwrap().any(|entry| { + let entry = entry.unwrap(); + entry + .file_name() + .to_string_lossy() + .contains(".interrupted-") + && std::fs::read(entry.path()).unwrap() == b"uncommitted-new-wav" + })); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_pending_wav_files_clear_journal_and_allow_repeated_cleanup() { + let dir = test_directory("processed-wav-missing-journal-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let target_path = sounds_dir.join("missing.wav"); + let orphan_path = sounds_dir.join("orphan.wav"); + std::fs::write(&orphan_path, b"orphan-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .update(|data| { + data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { + sound_path: target_path.to_string_lossy().to_string(), + had_original: true, + }); + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert_eq!(store.snapshot().pending_processed_wav_replacement, None); + assert!(!orphan_path.exists()); + let trash_sessions: Vec<_> = std::fs::read_dir(dir.join("trash")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert!(trash_sessions + .iter() + .any(|session| session.join("sounds").join("orphan.wav").exists())); + + store.cleanup_orphan_assets_now().unwrap(); + assert_eq!(store.snapshot().pending_processed_wav_replacement, None); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn unknown_pending_wav_presence_keeps_journal() { + let dir = test_directory("processed-wav-unknown-presence-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let target_path = sounds_dir.join("unknown.wav"); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let pending = PendingProcessedWavReplacement { + sound_path: target_path.to_string_lossy().to_string(), + had_original: true, + }; + store + .update(|data| { + data.pending_processed_wav_replacement = Some(pending.clone()); + }) + .unwrap(); + let checks = std::cell::Cell::new(0); + + let result = + store.recover_pending_processed_wav_replacement_with(&sounds_dir, |_path| match checks + .get() + { + 0 => { + checks.set(1); + Ok(false) + } + _ => { + checks.set(2); + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected permission failure", + )) + } + }); + + assert!(result.is_err()); + assert_eq!(checks.get(), 2); + assert_eq!( + store.snapshot().pending_processed_wav_replacement, + Some(pending) + ); + + store + .update(|data| data.pending_processed_wav_replacement = None) + .unwrap(); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn completed_wav_transaction_keeps_new_file_when_only_backup_cleanup_was_interrupted() { + let dir = test_directory("processed-wav-post-commit-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let target_path = sounds_dir.join("sound.wav"); + let backup_path = sounds_dir.join("sound.wav.bak"); + std::fs::write(&target_path, b"new-wav").unwrap(); + std::fs::write(&backup_path, b"old-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_key = target_path.to_string_lossy().to_string(); + store + .update(|data| { + data.sound_library.insert( + target_key.clone(), + crate::models::SoundLibraryEntry { + display_name: Some("new-metadata".to_string()), + ..Default::default() + }, + ); + data.pending_processed_wav_replacement = None; + }) + .unwrap(); + + store + .recover_interrupted_processed_wav_replacements_now() + .unwrap(); + + assert_eq!(std::fs::read(&target_path).unwrap(), b"new-wav"); + assert_eq!(std::fs::read(&backup_path).unwrap(), b"old-wav"); + assert_eq!( + store.snapshot().sound_library[&target_key] + .display_name + .as_deref(), + Some("new-metadata") + ); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn pending_wav_without_an_original_quarantines_the_uncommitted_file() { + let dir = test_directory("processed-wav-new-file-journal-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let target_path = sounds_dir.join("new.wav"); + std::fs::write(&target_path, b"uncommitted-new-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .update(|data| { + data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { + sound_path: target_path.to_string_lossy().to_string(), + had_original: false, + }); + }) + .unwrap(); + + store + .recover_interrupted_processed_wav_replacements_now() + .unwrap(); + + assert!(!target_path.exists()); + assert_eq!(store.snapshot().pending_processed_wav_replacement, None); + assert!(std::fs::read_dir(&sounds_dir).unwrap().any(|entry| { + let entry = entry.unwrap(); + entry + .file_name() + .to_string_lossy() + .contains(".interrupted-") + && std::fs::read(entry.path()).unwrap() == b"uncommitted-new-wav" + })); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn orphan_sweep_continues_recovery_and_protects_failed_backup() { + let dir = test_directory("processed-wav-partial-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + + let first_target = sounds_dir.join("first.wav"); + let first_backup = sounds_dir.join("first.wav.bak"); + let second_target = sounds_dir.join("second.wav"); + let second_backup = sounds_dir.join("second.wav.bak"); + let temp_path = sounds_dir.join(format!( + ".first.wav.processed-wav-{}.tmp", + uuid::Uuid::new_v4() + )); + std::fs::write(&first_backup, b"first-old-wav").unwrap(); + std::fs::write(&second_backup, b"second-old-wav").unwrap(); + std::fs::write(&temp_path, b"interrupted-new-wav").unwrap(); + + let mut retained_keys = HashSet::from([ + path_lookup_key(&first_target), + path_lookup_key(&second_target), + ]); + let rename_attempts = std::cell::Cell::new(0); + let failed_backup = std::cell::RefCell::new(None); + let recovered_target = std::cell::RefCell::new(None); + let recovery = + recover_interrupted_processed_wav_replacements_with(&sounds_dir, |from, to| { + let attempt = rename_attempts.get(); + rename_attempts.set(attempt + 1); + if attempt == 0 { + failed_backup.replace(Some(from.to_path_buf())); + Err(std::io::Error::other("injected recovery failure")) + } else { + recovered_target.replace(Some(to.to_path_buf())); + std::fs::rename(from, to) + } + }) + .unwrap(); + assert!(!recovery.complete); + retained_keys.extend(recovery.protected_keys); + let mut trash_session = TrashSession::new(dir.join("trash"), SystemTime::now()).unwrap(); + sweep_unreferenced_asset_files("Sounds", &sounds_dir, &retained_keys, &mut trash_session) + .unwrap(); + + assert_eq!(rename_attempts.get(), 2); + let failed_backup = failed_backup.into_inner().unwrap(); + let failed_target_name = failed_backup + .file_name() + .unwrap() + .to_string_lossy() + .strip_suffix(".bak") + .unwrap() + .to_string(); + let failed_target = failed_backup.with_file_name(failed_target_name); + let recovered_target = recovered_target.into_inner().unwrap(); + assert!(!failed_target.exists()); + assert!(failed_backup.exists()); + assert!(recovered_target.exists()); + assert!(!recovered_target.with_extension("wav.bak").exists()); + assert!(!temp_path.exists()); + + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index f887c8a4..d9dd056c 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -4,9 +4,13 @@ "active": true, "targets": ["app", "dmg"], "icon": ["icons/icon.icns"], - "resources": ["target/dmnote-helper/DM NOTE.app"], + "resources": { + "target/dmnote-helper/DM NOTE.app": "DM NOTE.app", + "../THIRD_PARTY_NOTICES.txt": "THIRD_PARTY_NOTICES.txt" + }, "macOS": { - "entitlements": null + "entitlements": null, + "minimumSystemVersion": "11.0" } } } 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/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 (
+ +
+ + + {children} + + + + ); +}; + +export default FullSurfaceModalLayout; diff --git a/src/renderer/components/main/Modal/ListPopup.tsx b/src/renderer/components/main/Modal/ListPopup.tsx index ab1bc356..21a04b01 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; @@ -134,7 +130,7 @@ const SubMenu = ({ data-dmn-popup-submenu="true" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} - className={`fixed z-[60] bg-glass backdrop-blur-[24px] shadow-elevation-2 rounded-surface p-[4px] flex flex-col gap-[4px] tooltip-fade-in${ + className={`fixed z-[60] bg-glass backdrop-glass-popup shadow-elevation-2 rounded-surface p-[4px] flex flex-col gap-[4px] tooltip-fade-in${ needsScroll ? ' listpopup-scroll' : '' }`} style={{ @@ -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,12 +348,11 @@ const ListPopup = ({ className = '', offsetX = 0, offsetY = 0, - textAlign = 'center', maxVisibleItems, }: ListPopupProps) => { // 일시적 팝업은 상주 크롬(z-30, 패널·미니맵)보다 항상 위 const defaultClassName = - 'z-40 bg-glass backdrop-blur-[24px] shadow-elevation-2 rounded-surface p-[4px] flex flex-col gap-[4px]'; + 'z-40 bg-glass backdrop-glass-popup shadow-elevation-2 rounded-surface p-[4px] flex flex-col gap-[4px]'; const effectiveClassName = `${defaultClassName} ${className}`.trim(); // 스크롤 필요 여부 계산 (아이템 26 + 갭 4 리듬) @@ -440,7 +406,6 @@ const ListPopup = ({
e.stopPropagation()} > {/* 탭 영역 */} 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/dialogs/Alert.tsx b/src/renderer/components/main/Modal/content/dialogs/Alert.tsx index 1626d727..060bb014 100644 --- a/src/renderer/components/main/Modal/content/dialogs/Alert.tsx +++ b/src/renderer/components/main/Modal/content/dialogs/Alert.tsx @@ -69,7 +69,7 @@ const Alert = ({ ariaLabel={isCustom ? t('common.dialog') : message} >
e.stopPropagation()} > {/* 메시지 텍스트 or Custom HTML */} diff --git a/src/renderer/components/main/Modal/content/dialogs/PluginDataDeleteModal.tsx b/src/renderer/components/main/Modal/content/dialogs/PluginDataDeleteModal.tsx index d28bdfa1..a05bec43 100644 --- a/src/renderer/components/main/Modal/content/dialogs/PluginDataDeleteModal.tsx +++ b/src/renderer/components/main/Modal/content/dialogs/PluginDataDeleteModal.tsx @@ -23,7 +23,7 @@ export function PluginDataDeleteModal({ return (
event.stopPropagation()} > diff --git a/src/renderer/components/main/Modal/content/dialogs/UnifiedKeySetting.tsx b/src/renderer/components/main/Modal/content/dialogs/UnifiedKeySetting.tsx index b9901224..a59fd01c 100644 --- a/src/renderer/components/main/Modal/content/dialogs/UnifiedKeySetting.tsx +++ b/src/renderer/components/main/Modal/content/dialogs/UnifiedKeySetting.tsx @@ -188,7 +188,7 @@ const UnifiedKeySetting: React.FC = ({ ariaLabel={t('keySetting.title')} >
e.stopPropagation()} >
e.stopPropagation()} > {isLatestVersion ? ( 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 && (