From 57e8bd8d132e4fa9276a77bb96f4bc530af41151 Mon Sep 17 00:00:00 2001 From: hu-ja-ja Date: Thu, 12 Feb 2026 01:05:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E3=82=92=E5=88=A5?= =?UTF-8?q?=E3=82=A6=E3=82=A3=E3=83=B3=E3=83=89=E3=82=A6=E3=81=AB/?= =?UTF-8?q?=E3=83=9B=E3=83=83=E3=83=88=E3=82=AD=E3=83=BC=E3=82=92=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E5=8F=AF=E8=83=BD=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/WindowController.App/App.xaml.cs | 119 ++++- src/WindowController.App/HotkeyManager.cs | 308 +++++++++++- src/WindowController.App/MainWindow.xaml | 98 +--- src/WindowController.App/MainWindow.xaml.cs | 10 + src/WindowController.App/ProfileApplier.cs | 188 +++++++ src/WindowController.App/SettingsWindow.xaml | 251 ++++++++++ .../SettingsWindow.xaml.cs | 54 ++ .../ViewModels/MainViewModel.cs | 95 ---- .../ViewModels/SettingsViewModel.cs | 465 ++++++++++++++++++ .../AppSettingsTests.cs | 143 ++++++ .../Models/AppSettings.cs | 6 + .../Models/HotkeySettings.cs | 100 ++++ src/WindowController.Win32/NativeMethods.cs | 4 +- 13 files changed, 1636 insertions(+), 205 deletions(-) create mode 100644 src/WindowController.App/ProfileApplier.cs create mode 100644 src/WindowController.App/SettingsWindow.xaml create mode 100644 src/WindowController.App/SettingsWindow.xaml.cs create mode 100644 src/WindowController.App/ViewModels/SettingsViewModel.cs create mode 100644 src/WindowController.Core.Tests/AppSettingsTests.cs create mode 100644 src/WindowController.Core/Models/HotkeySettings.cs diff --git a/src/WindowController.App/App.xaml.cs b/src/WindowController.App/App.xaml.cs index 0d194c8..b57b554 100644 --- a/src/WindowController.App/App.xaml.cs +++ b/src/WindowController.App/App.xaml.cs @@ -18,9 +18,14 @@ public partial class App : Application private static Mutex? _singleInstanceMutex; private TaskbarIcon? _trayIcon; private MainWindow? _mainWindow; + private SettingsWindow? _settingsWindow; private MainViewModel? _viewModel; + private SettingsViewModel? _settingsViewModel; private SyncManager? _syncManager; private HotkeyManager? _hotkeyManager; + private ProfileApplier? _profileApplier; + private ProfileStore? _profileStore; + private AppSettingsStore? _appSettingsStore; private ILogger? _log; protected override void OnStartup(StartupEventArgs e) @@ -62,23 +67,26 @@ protected override void OnStartup(StartupEventArgs e) _log.Information("Window-Controller starting"); // Load app-level settings (profiles path etc.) - var appSettings = new AppSettingsStore(appSettingsPath, defaultProfilesPath, _log); - appSettings.Load(); + _appSettingsStore = new AppSettingsStore(appSettingsPath, defaultProfilesPath, _log); + _appSettingsStore.Load(); - var profilesPath = appSettings.EffectiveProfilesPath; + var profilesPath = _appSettingsStore.EffectiveProfilesPath; _log.Information("Profiles path: {Path}", profilesPath); // Core services - var store = new ProfileStore(profilesPath, _log); - store.Load(); + _profileStore = new ProfileStore(profilesPath, _log); + _profileStore.Load(); var urlRetriever = new BrowserUrlRetriever(_log); var enumerator = new WindowEnumerator(_log, (hwnd, exe) => urlRetriever.TryGetUrl(hwnd, exe)); var arranger = new WindowArranger(_log); var hookManager = new WinEventHookManager(_log); - _syncManager = new SyncManager(store, enumerator, hookManager, _log); + _syncManager = new SyncManager(_profileStore, enumerator, hookManager, _log); - _viewModel = new MainViewModel(store, enumerator, arranger, urlRetriever, _syncManager, appSettings, _log); + // Profile applier for hotkey access + _profileApplier = new ProfileApplier(_profileStore, enumerator, arranger, _syncManager, _log); + + _viewModel = new MainViewModel(_profileStore, enumerator, arranger, urlRetriever, _syncManager, _appSettingsStore, _log); _viewModel.Initialize(); // Start sync hooks if enabled @@ -87,6 +95,7 @@ protected override void OnStartup(StartupEventArgs e) // Create main window _mainWindow = new MainWindow(); _mainWindow.DataContext = _viewModel; + _mainWindow.SettingsRequested += (_, _) => ShowSettingsWindow(); // Apply WPF-UI theme (follow system dark/light) ApplicationThemeManager.Apply(ApplicationTheme.Dark, Wpf.Ui.Controls.WindowBackdropType.Mica); @@ -99,13 +108,15 @@ protected override void OnStartup(StartupEventArgs e) // Setup tray icon SetupTrayIcon(); - // Setup hotkey (Ctrl+Alt+W) + // Setup hotkey manager _hotkeyManager = new HotkeyManager(_log); - // We need an HWND for hotkey registration; use a helper window - _hotkeyManager.Register(() => ShowMainWindow()); + RegisterAllHotkeys(); + + // Create settings window (lazily shown) + CreateSettingsWindow(); // Show GUI if setting says so - if (store.Data.Settings.ShowGuiOnStartup != 0) + if (_profileStore.Data.Settings.ShowGuiOnStartup != 0) ShowMainWindow(); } catch (Exception ex) @@ -116,6 +127,73 @@ protected override void OnStartup(StartupEventArgs e) } } + private void CreateSettingsWindow() + { + if (_profileStore == null || _appSettingsStore == null || _hotkeyManager == null || _syncManager == null || _log == null) + return; + + _settingsViewModel = new SettingsViewModel( + _profileStore, + _appSettingsStore, + _hotkeyManager, + _syncManager, + _log, + refreshHotkeysCallback: RegisterAllHotkeys, + applyProfileCallback: async (profileId, launchMissing) => + { + if (_profileApplier != null) + await _profileApplier.ApplyByIdAsync(profileId, launchMissing); + }); + + _settingsWindow = new SettingsWindow(); + _settingsWindow.DataContext = _settingsViewModel; + } + + private void RegisterAllHotkeys() + { + if (_hotkeyManager == null || _appSettingsStore == null || _profileApplier == null) + return; + + // Unregister all profile hotkeys first + _hotkeyManager.UnregisterAllProfileHotkeys(); + + // Register GUI hotkey + var guiHotkey = _appSettingsStore.Data.Hotkeys.ShowGui; + if (!guiHotkey.IsEmpty) + { + _hotkeyManager.UpdateGuiHotkey(guiHotkey, () => ShowMainWindow()); + } + else + { + _hotkeyManager.UpdateGuiHotkey(new Core.Models.HotkeyBinding(), () => { }); + } + + // Register profile hotkeys + foreach (var (profileId, binding) in _appSettingsStore.Data.Hotkeys.Profiles) + { + if (binding.IsEmpty) continue; + + var capturedProfileId = profileId; + _hotkeyManager.RegisterProfileHotkey(profileId, binding, () => + { + // Apply profile on hotkey press (arrange only, no launch) + _ = Dispatcher.InvokeAsync(async () => + { + if (_profileApplier != null) + { + var result = await _profileApplier.ApplyByIdAsync(capturedProfileId, false); + var profile = _profileStore?.FindById(capturedProfileId); + var name = profile?.Name ?? capturedProfileId; + if (_viewModel != null) + { + _viewModel.StatusText = result.ToStatusMessage(name); + } + } + }); + }); + } + } + private void SetupTrayIcon() { _trayIcon = new TaskbarIcon @@ -130,7 +208,14 @@ private void SetupTrayIcon() menuOpen.Click += (_, _) => ShowMainWindow(); contextMenu.Items.Add(menuOpen); + var menuSettings = new System.Windows.Controls.MenuItem { Header = "設定…" }; + menuSettings.Click += (_, _) => ShowSettingsWindow(); + contextMenu.Items.Add(menuSettings); + + contextMenu.Items.Add(new System.Windows.Controls.Separator()); + var menuApply = new System.Windows.Controls.MenuItem { Header = "プロファイルを適用(配置のみ)" }; + // TODO: Add submenu for profile selection if needed menuApply.Click += (_, _) => ShowMainWindow(); contextMenu.Items.Add(menuApply); @@ -153,6 +238,18 @@ private void ShowMainWindow() _mainWindow.Activate(); } + private void ShowSettingsWindow() + { + if (_settingsWindow == null) return; + + // Refresh profile list in settings + _settingsViewModel?.RefreshProfiles(); + + _settingsWindow.Show(); + _settingsWindow.WindowState = WindowState.Normal; + _settingsWindow.Activate(); + } + private void ExitApp() { _log?.Information("Window-Controller exiting"); diff --git a/src/WindowController.App/HotkeyManager.cs b/src/WindowController.App/HotkeyManager.cs index 0667df6..e8e778b 100644 --- a/src/WindowController.App/HotkeyManager.cs +++ b/src/WindowController.App/HotkeyManager.cs @@ -1,33 +1,47 @@ -using System.Runtime.InteropServices; -using System.Windows; +using System.Windows.Input; using System.Windows.Interop; using Serilog; +using WindowController.Core.Models; using WindowController.Win32; namespace WindowController.App; /// -/// Manages global hotkey (Ctrl+Alt+W) registration. +/// Result of a hotkey registration attempt. +/// +public record HotkeyRegistrationResult(bool Success, string? ErrorMessage = null); + +/// +/// Manages multiple global hotkeys with dynamic registration/unregistration. /// public class HotkeyManager : IDisposable { - private const int HOTKEY_ID = 1; private const int WM_HOTKEY = 0x0312; + // Reserved hotkey IDs + private const int HOTKEY_ID_GUI = 1; + private const int HOTKEY_ID_PROFILE_BASE = 1000; + private readonly ILogger _log; private HwndSource? _hwndSource; - private Action? _callback; private bool _disposed; + // Currently registered hotkeys + private readonly Dictionary _callbacks = new(); + private readonly Dictionary _registeredBindings = new(); + + // Mapping from profile Id to hotkey Id + private readonly Dictionary _profileHotkeyIds = new(); + private int _nextProfileHotkeyId = HOTKEY_ID_PROFILE_BASE; + public HotkeyManager(ILogger log) { _log = log; + InitializeHwndSource(); } - public void Register(Action callback) + private void InitializeHwndSource() { - _callback = callback; - // Create a hidden window for hotkey messages var parameters = new HwndSourceParameters("WindowControllerHotkey") { @@ -37,27 +51,279 @@ public void Register(Action callback) }; _hwndSource = new HwndSource(parameters); _hwndSource.AddHook(WndProc); + } + + /// + /// Register the GUI toggle hotkey. + /// + public HotkeyRegistrationResult RegisterGuiHotkey(HotkeyBinding binding, Action callback) + { + return RegisterHotkey(HOTKEY_ID_GUI, binding, callback, "GUI"); + } + + /// + /// Update the GUI toggle hotkey. Unregisters the old one first. + /// + public HotkeyRegistrationResult UpdateGuiHotkey(HotkeyBinding binding, Action callback) + { + UnregisterHotkey(HOTKEY_ID_GUI); + if (binding.IsEmpty) + return new HotkeyRegistrationResult(true); + return RegisterHotkey(HOTKEY_ID_GUI, binding, callback, "GUI"); + } + + /// + /// Register a profile hotkey. + /// + public HotkeyRegistrationResult RegisterProfileHotkey(string profileId, HotkeyBinding binding, Action callback) + { + // Clean up any existing registration for this profile + if (_profileHotkeyIds.TryGetValue(profileId, out var existingId)) + { + UnregisterHotkey(existingId); + _profileHotkeyIds.Remove(profileId); + } + + if (binding.IsEmpty) + return new HotkeyRegistrationResult(true); + + var hotkeyId = _nextProfileHotkeyId++; + var result = RegisterHotkey(hotkeyId, binding, callback, $"Profile:{profileId}"); + if (result.Success) + { + _profileHotkeyIds[profileId] = hotkeyId; + } + return result; + } + + /// + /// Unregister a profile hotkey. + /// + public void UnregisterProfileHotkey(string profileId) + { + if (_profileHotkeyIds.TryGetValue(profileId, out var hotkeyId)) + { + UnregisterHotkey(hotkeyId); + _profileHotkeyIds.Remove(profileId); + } + } + + /// + /// Unregister all profile hotkeys. + /// + public void UnregisterAllProfileHotkeys() + { + foreach (var (_, hotkeyId) in _profileHotkeyIds) + { + UnregisterHotkey(hotkeyId); + } + _profileHotkeyIds.Clear(); + } + + /// + /// Test if a hotkey can be registered without actually keeping it registered. + /// Used to validate hotkey before saving to settings. + /// + public HotkeyRegistrationResult TestHotkey(HotkeyBinding binding) + { + if (binding.IsEmpty) + return new HotkeyRegistrationResult(true); + + if (_hwndSource == null) + return new HotkeyRegistrationResult(false, "HotkeyManager not initialized"); var hwnd = _hwndSource.Handle; - var result = NativeMethods.RegisterHotKey(hwnd, HOTKEY_ID, - NativeMethods.MOD_CONTROL | NativeMethods.MOD_ALT | NativeMethods.MOD_NOREPEAT, - NativeMethods.VK_W); + var modifiers = GetModifiers(binding); + var vkCode = GetVirtualKeyCode(binding.Key); + + if (vkCode == 0) + return new HotkeyRegistrationResult(false, $"無効なキー: {binding.Key}"); + + // Check for conflicts with already registered hotkeys + foreach (var (id, existingBinding) in _registeredBindings) + { + if (existingBinding.Equals(binding)) + { + return new HotkeyRegistrationResult(false, $"このホットキーは既に登録されています: {binding}"); + } + } + + // Try to register with a temporary ID + const int testId = 99999; + var result = NativeMethods.RegisterHotKey(hwnd, testId, modifiers | NativeMethods.MOD_NOREPEAT, (uint)vkCode); if (result) - _log.Information("Hotkey Ctrl+Alt+W registered"); + { + // Immediately unregister + NativeMethods.UnregisterHotKey(hwnd, testId); + return new HotkeyRegistrationResult(true); + } else - _log.Warning("Failed to register hotkey Ctrl+Alt+W"); + { + return new HotkeyRegistrationResult(false, $"ホットキー {binding} は他のアプリで使用中か、システムで予約されています"); + } + } + + private HotkeyRegistrationResult RegisterHotkey(int hotkeyId, HotkeyBinding binding, Action callback, string description) + { + if (_hwndSource == null) + return new HotkeyRegistrationResult(false, "HotkeyManager not initialized"); + + if (binding.IsEmpty) + return new HotkeyRegistrationResult(true); + + var hwnd = _hwndSource.Handle; + var modifiers = GetModifiers(binding); + var vkCode = GetVirtualKeyCode(binding.Key); + + if (vkCode == 0) + { + _log.Warning("Invalid key for hotkey {Description}: {Key}", description, binding.Key); + return new HotkeyRegistrationResult(false, $"無効なキー: {binding.Key}"); + } + + var result = NativeMethods.RegisterHotKey(hwnd, hotkeyId, modifiers | NativeMethods.MOD_NOREPEAT, (uint)vkCode); + if (result) + { + _callbacks[hotkeyId] = callback; + _registeredBindings[hotkeyId] = binding.Clone(); + _log.Information("Hotkey [{Description}] {Binding} registered", description, binding); + return new HotkeyRegistrationResult(true); + } + else + { + _log.Warning("Failed to register hotkey [{Description}] {Binding}", description, binding); + return new HotkeyRegistrationResult(false, $"ホットキー {binding} の登録に失敗しました(他のアプリで使用中の可能性)"); + } + } + + private void UnregisterHotkey(int hotkeyId) + { + if (_hwndSource == null) return; + + if (_callbacks.ContainsKey(hotkeyId)) + { + NativeMethods.UnregisterHotKey(_hwndSource.Handle, hotkeyId); + if (_registeredBindings.TryGetValue(hotkeyId, out var binding)) + { + _log.Information("Hotkey {Binding} unregistered", binding); + } + _callbacks.Remove(hotkeyId); + _registeredBindings.Remove(hotkeyId); + } } private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled) { - if (msg == WM_HOTKEY && wParam.ToInt32() == HOTKEY_ID) + if (msg == WM_HOTKEY) { - _callback?.Invoke(); - handled = true; + var hotkeyId = wParam.ToInt32(); + if (_callbacks.TryGetValue(hotkeyId, out var callback)) + { + callback(); + handled = true; + } } return 0; } + private static uint GetModifiers(HotkeyBinding binding) + { + uint mods = 0; + if (binding.Ctrl) mods |= NativeMethods.MOD_CONTROL; + if (binding.Alt) mods |= NativeMethods.MOD_ALT; + if (binding.Shift) mods |= NativeMethods.MOD_SHIFT; + if (binding.Win) mods |= NativeMethods.MOD_WIN; + return mods; + } + + /// + /// Convert a key string to a virtual key code. + /// + public static int GetVirtualKeyCode(string key) + { + if (string.IsNullOrEmpty(key)) return 0; + + // Try parsing as a Key enum first + if (Enum.TryParse(key, ignoreCase: true, out var wpfKey)) + { + return KeyInterop.VirtualKeyFromKey(wpfKey); + } + + // Handle single character keys + if (key.Length == 1) + { + var c = char.ToUpperInvariant(key[0]); + if (c >= 'A' && c <= 'Z') + return c; // VK_A to VK_Z are same as ASCII + if (c >= '0' && c <= '9') + return c; // VK_0 to VK_9 are same as ASCII + } + + // Handle function keys + if (key.StartsWith("F", StringComparison.OrdinalIgnoreCase) && + int.TryParse(key.AsSpan(1), out var fNum) && fNum >= 1 && fNum <= 24) + { + return 0x70 + fNum - 1; // VK_F1 = 0x70 + } + + // Handle special keys + return key.ToUpperInvariant() switch + { + "SPACE" => 0x20, + "TAB" => 0x09, + "ENTER" or "RETURN" => 0x0D, + "ESCAPE" or "ESC" => 0x1B, + "BACKSPACE" or "BACK" => 0x08, + "DELETE" or "DEL" => 0x2E, + "INSERT" or "INS" => 0x2D, + "HOME" => 0x24, + "END" => 0x23, + "PAGEUP" or "PGUP" => 0x21, + "PAGEDOWN" or "PGDN" => 0x22, + "UP" => 0x26, + "DOWN" => 0x28, + "LEFT" => 0x25, + "RIGHT" => 0x27, + "PRINTSCREEN" or "PRTSC" => 0x2C, + "PAUSE" => 0x13, + "NUMLOCK" => 0x90, + "SCROLLLOCK" => 0x91, + "CAPSLOCK" => 0x14, + _ => 0 + }; + } + + /// + /// Get the key string from a WPF Key value. + /// + public static string GetKeyString(Key key) + { + return key switch + { + Key.None => "", + >= Key.A and <= Key.Z => key.ToString(), + >= Key.D0 and <= Key.D9 => ((int)key - (int)Key.D0).ToString(), + >= Key.NumPad0 and <= Key.NumPad9 => "NumPad" + ((int)key - (int)Key.NumPad0), + >= Key.F1 and <= Key.F24 => key.ToString(), + Key.Space => "Space", + Key.Tab => "Tab", + Key.Enter => "Enter", + Key.Escape => "Escape", + Key.Back => "Backspace", + Key.Delete => "Delete", + Key.Insert => "Insert", + Key.Home => "Home", + Key.End => "End", + Key.PageUp => "PageUp", + Key.PageDown => "PageDown", + Key.Up => "Up", + Key.Down => "Down", + Key.Left => "Left", + Key.Right => "Right", + _ => key.ToString() + }; + } + public void Dispose() { if (_disposed) return; @@ -65,7 +331,15 @@ public void Dispose() if (_hwndSource != null) { - NativeMethods.UnregisterHotKey(_hwndSource.Handle, HOTKEY_ID); + // Unregister all hotkeys + foreach (var hotkeyId in _callbacks.Keys.ToList()) + { + NativeMethods.UnregisterHotKey(_hwndSource.Handle, hotkeyId); + } + _callbacks.Clear(); + _registeredBindings.Clear(); + _profileHotkeyIds.Clear(); + _hwndSource.RemoveHook(WndProc); _hwndSource.Dispose(); _hwndSource = null; diff --git a/src/WindowController.App/MainWindow.xaml b/src/WindowController.App/MainWindow.xaml index 0b70112..e1bf7c3 100644 --- a/src/WindowController.App/MainWindow.xaml +++ b/src/WindowController.App/MainWindow.xaml @@ -25,6 +25,18 @@ + + + + + + + + + + + + @@ -40,7 +52,7 @@ + > @@ -114,7 +126,7 @@ Text="{Binding ProfileName, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,12,0"/> + > @@ -187,7 +199,7 @@ + > @@ -196,7 +208,7 @@ + > @@ -204,8 +216,7 @@ - + @@ -217,81 +228,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/WindowController.App/MainWindow.xaml.cs b/src/WindowController.App/MainWindow.xaml.cs index 7229567..eeee88c 100644 --- a/src/WindowController.App/MainWindow.xaml.cs +++ b/src/WindowController.App/MainWindow.xaml.cs @@ -8,6 +8,11 @@ namespace WindowController.App; public partial class MainWindow : FluentWindow { + /// + /// Event raised when the settings button is clicked. + /// + public event EventHandler? SettingsRequested; + public MainWindow() { InitializeComponent(); @@ -20,6 +25,11 @@ private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs Hide(); } + private void SettingsButton_Click(object sender, RoutedEventArgs e) + { + SettingsRequested?.Invoke(this, EventArgs.Empty); + } + /// /// Prevent inner DataGrid scroll from bubbling to the outer ScrollViewer. /// diff --git a/src/WindowController.App/ProfileApplier.cs b/src/WindowController.App/ProfileApplier.cs new file mode 100644 index 0000000..0c8f4a9 --- /dev/null +++ b/src/WindowController.App/ProfileApplier.cs @@ -0,0 +1,188 @@ +using System.Diagnostics; +using System.IO; +using Serilog; +using WindowController.Browser; +using WindowController.Core; +using WindowController.Core.Models; +using WindowController.Win32; + +namespace WindowController.App; + +/// +/// Result of a profile apply operation. +/// +public record ApplyResult(int Applied, int Total, List Failures) +{ + public bool Success => Failures.Count == 0; + + public string ToStatusMessage(string profileName) + { + var msg = $"{profileName} を適用: {Applied}/{Total}"; + if (Failures.Count > 0) + msg += $"(失敗 {Failures.Count}件: {string.Join(", ", Failures.Take(3))})"; + return msg; + } +} + +/// +/// Encapsulates profile application logic so that both UI and hotkeys can invoke it. +/// +public class ProfileApplier +{ + private readonly ProfileStore _store; + private readonly WindowEnumerator _enumerator; + private readonly WindowArranger _arranger; + private readonly SyncManager _syncManager; + private readonly ILogger _log; + + public ProfileApplier(ProfileStore store, WindowEnumerator enumerator, + WindowArranger arranger, SyncManager syncManager, ILogger log) + { + _store = store; + _enumerator = enumerator; + _arranger = arranger; + _syncManager = syncManager; + _log = log; + } + + /// + /// Apply a profile by Id. + /// + /// The profile Id to apply. + /// If true, launch missing windows before applying. + /// Result with applied count and failures. + public async Task ApplyByIdAsync(string profileId, bool launchMissing) + { + var profile = _store.FindById(profileId); + if (profile == null) + { + return new ApplyResult(0, 0, new List { "プロファイルが見つかりません" }); + } + + return await ApplyProfileAsync(profile, launchMissing); + } + + /// + /// Apply a profile by name. + /// + public async Task ApplyByNameAsync(string profileName, bool launchMissing) + { + var profile = _store.FindByName(profileName); + if (profile == null) + { + return new ApplyResult(0, 0, new List { "プロファイルが見つかりません" }); + } + + return await ApplyProfileAsync(profile, launchMissing); + } + + private async Task ApplyProfileAsync(Profile profile, bool launchMissing) + { + var candidates = GetCandidates(); + int applied = 0; + var failures = new List(); + + foreach (var entry in profile.Windows) + { + try + { + var match = WindowMatcher.FindBest(entry, candidates); + nint hwnd = match?.Hwnd ?? 0; + + if ((hwnd == 0 || !NativeMethods.IsWindow(hwnd)) && launchMissing) + { + hwnd = await LaunchAndWaitAsync(entry, candidates); + } + + if (hwnd == 0 || !NativeMethods.IsWindow(hwnd)) + { + failures.Add($"{entry.Match.Exe} | {entry.Match.Title} : 見つかりません"); + continue; + } + + _arranger.Arrange(hwnd, entry); + applied++; + } + catch (Exception ex) + { + failures.Add($"{entry.Match.Exe} | {entry.Match.Title} : {ex.Message}"); + _log.Warning(ex, "ApplyProfile item failed"); + } + } + + _syncManager.ScheduleRebuild(); + return new ApplyResult(applied, profile.Windows.Count, failures); + } + + private async Task LaunchAndWaitAsync(WindowEntry entry, List existingCandidates) + { + var exe = entry.Match.Exe; + var url = entry.Match.Url; + var path = entry.Path; + + // Collect existing hwnds for this exe + var beforeHwnds = new HashSet( + existingCandidates.Where(c => c.Exe.Equals(exe, StringComparison.OrdinalIgnoreCase)).Select(c => c.Hwnd)); + + try + { + var startPath = !string.IsNullOrEmpty(path) && File.Exists(path) ? path : exe; + + var psi = new ProcessStartInfo(startPath); + if (!string.IsNullOrEmpty(url)) + { + // Only allow http/https/file URLs as arguments + if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + psi.Arguments = $"\"{url}\""; + } + else + { + _log.Warning("Launch skipped URL argument with unsupported scheme: {Url}", url); + } + } + psi.UseShellExecute = true; + Process.Start(psi); + } + catch (Exception ex) + { + _log.Warning(ex, "Launch failed for {Exe}", exe); + return 0; + } + + // Wait for new window + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < 12000) + { + await Task.Delay(300); + var wins = _enumerator.EnumerateWindows(); + foreach (var w in wins) + { + if (w.Exe.Equals(exe, StringComparison.OrdinalIgnoreCase) && !beforeHwnds.Contains(w.Hwnd)) + return w.Hwnd; + } + } + + // Last resort: try matching again + var newCandidates = GetCandidates(); + var match = WindowMatcher.FindBest(entry, newCandidates); + return match?.Hwnd ?? 0; + } + + private List GetCandidates() + { + var wins = _enumerator.EnumerateWindows(); + return wins.Select(w => new WindowCandidate + { + Hwnd = w.Hwnd, + Exe = w.Exe, + Class = w.Class, + Title = w.Title, + Path = w.Path, + Url = w.Url, + CommandLine = w.CommandLine + }).ToList(); + } +} diff --git a/src/WindowController.App/SettingsWindow.xaml b/src/WindowController.App/SettingsWindow.xaml new file mode 100644 index 0000000..eb44b9c --- /dev/null +++ b/src/WindowController.App/SettingsWindow.xaml @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WindowController.App/SettingsWindow.xaml.cs b/src/WindowController.App/SettingsWindow.xaml.cs new file mode 100644 index 0000000..fdd929a --- /dev/null +++ b/src/WindowController.App/SettingsWindow.xaml.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using System.Windows.Input; +using WindowController.App.ViewModels; +using Wpf.Ui.Controls; + +namespace WindowController.App; + +public partial class SettingsWindow : FluentWindow +{ + public SettingsWindow() + { + InitializeComponent(); + } + + private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e) + { + // Hide instead of close (tray resident app) + e.Cancel = true; + Hide(); + } + + private void Window_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (DataContext is SettingsViewModel vm) + { + if (vm.IsCapturingGuiHotkey) + { + vm.OnGuiHotkeyKeyDown(e); + } + else if (vm.IsCapturingProfileHotkey) + { + vm.OnProfileHotkeyKeyDown(e); + } + } + } +} + +/// +/// Converter to display "設定…" or "入力待ち…" based on capturing state. +/// +public class BoolToCapturingTextConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is true ? "入力待ち…" : "設定…"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/src/WindowController.App/ViewModels/MainViewModel.cs b/src/WindowController.App/ViewModels/MainViewModel.cs index cea74d8..948efe9 100644 --- a/src/WindowController.App/ViewModels/MainViewModel.cs +++ b/src/WindowController.App/ViewModels/MainViewModel.cs @@ -43,16 +43,12 @@ public partial class MainViewModel : ObservableObject private readonly WindowArranger _arranger; private readonly BrowserUrlRetriever _urlRetriever; private readonly SyncManager _syncManager; - private readonly AppSettingsStore _appSettings; private readonly ILogger _log; private bool _isUpdatingProfileName; [ObservableProperty] private string _statusText = ""; [ObservableProperty] private string _profileName = ""; - [ObservableProperty] private bool _syncEnabled; - [ObservableProperty] private bool _showGuiOnStartup; [ObservableProperty] private ProfileItem? _selectedProfile; - [ObservableProperty] private string _profilesPathDisplay = ""; public ObservableCollection Windows { get; } = new(); public ObservableCollection Profiles { get; } = new(); @@ -66,12 +62,7 @@ public MainViewModel(ProfileStore store, WindowEnumerator enumerator, _arranger = arranger; _urlRetriever = urlRetriever; _syncManager = syncManager; - _appSettings = appSettings; _log = log; - - SyncEnabled = _store.Data.Settings.SyncMinMax != 0; - ShowGuiOnStartup = _store.Data.Settings.ShowGuiOnStartup != 0; - ProfilesPathDisplay = _store.FilePath; } [RelayCommand] @@ -520,96 +511,10 @@ private void OnProfileSyncChanged(ProfileItem pi) } } - partial void OnSyncEnabledChanged(bool value) - { - _store.Data.Settings.SyncMinMax = value ? 1 : 0; - _store.Save(); - _syncManager.UpdateHooksIfNeeded(); - StatusText = $"連動設定: {(value ? "ON" : "OFF")}"; - } - - partial void OnShowGuiOnStartupChanged(bool value) - { - _store.Data.Settings.ShowGuiOnStartup = value ? 1 : 0; - _store.Save(); - StatusText = $"起動時GUI表示: {(value ? "ON" : "OFF")}"; - } - public void Initialize() { ReloadProfiles(); // Kick off async refresh (fire-and-forget on UI thread) _ = RefreshWindowsCommand.ExecuteAsync(null); } - - [RelayCommand] - private void BrowseProfilesPath() - { - var dlg = new OpenFileDialog - { - Title = "profiles.json の保存先を選択", - Filter = "JSONファイル|profiles.json|All files|*.*", - FileName = "profiles.json", - CheckFileExists = false, - }; - - // Set initial directory from current path - var currentDir = Path.GetDirectoryName(_store.FilePath); - if (!string.IsNullOrEmpty(currentDir) && Directory.Exists(currentDir)) - dlg.InitialDirectory = currentDir; - - if (dlg.ShowDialog() != true) - return; - - var selectedPath = dlg.FileName; - - // Ensure the filename is profiles.json - if (!Path.GetFileName(selectedPath).Equals("profiles.json", StringComparison.OrdinalIgnoreCase)) - selectedPath = Path.Combine(Path.GetDirectoryName(selectedPath) ?? selectedPath, "profiles.json"); - - try - { - _appSettings.SetProfilesPath(selectedPath); - _store.ChangePath(selectedPath); - ProfilesPathDisplay = _store.FilePath; - - SyncEnabled = _store.Data.Settings.SyncMinMax != 0; - ShowGuiOnStartup = _store.Data.Settings.ShowGuiOnStartup != 0; - - ReloadProfiles(); - _syncManager.ScheduleRebuild(); - StatusText = $"保存先を変更しました: {selectedPath}"; - _log.Information("Profiles path changed to {Path}", selectedPath); - } - catch (Exception ex) - { - _log.Error(ex, "BrowseProfilesPath failed"); - StatusText = $"保存先の変更に失敗: {ex.Message}"; - } - } - - [RelayCommand] - private void ResetProfilesPath() - { - try - { - _appSettings.SetProfilesPath(""); - var defaultPath = _appSettings.EffectiveProfilesPath; - _store.ChangePath(defaultPath); - ProfilesPathDisplay = _store.FilePath; - - SyncEnabled = _store.Data.Settings.SyncMinMax != 0; - ShowGuiOnStartup = _store.Data.Settings.ShowGuiOnStartup != 0; - - ReloadProfiles(); - _syncManager.ScheduleRebuild(); - StatusText = $"保存先を既定に戻しました: {defaultPath}"; - _log.Information("Profiles path reset to default: {Path}", defaultPath); - } - catch (Exception ex) - { - _log.Error(ex, "ResetProfilesPath failed"); - StatusText = $"既定へのリセットに失敗: {ex.Message}"; - } - } } diff --git a/src/WindowController.App/ViewModels/SettingsViewModel.cs b/src/WindowController.App/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..d44488f --- /dev/null +++ b/src/WindowController.App/ViewModels/SettingsViewModel.cs @@ -0,0 +1,465 @@ +using System.Collections.ObjectModel; +using System.IO; +using System.Windows; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Win32; +using Serilog; +using WindowController.Core; +using WindowController.Core.Models; + +namespace WindowController.App.ViewModels; + +/// +/// Represents a hotkey item for a profile in the settings UI. +/// +public partial class ProfileHotkeyItem : ObservableObject +{ + [ObservableProperty] private string _profileId = ""; + [ObservableProperty] private string _profileName = ""; + [ObservableProperty] private string _hotkeyDisplay = "なし"; + [ObservableProperty] private HotkeyBinding _binding = new(); + + public void UpdateDisplay() + { + HotkeyDisplay = Binding.IsEmpty ? "なし" : Binding.ToString(); + } +} + +/// +/// ViewModel for the SettingsWindow. +/// Manages both profiles.json settings (via ProfileStore) and appsettings.json (via AppSettingsStore). +/// +public partial class SettingsViewModel : ObservableObject +{ + private readonly ProfileStore _profileStore; + private readonly AppSettingsStore _appSettingsStore; + private readonly HotkeyManager _hotkeyManager; + private readonly SyncManager _syncManager; + private readonly ILogger _log; + + // Callback to refresh hotkeys after settings change + private readonly Action? _refreshHotkeysCallback; + private readonly Func? _applyProfileCallback; + + // ========== Existing settings from profiles.json ========== + [ObservableProperty] private bool _syncEnabled; + [ObservableProperty] private bool _showGuiOnStartup; + + // ========== Settings from appsettings.json ========== + [ObservableProperty] private string _profilesPathDisplay = ""; + + // ========== Hotkey settings ========== + [ObservableProperty] private string _guiHotkeyDisplay = ""; + [ObservableProperty] private HotkeyBinding _guiHotkeyBinding = new(); + [ObservableProperty] private bool _isCapturingGuiHotkey; + + public ObservableCollection ProfileHotkeys { get; } = new(); + + [ObservableProperty] private ProfileHotkeyItem? _selectedProfileHotkey; + [ObservableProperty] private bool _isCapturingProfileHotkey; + + [ObservableProperty] private string _statusText = ""; + + public SettingsViewModel( + ProfileStore profileStore, + AppSettingsStore appSettingsStore, + HotkeyManager hotkeyManager, + SyncManager syncManager, + ILogger log, + Action? refreshHotkeysCallback = null, + Func? applyProfileCallback = null) + { + _profileStore = profileStore; + _appSettingsStore = appSettingsStore; + _hotkeyManager = hotkeyManager; + _syncManager = syncManager; + _log = log; + _refreshHotkeysCallback = refreshHotkeysCallback; + _applyProfileCallback = applyProfileCallback; + + LoadSettings(); + } + + private void LoadSettings() + { + // Load from profiles.json + SyncEnabled = _profileStore.Data.Settings.SyncMinMax != 0; + ShowGuiOnStartup = _profileStore.Data.Settings.ShowGuiOnStartup != 0; + + // Load from appsettings.json + ProfilesPathDisplay = _profileStore.FilePath; + + // Load hotkey settings + GuiHotkeyBinding = _appSettingsStore.Data.Hotkeys.ShowGui.Clone(); + UpdateGuiHotkeyDisplay(); + + // Load profile hotkeys + LoadProfileHotkeys(); + } + + // ========== Reset-to-default (per section) ========== + + [RelayCommand] + private void ResetGeneralSettings() + { + // Defaults from ProfileStore.CreateDefault(): SyncMinMax=0, ShowGuiOnStartup=1 + SyncEnabled = false; + ShowGuiOnStartup = true; + StatusText = "全般設定を既定に戻しました"; + } + + [RelayCommand] + private void ResetGuiHotkeyToDefault() + { + // Default GUI hotkey: Ctrl+Alt+W + var defaultBinding = new HotkeyBinding { Key = "W", Ctrl = true, Alt = true }; + + // Strict flow: test -> save only if success + var testResult = _hotkeyManager.TestHotkey(defaultBinding); + if (!testResult.Success) + { + StatusText = testResult.ErrorMessage ?? "既定ホットキーの登録に失敗しました"; + return; + } + + GuiHotkeyBinding = defaultBinding; + UpdateGuiHotkeyDisplay(); + _appSettingsStore.Data.Hotkeys.ShowGui = defaultBinding.Clone(); + _appSettingsStore.Save(); + _refreshHotkeysCallback?.Invoke(); + StatusText = $"GUI表示ホットキーを既定に戻しました: {defaultBinding}"; + } + + [RelayCommand] + private void ResetAllProfileHotkeys() + { + // Default for profile hotkeys: no binds + _appSettingsStore.Data.Hotkeys.Profiles.Clear(); + _appSettingsStore.Save(); + + foreach (var item in ProfileHotkeys) + { + item.Binding = new HotkeyBinding(); + item.UpdateDisplay(); + } + + _refreshHotkeysCallback?.Invoke(); + StatusText = "プロファイル適用ホットキーを既定に戻しました"; + } + + private void LoadProfileHotkeys() + { + ProfileHotkeys.Clear(); + foreach (var profile in _profileStore.Data.Profiles) + { + var item = new ProfileHotkeyItem + { + ProfileId = profile.Id, + ProfileName = profile.Name, + Binding = _appSettingsStore.Data.Hotkeys.Profiles.TryGetValue(profile.Id, out var binding) + ? binding.Clone() + : new HotkeyBinding() + }; + item.UpdateDisplay(); + ProfileHotkeys.Add(item); + } + } + + private void UpdateGuiHotkeyDisplay() + { + GuiHotkeyDisplay = GuiHotkeyBinding.IsEmpty ? "なし" : GuiHotkeyBinding.ToString(); + } + + // ========== Settings changes ========== + + partial void OnSyncEnabledChanged(bool value) + { + _profileStore.Data.Settings.SyncMinMax = value ? 1 : 0; + _profileStore.Save(); + _syncManager.UpdateHooksIfNeeded(); + StatusText = $"連動設定: {(value ? "ON" : "OFF")}"; + } + + partial void OnShowGuiOnStartupChanged(bool value) + { + _profileStore.Data.Settings.ShowGuiOnStartup = value ? 1 : 0; + _profileStore.Save(); + StatusText = $"起動時GUI表示: {(value ? "ON" : "OFF")}"; + } + + // ========== Profiles path commands ========== + + [RelayCommand] + private void BrowseProfilesPath() + { + var dlg = new OpenFileDialog + { + Title = "profiles.json の保存先を選択", + Filter = "JSONファイル|profiles.json|All files|*.*", + FileName = "profiles.json", + CheckFileExists = false, + }; + + var currentDir = Path.GetDirectoryName(_profileStore.FilePath); + if (!string.IsNullOrEmpty(currentDir) && Directory.Exists(currentDir)) + dlg.InitialDirectory = currentDir; + + if (dlg.ShowDialog() != true) + return; + + var selectedPath = dlg.FileName; + if (!Path.GetFileName(selectedPath).Equals("profiles.json", StringComparison.OrdinalIgnoreCase)) + selectedPath = Path.Combine(Path.GetDirectoryName(selectedPath) ?? selectedPath, "profiles.json"); + + try + { + _appSettingsStore.SetProfilesPath(selectedPath); + _profileStore.ChangePath(selectedPath); + ProfilesPathDisplay = _profileStore.FilePath; + + // Reload settings from new file + SyncEnabled = _profileStore.Data.Settings.SyncMinMax != 0; + ShowGuiOnStartup = _profileStore.Data.Settings.ShowGuiOnStartup != 0; + + // Reload profile hotkeys + LoadProfileHotkeys(); + + _syncManager.ScheduleRebuild(); + StatusText = $"保存先を変更しました: {selectedPath}"; + _log.Information("Profiles path changed to {Path}", selectedPath); + } + catch (Exception ex) + { + _log.Error(ex, "BrowseProfilesPath failed"); + StatusText = $"保存先の変更に失敗: {ex.Message}"; + } + } + + [RelayCommand] + private void ResetProfilesPath() + { + try + { + _appSettingsStore.SetProfilesPath(""); + var defaultPath = _appSettingsStore.EffectiveProfilesPath; + _profileStore.ChangePath(defaultPath); + ProfilesPathDisplay = _profileStore.FilePath; + + SyncEnabled = _profileStore.Data.Settings.SyncMinMax != 0; + ShowGuiOnStartup = _profileStore.Data.Settings.ShowGuiOnStartup != 0; + + LoadProfileHotkeys(); + + _syncManager.ScheduleRebuild(); + StatusText = $"保存先を既定に戻しました: {defaultPath}"; + _log.Information("Profiles path reset to default: {Path}", defaultPath); + } + catch (Exception ex) + { + _log.Error(ex, "ResetProfilesPath failed"); + StatusText = $"既定へのリセットに失敗: {ex.Message}"; + } + } + + // ========== GUI Hotkey ========== + + [RelayCommand] + private void CaptureGuiHotkey() + { + IsCapturingGuiHotkey = true; + StatusText = "キーを押してください… (Escでキャンセル)"; + } + + public void OnGuiHotkeyKeyDown(KeyEventArgs e) + { + if (!IsCapturingGuiHotkey) return; + + e.Handled = true; + + // Cancel on Escape + if (e.Key == Key.Escape) + { + IsCapturingGuiHotkey = false; + StatusText = "キャンセルしました"; + return; + } + + // Ignore modifier-only presses + if (IsModifierKey(e.Key)) + return; + + var binding = CreateBindingFromKeyEvent(e); + if (binding.IsEmpty) + { + StatusText = "無効なキーです"; + return; + } + + // Test registration before saving + var testResult = _hotkeyManager.TestHotkey(binding); + if (!testResult.Success) + { + StatusText = testResult.ErrorMessage ?? "ホットキーの登録に失敗しました"; + IsCapturingGuiHotkey = false; + return; + } + + // Save to settings + GuiHotkeyBinding = binding; + UpdateGuiHotkeyDisplay(); + _appSettingsStore.Data.Hotkeys.ShowGui = binding.Clone(); + _appSettingsStore.Save(); + + // Re-register hotkeys + _refreshHotkeysCallback?.Invoke(); + + IsCapturingGuiHotkey = false; + StatusText = $"GUI表示ホットキーを設定しました: {binding}"; + } + + [RelayCommand] + private void ClearGuiHotkey() + { + GuiHotkeyBinding = new HotkeyBinding(); + UpdateGuiHotkeyDisplay(); + _appSettingsStore.Data.Hotkeys.ShowGui = new HotkeyBinding(); + _appSettingsStore.Save(); + _refreshHotkeysCallback?.Invoke(); + StatusText = "GUI表示ホットキーを無効化しました"; + } + + // ========== Profile Hotkey ========== + + [RelayCommand] + private void CaptureProfileHotkey() + { + if (SelectedProfileHotkey == null) + { + StatusText = "プロファイルを選択してください"; + return; + } + IsCapturingProfileHotkey = true; + StatusText = $"[{SelectedProfileHotkey.ProfileName}] キーを押してください… (Escでキャンセル)"; + } + + public void OnProfileHotkeyKeyDown(KeyEventArgs e) + { + if (!IsCapturingProfileHotkey || SelectedProfileHotkey == null) return; + + e.Handled = true; + + if (e.Key == Key.Escape) + { + IsCapturingProfileHotkey = false; + StatusText = "キャンセルしました"; + return; + } + + if (IsModifierKey(e.Key)) + return; + + var binding = CreateBindingFromKeyEvent(e); + if (binding.IsEmpty) + { + StatusText = "無効なキーです"; + return; + } + + // Check for conflicts with GUI hotkey + if (GuiHotkeyBinding.Equals(binding)) + { + StatusText = "GUI表示ホットキーと重複しています"; + IsCapturingProfileHotkey = false; + return; + } + + // Check for conflicts with other profile hotkeys + foreach (var item in ProfileHotkeys) + { + if (item.ProfileId != SelectedProfileHotkey.ProfileId && item.Binding.Equals(binding)) + { + StatusText = $"プロファイル「{item.ProfileName}」のホットキーと重複しています"; + IsCapturingProfileHotkey = false; + return; + } + } + + // Test registration + var testResult = _hotkeyManager.TestHotkey(binding); + if (!testResult.Success) + { + StatusText = testResult.ErrorMessage ?? "ホットキーの登録に失敗しました"; + IsCapturingProfileHotkey = false; + return; + } + + // Save to settings + SelectedProfileHotkey.Binding = binding; + SelectedProfileHotkey.UpdateDisplay(); + _appSettingsStore.Data.Hotkeys.Profiles[SelectedProfileHotkey.ProfileId] = binding.Clone(); + _appSettingsStore.Save(); + + _refreshHotkeysCallback?.Invoke(); + + IsCapturingProfileHotkey = false; + StatusText = $"[{SelectedProfileHotkey.ProfileName}] ホットキーを設定しました: {binding}"; + } + + [RelayCommand] + private void ClearProfileHotkey() + { + if (SelectedProfileHotkey == null) + { + StatusText = "プロファイルを選択してください"; + return; + } + + SelectedProfileHotkey.Binding = new HotkeyBinding(); + SelectedProfileHotkey.UpdateDisplay(); + _appSettingsStore.Data.Hotkeys.Profiles.Remove(SelectedProfileHotkey.ProfileId); + _appSettingsStore.Save(); + _refreshHotkeysCallback?.Invoke(); + StatusText = $"[{SelectedProfileHotkey.ProfileName}] ホットキーを無効化しました"; + } + + // ========== Helpers ========== + + private static bool IsModifierKey(Key key) + { + return key == Key.LeftCtrl || key == Key.RightCtrl || + key == Key.LeftAlt || key == Key.RightAlt || + key == Key.LeftShift || key == Key.RightShift || + key == Key.LWin || key == Key.RWin || + key == Key.System; // Alt key generates System key + } + + private static HotkeyBinding CreateBindingFromKeyEvent(KeyEventArgs e) + { + var key = e.Key == Key.System ? e.SystemKey : e.Key; + if (IsModifierKey(key)) + return new HotkeyBinding(); + + var keyString = HotkeyManager.GetKeyString(key); + if (string.IsNullOrEmpty(keyString)) + return new HotkeyBinding(); + + return new HotkeyBinding + { + Key = keyString, + Ctrl = (Keyboard.Modifiers & ModifierKeys.Control) != 0, + Alt = (Keyboard.Modifiers & ModifierKeys.Alt) != 0, + Shift = (Keyboard.Modifiers & ModifierKeys.Shift) != 0, + Win = (Keyboard.Modifiers & ModifierKeys.Windows) != 0 + }; + } + + /// + /// Refresh the profile list (e.g., after profiles are added/removed in main window). + /// + public void RefreshProfiles() + { + LoadProfileHotkeys(); + } +} diff --git a/src/WindowController.Core.Tests/AppSettingsTests.cs b/src/WindowController.Core.Tests/AppSettingsTests.cs new file mode 100644 index 0000000..2e08403 --- /dev/null +++ b/src/WindowController.Core.Tests/AppSettingsTests.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using WindowController.Core.Models; + +namespace WindowController.Core.Tests; + +public class AppSettingsTests +{ + [Fact] + public void AppSettings_DefaultValues_AreCorrect() + { + var settings = new AppSettings(); + + Assert.Equal("", settings.ProfilesPath); + Assert.NotNull(settings.Hotkeys); + } + + [Fact] + public void HotkeySettings_DefaultShowGui_IsCtrlAltW() + { + var hotkeySettings = new HotkeySettings(); + + Assert.Equal("W", hotkeySettings.ShowGui.Key); + Assert.True(hotkeySettings.ShowGui.Ctrl); + Assert.True(hotkeySettings.ShowGui.Alt); + Assert.False(hotkeySettings.ShowGui.Shift); + Assert.False(hotkeySettings.ShowGui.Win); + } + + [Fact] + public void HotkeyBinding_IsEmpty_WhenNoKeySet() + { + var binding = new HotkeyBinding(); + Assert.True(binding.IsEmpty); + + var bindingWithKey = new HotkeyBinding { Key = "A" }; + Assert.False(bindingWithKey.IsEmpty); + } + + [Fact] + public void HotkeyBinding_ToString_FormatsCorrectly() + { + var binding = new HotkeyBinding(); + Assert.Equal("なし", binding.ToString()); + + binding = new HotkeyBinding { Key = "W", Ctrl = true, Alt = true }; + Assert.Equal("Ctrl+Alt+W", binding.ToString()); + + binding = new HotkeyBinding { Key = "F1", Shift = true, Win = true }; + Assert.Equal("Shift+Win+F1", binding.ToString()); + } + + [Fact] + public void HotkeyBinding_Clone_CreatesIndependentCopy() + { + var original = new HotkeyBinding { Key = "W", Ctrl = true, Alt = true }; + var clone = original.Clone(); + + Assert.Equal(original.Key, clone.Key); + Assert.Equal(original.Ctrl, clone.Ctrl); + Assert.Equal(original.Alt, clone.Alt); + + // Modify clone and verify original unchanged + clone.Key = "X"; + Assert.Equal("W", original.Key); + } + + [Fact] + public void HotkeyBinding_Equals_ComparesCorrectly() + { + var a = new HotkeyBinding { Key = "W", Ctrl = true, Alt = true }; + var b = new HotkeyBinding { Key = "W", Ctrl = true, Alt = true }; + var c = new HotkeyBinding { Key = "X", Ctrl = true, Alt = true }; + var d = new HotkeyBinding { Key = "W", Ctrl = true, Shift = true }; + + Assert.True(a.Equals(b)); + Assert.False(a.Equals(c)); + Assert.False(a.Equals(d)); + Assert.False(a.Equals(null)); + } + + [Fact] + public void AppSettings_DeserializesWithMissingHotkeys_UsesDefaults() + { + // Simulate old appsettings.json without hotkeys field + var json = """{ "profilesPath": "/some/path" }"""; + var settings = JsonSerializer.Deserialize(json); + + Assert.NotNull(settings); + Assert.Equal("/some/path", settings!.ProfilesPath); + Assert.NotNull(settings.Hotkeys); + // Default hotkey should be applied + Assert.Equal("W", settings.Hotkeys.ShowGui.Key); + } + + [Fact] + public void AppSettings_DeserializesWithEmptyHotkeys_Succeeds() + { + var json = """{ "profilesPath": "", "hotkeys": {} }"""; + var settings = JsonSerializer.Deserialize(json); + + Assert.NotNull(settings); + Assert.NotNull(settings!.Hotkeys); + } + + [Fact] + public void AppSettings_SerializesAndDeserializesCorrectly() + { + var original = new AppSettings + { + ProfilesPath = "/custom/path/profiles.json", + Hotkeys = new HotkeySettings + { + ShowGui = new HotkeyBinding { Key = "G", Ctrl = true, Shift = true }, + Profiles = new Dictionary + { + ["profile-1"] = new HotkeyBinding { Key = "1", Ctrl = true, Alt = true }, + ["profile-2"] = new HotkeyBinding { Key = "2", Ctrl = true, Alt = true } + } + } + }; + + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.NotNull(deserialized); + Assert.Equal(original.ProfilesPath, deserialized!.ProfilesPath); + Assert.Equal("G", deserialized.Hotkeys.ShowGui.Key); + Assert.True(deserialized.Hotkeys.ShowGui.Ctrl); + Assert.True(deserialized.Hotkeys.ShowGui.Shift); + Assert.Equal(2, deserialized.Hotkeys.Profiles.Count); + Assert.True(deserialized.Hotkeys.Profiles.ContainsKey("profile-1")); + Assert.Equal("1", deserialized.Hotkeys.Profiles["profile-1"].Key); + } + + [Fact] + public void HotkeySettings_ProfilesMap_DefaultsToEmpty() + { + var hotkeySettings = new HotkeySettings(); + + Assert.NotNull(hotkeySettings.Profiles); + Assert.Empty(hotkeySettings.Profiles); + } +} diff --git a/src/WindowController.Core/Models/AppSettings.cs b/src/WindowController.Core/Models/AppSettings.cs index 7170506..d1fbdd4 100644 --- a/src/WindowController.Core/Models/AppSettings.cs +++ b/src/WindowController.Core/Models/AppSettings.cs @@ -16,4 +16,10 @@ public class AppSettings /// [JsonPropertyName("profilesPath")] public string ProfilesPath { get; set; } = ""; + + /// + /// Hotkey configurations (GUI toggle + per-profile apply). + /// + [JsonPropertyName("hotkeys")] + public HotkeySettings Hotkeys { get; set; } = new(); } diff --git a/src/WindowController.Core/Models/HotkeySettings.cs b/src/WindowController.Core/Models/HotkeySettings.cs new file mode 100644 index 0000000..d2be735 --- /dev/null +++ b/src/WindowController.Core/Models/HotkeySettings.cs @@ -0,0 +1,100 @@ +using System.Text.Json.Serialization; + +namespace WindowController.Core.Models; + +/// +/// Hotkey binding configuration for a profile or the GUI toggle. +/// +public class HotkeyBinding +{ + /// + /// Key code (e.g., "W", "F1", "1", etc.). + /// Empty string means not bound. + /// + [JsonPropertyName("key")] + public string Key { get; set; } = ""; + + /// + /// Modifier flags (Ctrl, Alt, Shift, Win). + /// + [JsonPropertyName("ctrl")] + public bool Ctrl { get; set; } + + [JsonPropertyName("alt")] + public bool Alt { get; set; } + + [JsonPropertyName("shift")] + public bool Shift { get; set; } + + [JsonPropertyName("win")] + public bool Win { get; set; } + + /// + /// Returns true if the binding is empty (no key assigned). + /// + [JsonIgnore] + public bool IsEmpty => string.IsNullOrEmpty(Key); + + /// + /// Returns a human-readable representation of the hotkey. + /// + public override string ToString() + { + if (IsEmpty) return "なし"; + var parts = new List(); + if (Ctrl) parts.Add("Ctrl"); + if (Alt) parts.Add("Alt"); + if (Shift) parts.Add("Shift"); + if (Win) parts.Add("Win"); + parts.Add(Key); + return string.Join("+", parts); + } + + /// + /// Creates a copy of this binding. + /// + public HotkeyBinding Clone() => new() + { + Key = Key, + Ctrl = Ctrl, + Alt = Alt, + Shift = Shift, + Win = Win + }; + + /// + /// Returns true if this binding equals another (same key and modifiers). + /// + public bool Equals(HotkeyBinding? other) + { + if (other is null) return false; + return Key.Equals(other.Key, StringComparison.OrdinalIgnoreCase) && + Ctrl == other.Ctrl && + Alt == other.Alt && + Shift == other.Shift && + Win == other.Win; + } + + public override bool Equals(object? obj) => obj is HotkeyBinding other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(Key?.ToUpperInvariant(), Ctrl, Alt, Shift, Win); +} + +/// +/// All hotkey-related settings for the application. +/// +public class HotkeySettings +{ + /// + /// Hotkey to toggle the main GUI. Default: Ctrl+Alt+W (for backward compat). + /// + [JsonPropertyName("showGui")] + public HotkeyBinding ShowGui { get; set; } = new() { Key = "W", Ctrl = true, Alt = true }; + + /// + /// Per-profile hotkeys. Key is profile Id, value is the binding. + /// Empty bindings (or missing keys) mean no hotkey for that profile. + /// + [JsonPropertyName("profiles")] + public Dictionary Profiles { get; set; } = new(); +} diff --git a/src/WindowController.Win32/NativeMethods.cs b/src/WindowController.Win32/NativeMethods.cs index 9f2c679..69d77e3 100644 --- a/src/WindowController.Win32/NativeMethods.cs +++ b/src/WindowController.Win32/NativeMethods.cs @@ -124,8 +124,10 @@ public static extern bool QueryFullProcessImageNameW(nint hProcess, uint dwFlags public const uint WINEVENT_SKIPOWNPROCESS = 0x0002; // Hotkey modifiers - public const uint MOD_CONTROL = 0x0002; public const uint MOD_ALT = 0x0001; + public const uint MOD_CONTROL = 0x0002; + public const uint MOD_SHIFT = 0x0004; + public const uint MOD_WIN = 0x0008; public const uint MOD_NOREPEAT = 0x4000; public const uint VK_W = 0x57; From 94848b74f57ec35e8a15d0f1075a46a3b6e9ceed Mon Sep 17 00:00:00 2001 From: hu-ja-ja Date: Sat, 14 Feb 2026 22:25:55 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HotkeyManagerLogicTests.cs | 56 +++++++ .../ProfileApplierTests.cs | 143 ++++++++++++++++ .../WindowController.App.Tests.csproj | 25 +++ src/WindowController.App/App.xaml.cs | 9 +- src/WindowController.App/HotkeyManager.cs | 154 ++++++++++-------- src/WindowController.App/ProfileApplier.cs | 27 ++- .../ViewModels/SettingsViewModel.cs | 5 +- .../Models/HotkeySettings.cs | 10 +- src/WindowController.sln | 14 ++ 9 files changed, 353 insertions(+), 90 deletions(-) create mode 100644 src/WindowController.App.Tests/HotkeyManagerLogicTests.cs create mode 100644 src/WindowController.App.Tests/ProfileApplierTests.cs create mode 100644 src/WindowController.App.Tests/WindowController.App.Tests.csproj diff --git a/src/WindowController.App.Tests/HotkeyManagerLogicTests.cs b/src/WindowController.App.Tests/HotkeyManagerLogicTests.cs new file mode 100644 index 0000000..12b4687 --- /dev/null +++ b/src/WindowController.App.Tests/HotkeyManagerLogicTests.cs @@ -0,0 +1,56 @@ +using System.Windows.Input; +using WindowController.App; +using Xunit; + +namespace WindowController.App.Tests; + +public class HotkeyManagerLogicTests +{ + [Theory] + [InlineData(Key.A, "A")] + [InlineData(Key.Z, "Z")] + [InlineData(Key.D0, "0")] + [InlineData(Key.D9, "9")] + [InlineData(Key.NumPad0, "NumPad0")] + [InlineData(Key.NumPad7, "NumPad7")] + [InlineData(Key.F13, "F13")] + [InlineData(Key.F24, "F24")] + [InlineData(Key.PageUp, "PageUp")] + [InlineData(Key.PageDown, "PageDown")] + [InlineData(Key.Escape, "Escape")] + public void GetKeyString_ReturnsExpected(Key key, string expected) + { + Assert.Equal(expected, HotkeyManager.GetKeyString(key)); + } + + [Theory] + [InlineData("ESC", 0x1B)] + [InlineData("Escape", 0x1B)] + [InlineData("PGUP", 0x21)] + [InlineData("PageDown", 0x22)] + [InlineData("DEL", 0x2E)] + [InlineData("Insert", 0x2D)] + [InlineData("PrtSc", 0x2C)] + [InlineData("Space", 0x20)] + public void GetVirtualKeyCode_SpecialStrings_ReturnExpected(string key, int expectedVk) + { + Assert.Equal(expectedVk, HotkeyManager.GetVirtualKeyCode(key)); + } + + [Theory] + [InlineData("", 0)] + [InlineData("[", 0)] + [InlineData("-", 0)] + public void GetVirtualKeyCode_UnsupportedCharacters_ReturnZero(string key, int expectedVk) + { + Assert.Equal(expectedVk, HotkeyManager.GetVirtualKeyCode(key)); + } + + [Theory] + [InlineData("F13", 0x7C)] + [InlineData("F24", 0x87)] + public void GetVirtualKeyCode_FunctionKeys_ReturnExpected(string key, int expectedVk) + { + Assert.Equal(expectedVk, HotkeyManager.GetVirtualKeyCode(key)); + } +} diff --git a/src/WindowController.App.Tests/ProfileApplierTests.cs b/src/WindowController.App.Tests/ProfileApplierTests.cs new file mode 100644 index 0000000..200e0ab --- /dev/null +++ b/src/WindowController.App.Tests/ProfileApplierTests.cs @@ -0,0 +1,143 @@ +using System.IO; +using Serilog; +using WindowController.App; +using WindowController.Core; +using WindowController.Core.Models; +using WindowController.Win32; +using Xunit; + +namespace WindowController.App.Tests; + +public class ProfileApplierTests +{ + [Fact] + public async Task ApplyByIdAsync_ProfileMissing_ReturnsFailure_AndDoesNotScheduleRebuild() + { + var log = Serilog.Core.Logger.None; + var storePath = CreateTempProfilesJson(new ProfilesRoot { Profiles = new() }); + var store = new ProfileStore(storePath, log); + store.Load(); + + var scheduled = 0; + var applier = CreateApplier(store, log, scheduleRebuild: () => scheduled++); + + var result = await applier.ApplyByIdAsync("missing", launchMissing: false); + + Assert.Equal(0, result.Applied); + Assert.Equal(0, result.Total); + Assert.False(result.Success); + Assert.Contains("プロファイルが見つかりません", result.Failures); + Assert.Equal(0, scheduled); + } + + [Fact] + public async Task ApplyByNameAsync_ProfileMissing_ReturnsFailure_AndDoesNotScheduleRebuild() + { + var log = Serilog.Core.Logger.None; + var storePath = CreateTempProfilesJson(new ProfilesRoot { Profiles = new() }); + var store = new ProfileStore(storePath, log); + store.Load(); + + var scheduled = 0; + var applier = CreateApplier(store, log, scheduleRebuild: () => scheduled++); + + var result = await applier.ApplyByNameAsync("missing", launchMissing: false); + + Assert.Equal(0, result.Applied); + Assert.Equal(0, result.Total); + Assert.False(result.Success); + Assert.Contains("プロファイルが見つかりません", result.Failures); + Assert.Equal(0, scheduled); + } + + [Fact] + public async Task ApplyByIdAsync_InvalidWindowHandle_ReportsFailure_AndSchedulesRebuild() + { + var log = Serilog.Core.Logger.None; + + var root = new ProfilesRoot + { + Settings = new Settings { SyncMinMax = 0, ShowGuiOnStartup = 1 }, + Profiles = new() + { + new Profile + { + Id = "p1", + Name = "Profile1", + SyncMinMax = 0, + Windows = new() + { + new WindowEntry + { + Match = new MatchInfo + { + Exe = "notepad.exe", + Class = "", + Title = "Untitled - Notepad", + Url = "", + UrlKey = "" + }, + Path = "", + Rect = new Rect { X = 0, Y = 0, W = 100, H = 100 }, + MinMax = 0 + } + } + } + } + }; + + var storePath = CreateTempProfilesJson(root); + var store = new ProfileStore(storePath, log); + store.Load(); + + var scheduled = 0; + var applier = CreateApplier( + store, + log, + scheduleRebuild: () => scheduled++, + candidatesProvider: () => new List()); + + var result = await applier.ApplyByIdAsync("p1", launchMissing: false); + + Assert.Equal(0, result.Applied); + Assert.Equal(1, result.Total); + Assert.False(result.Success); + Assert.Single(result.Failures); + Assert.Contains("見つかりません", result.Failures[0]); + Assert.Equal(1, scheduled); + } + + private static ProfileApplier CreateApplier( + ProfileStore store, + ILogger log, + Action scheduleRebuild, + Func>? candidatesProvider = null) + { + var enumerator = new WindowEnumerator(log); + var arranger = new WindowArranger(log); + return new ProfileApplier( + store, + enumerator, + arranger, + scheduleRebuild, + log, + candidatesProvider); + } + + private static string CreateTempProfilesJson(ProfilesRoot root) + { + var dir = Path.Combine(Path.GetTempPath(), "WindowController.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "profiles.json"); + + var json = System.Text.Json.JsonSerializer.Serialize(root, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = null + }); + + File.WriteAllText(path, json); + return path; + } +} diff --git a/src/WindowController.App.Tests/WindowController.App.Tests.csproj b/src/WindowController.App.Tests/WindowController.App.Tests.csproj new file mode 100644 index 0000000..8cb3e9b --- /dev/null +++ b/src/WindowController.App.Tests/WindowController.App.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0-windows + enable + enable + false + true + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/WindowController.App/App.xaml.cs b/src/WindowController.App/App.xaml.cs index b57b554..186c1d1 100644 --- a/src/WindowController.App/App.xaml.cs +++ b/src/WindowController.App/App.xaml.cs @@ -84,7 +84,7 @@ protected override void OnStartup(StartupEventArgs e) _syncManager = new SyncManager(_profileStore, enumerator, hookManager, _log); // Profile applier for hotkey access - _profileApplier = new ProfileApplier(_profileStore, enumerator, arranger, _syncManager, _log); + _profileApplier = new ProfileApplier(_profileStore, enumerator, arranger, () => _syncManager.ScheduleRebuild(), _log); _viewModel = new MainViewModel(_profileStore, enumerator, arranger, urlRetriever, _syncManager, _appSettingsStore, _log); _viewModel.Initialize(); @@ -138,12 +138,7 @@ private void CreateSettingsWindow() _hotkeyManager, _syncManager, _log, - refreshHotkeysCallback: RegisterAllHotkeys, - applyProfileCallback: async (profileId, launchMissing) => - { - if (_profileApplier != null) - await _profileApplier.ApplyByIdAsync(profileId, launchMissing); - }); + refreshHotkeysCallback: RegisterAllHotkeys); _settingsWindow = new SettingsWindow(); _settingsWindow.DataContext = _settingsViewModel; diff --git a/src/WindowController.App/HotkeyManager.cs b/src/WindowController.App/HotkeyManager.cs index e8e778b..0a60bcc 100644 --- a/src/WindowController.App/HotkeyManager.cs +++ b/src/WindowController.App/HotkeyManager.cs @@ -26,6 +26,9 @@ public class HotkeyManager : IDisposable private HwndSource? _hwndSource; private bool _disposed; + private readonly object _gate = new(); + private int _nextTestHotkeyId = int.MaxValue; + // Currently registered hotkeys private readonly Dictionary _callbacks = new(); private readonly Dictionary _registeredBindings = new(); @@ -123,92 +126,104 @@ public void UnregisterAllProfileHotkeys() /// /// Test if a hotkey can be registered without actually keeping it registered. /// Used to validate hotkey before saving to settings. + /// Note: This method does not reserve the hotkey. Another thread/process may register it + /// after this check and before the actual registration. /// public HotkeyRegistrationResult TestHotkey(HotkeyBinding binding) { if (binding.IsEmpty) return new HotkeyRegistrationResult(true); - if (_hwndSource == null) - return new HotkeyRegistrationResult(false, "HotkeyManager not initialized"); + lock (_gate) + { + if (_hwndSource == null) + return new HotkeyRegistrationResult(false, "HotkeyManager not initialized"); - var hwnd = _hwndSource.Handle; - var modifiers = GetModifiers(binding); - var vkCode = GetVirtualKeyCode(binding.Key); + var hwnd = _hwndSource.Handle; + var modifiers = GetModifiers(binding); + var vkCode = GetVirtualKeyCode(binding.Key); - if (vkCode == 0) - return new HotkeyRegistrationResult(false, $"無効なキー: {binding.Key}"); + if (vkCode == 0) + return new HotkeyRegistrationResult(false, $"無効なキー: {binding.Key}"); - // Check for conflicts with already registered hotkeys - foreach (var (id, existingBinding) in _registeredBindings) - { - if (existingBinding.Equals(binding)) + // Check for conflicts with already registered hotkeys + foreach (var (_, existingBinding) in _registeredBindings) { - return new HotkeyRegistrationResult(false, $"このホットキーは既に登録されています: {binding}"); + if (existingBinding.Equals(binding)) + { + return new HotkeyRegistrationResult(false, $"このホットキーは既に登録されています: {binding}"); + } } - } - // Try to register with a temporary ID - const int testId = 99999; - var result = NativeMethods.RegisterHotKey(hwnd, testId, modifiers | NativeMethods.MOD_NOREPEAT, (uint)vkCode); - if (result) - { - // Immediately unregister - NativeMethods.UnregisterHotKey(hwnd, testId); - return new HotkeyRegistrationResult(true); - } - else - { - return new HotkeyRegistrationResult(false, $"ホットキー {binding} は他のアプリで使用中か、システムで予約されています"); + // Try to register with a temporary ID that will not collide with real hotkey IDs. + // Use unique IDs to avoid cross-thread interference. + var testId = unchecked(_nextTestHotkeyId--); + var result = NativeMethods.RegisterHotKey(hwnd, testId, modifiers | NativeMethods.MOD_NOREPEAT, (uint)vkCode); + if (result) + { + // Immediately unregister + NativeMethods.UnregisterHotKey(hwnd, testId); + return new HotkeyRegistrationResult(true); + } + else + { + return new HotkeyRegistrationResult(false, $"ホットキー {binding} は他のアプリで使用中か、システムで予約されています"); + } } } private HotkeyRegistrationResult RegisterHotkey(int hotkeyId, HotkeyBinding binding, Action callback, string description) { - if (_hwndSource == null) - return new HotkeyRegistrationResult(false, "HotkeyManager not initialized"); + lock (_gate) + { + if (_hwndSource == null) + return new HotkeyRegistrationResult(false, "HotkeyManager not initialized"); - if (binding.IsEmpty) - return new HotkeyRegistrationResult(true); + if (binding.IsEmpty) + return new HotkeyRegistrationResult(true); - var hwnd = _hwndSource.Handle; - var modifiers = GetModifiers(binding); - var vkCode = GetVirtualKeyCode(binding.Key); + var hwnd = _hwndSource.Handle; + var modifiers = GetModifiers(binding); + var vkCode = GetVirtualKeyCode(binding.Key); - if (vkCode == 0) - { - _log.Warning("Invalid key for hotkey {Description}: {Key}", description, binding.Key); - return new HotkeyRegistrationResult(false, $"無効なキー: {binding.Key}"); - } + if (vkCode == 0) + { + _log.Warning("Invalid key for hotkey {Description}: {Key}", description, binding.Key); + return new HotkeyRegistrationResult(false, $"無効なキー: {binding.Key}"); + } - var result = NativeMethods.RegisterHotKey(hwnd, hotkeyId, modifiers | NativeMethods.MOD_NOREPEAT, (uint)vkCode); - if (result) - { - _callbacks[hotkeyId] = callback; - _registeredBindings[hotkeyId] = binding.Clone(); - _log.Information("Hotkey [{Description}] {Binding} registered", description, binding); - return new HotkeyRegistrationResult(true); - } - else - { - _log.Warning("Failed to register hotkey [{Description}] {Binding}", description, binding); - return new HotkeyRegistrationResult(false, $"ホットキー {binding} の登録に失敗しました(他のアプリで使用中の可能性)"); + var result = NativeMethods.RegisterHotKey(hwnd, hotkeyId, modifiers | NativeMethods.MOD_NOREPEAT, (uint)vkCode); + if (result) + { + _callbacks[hotkeyId] = callback; + _registeredBindings[hotkeyId] = binding.Clone(); + _log.Information("Hotkey [{Description}] {Binding} registered", description, binding); + return new HotkeyRegistrationResult(true); + } + else + { + _log.Warning("Failed to register hotkey [{Description}] {Binding}", description, binding); + return new HotkeyRegistrationResult(false, $"ホットキー {binding} の登録に失敗しました(他のアプリで使用中の可能性)"); + } } } private void UnregisterHotkey(int hotkeyId) { - if (_hwndSource == null) return; - - if (_callbacks.ContainsKey(hotkeyId)) + lock (_gate) { - NativeMethods.UnregisterHotKey(_hwndSource.Handle, hotkeyId); - if (_registeredBindings.TryGetValue(hotkeyId, out var binding)) + if (_hwndSource == null) return; + + if (_callbacks.ContainsKey(hotkeyId)) { - _log.Information("Hotkey {Binding} unregistered", binding); + NativeMethods.UnregisterHotKey(_hwndSource.Handle, hotkeyId); + if (_registeredBindings.TryGetValue(hotkeyId, out var binding)) + { + _log.Information("Hotkey {Binding} unregistered", binding); + } + _callbacks.Remove(hotkeyId); + _registeredBindings.Remove(hotkeyId); } - _callbacks.Remove(hotkeyId); - _registeredBindings.Remove(hotkeyId); } } @@ -329,20 +344,23 @@ public void Dispose() if (_disposed) return; _disposed = true; - if (_hwndSource != null) + lock (_gate) { - // Unregister all hotkeys - foreach (var hotkeyId in _callbacks.Keys.ToList()) + if (_hwndSource != null) { - NativeMethods.UnregisterHotKey(_hwndSource.Handle, hotkeyId); + // Unregister all hotkeys + foreach (var hotkeyId in _callbacks.Keys.ToList()) + { + NativeMethods.UnregisterHotKey(_hwndSource.Handle, hotkeyId); + } + _callbacks.Clear(); + _registeredBindings.Clear(); + _profileHotkeyIds.Clear(); + + _hwndSource.RemoveHook(WndProc); + _hwndSource.Dispose(); + _hwndSource = null; } - _callbacks.Clear(); - _registeredBindings.Clear(); - _profileHotkeyIds.Clear(); - - _hwndSource.RemoveHook(WndProc); - _hwndSource.Dispose(); - _hwndSource = null; } } } diff --git a/src/WindowController.App/ProfileApplier.cs b/src/WindowController.App/ProfileApplier.cs index 0c8f4a9..1a747fb 100644 --- a/src/WindowController.App/ProfileApplier.cs +++ b/src/WindowController.App/ProfileApplier.cs @@ -11,7 +11,7 @@ namespace WindowController.App; /// /// Result of a profile apply operation. /// -public record ApplyResult(int Applied, int Total, List Failures) +public record ApplyResult(int Applied, int Total, IReadOnlyList Failures) { public bool Success => Failures.Count == 0; @@ -32,17 +32,26 @@ public class ProfileApplier private readonly ProfileStore _store; private readonly WindowEnumerator _enumerator; private readonly WindowArranger _arranger; - private readonly SyncManager _syncManager; + private readonly Action _scheduleRebuild; private readonly ILogger _log; - public ProfileApplier(ProfileStore store, WindowEnumerator enumerator, - WindowArranger arranger, SyncManager syncManager, ILogger log) + private readonly Func> _candidatesProvider; + + public ProfileApplier( + ProfileStore store, + WindowEnumerator enumerator, + WindowArranger arranger, + Action scheduleRebuild, + ILogger log, + Func>? candidatesProvider = null) { _store = store; _enumerator = enumerator; _arranger = arranger; - _syncManager = syncManager; + _scheduleRebuild = scheduleRebuild; _log = log; + + _candidatesProvider = candidatesProvider ?? GetCandidatesFromEnumerator; } /// @@ -78,7 +87,7 @@ public async Task ApplyByNameAsync(string profileName, bool launchM private async Task ApplyProfileAsync(Profile profile, bool launchMissing) { - var candidates = GetCandidates(); + var candidates = _candidatesProvider(); int applied = 0; var failures = new List(); @@ -110,7 +119,7 @@ private async Task ApplyProfileAsync(Profile profile, bool launchMi } } - _syncManager.ScheduleRebuild(); + _scheduleRebuild(); return new ApplyResult(applied, profile.Windows.Count, failures); } @@ -166,12 +175,12 @@ private async Task LaunchAndWaitAsync(WindowEntry entry, List GetCandidates() + private List GetCandidatesFromEnumerator() { var wins = _enumerator.EnumerateWindows(); return wins.Select(w => new WindowCandidate diff --git a/src/WindowController.App/ViewModels/SettingsViewModel.cs b/src/WindowController.App/ViewModels/SettingsViewModel.cs index d44488f..4978dee 100644 --- a/src/WindowController.App/ViewModels/SettingsViewModel.cs +++ b/src/WindowController.App/ViewModels/SettingsViewModel.cs @@ -41,7 +41,6 @@ public partial class SettingsViewModel : ObservableObject // Callback to refresh hotkeys after settings change private readonly Action? _refreshHotkeysCallback; - private readonly Func? _applyProfileCallback; // ========== Existing settings from profiles.json ========== [ObservableProperty] private bool _syncEnabled; @@ -68,8 +67,7 @@ public SettingsViewModel( HotkeyManager hotkeyManager, SyncManager syncManager, ILogger log, - Action? refreshHotkeysCallback = null, - Func? applyProfileCallback = null) + Action? refreshHotkeysCallback = null) { _profileStore = profileStore; _appSettingsStore = appSettingsStore; @@ -77,7 +75,6 @@ public SettingsViewModel( _syncManager = syncManager; _log = log; _refreshHotkeysCallback = refreshHotkeysCallback; - _applyProfileCallback = applyProfileCallback; LoadSettings(); } diff --git a/src/WindowController.Core/Models/HotkeySettings.cs b/src/WindowController.Core/Models/HotkeySettings.cs index d2be735..9a9eaf4 100644 --- a/src/WindowController.Core/Models/HotkeySettings.cs +++ b/src/WindowController.Core/Models/HotkeySettings.cs @@ -7,12 +7,18 @@ namespace WindowController.Core.Models; /// public class HotkeyBinding { + private string _key = ""; + /// /// Key code (e.g., "W", "F1", "1", etc.). /// Empty string means not bound. /// [JsonPropertyName("key")] - public string Key { get; set; } = ""; + public string Key + { + get => _key; + set => _key = value ?? ""; + } /// /// Modifier flags (Ctrl, Alt, Shift, Win). @@ -77,7 +83,7 @@ public bool Equals(HotkeyBinding? other) public override bool Equals(object? obj) => obj is HotkeyBinding other && Equals(other); - public override int GetHashCode() => HashCode.Combine(Key?.ToUpperInvariant(), Ctrl, Alt, Shift, Win); + public override int GetHashCode() => HashCode.Combine(Key.ToUpperInvariant(), Ctrl, Alt, Shift, Win); } /// diff --git a/src/WindowController.sln b/src/WindowController.sln index d1a0ab4..c3fcf5b 100644 --- a/src/WindowController.sln +++ b/src/WindowController.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowController.App", "Win EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowController.Core.Tests", "WindowController.Core.Tests\WindowController.Core.Tests.csproj", "{89485D59-7247-423B-8360-37C26780B5DD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowController.App.Tests", "WindowController.App.Tests\WindowController.App.Tests.csproj", "{D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -83,6 +85,18 @@ Global {89485D59-7247-423B-8360-37C26780B5DD}.Release|x64.Build.0 = Release|Any CPU {89485D59-7247-423B-8360-37C26780B5DD}.Release|x86.ActiveCfg = Release|Any CPU {89485D59-7247-423B-8360-37C26780B5DD}.Release|x86.Build.0 = Release|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Debug|x64.ActiveCfg = Debug|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Debug|x64.Build.0 = Debug|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Debug|x86.ActiveCfg = Debug|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Debug|x86.Build.0 = Debug|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Release|Any CPU.Build.0 = Release|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Release|x64.ActiveCfg = Release|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Release|x64.Build.0 = Release|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Release|x86.ActiveCfg = Release|Any CPU + {D44D03B6-3E4B-4F8E-9DA0-7EEB24B7F4AF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE