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