From e620a790acf2ec9e42c074f265c85bee5e42fe6a Mon Sep 17 00:00:00 2001 From: hu-ja-ja Date: Thu, 12 Feb 2026 01:05:30 +0900 Subject: [PATCH 1/4] =?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 7c6f10d0289e72774de1385713a165867c81b609 Mon Sep 17 00:00:00 2001 From: hu-ja-ja Date: Sat, 14 Feb 2026 19:01:33 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E7=89=A9=E7=90=86=E3=83=A2=E3=83=8B?= =?UTF-8?q?=E3=82=BF=E3=83=BC=E3=82=92=E9=81=B8=E3=82=93=E3=81=A7=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/WindowController.App/App.xaml.cs | 28 +- .../DesktopPickerWindow.xaml | 68 ++++ .../DesktopPickerWindow.xaml.cs | 86 +++++ src/WindowController.App/MainWindow.xaml | 9 + .../MonitorOverlayWindow.xaml | 24 ++ .../MonitorOverlayWindow.xaml.cs | 41 +++ .../MonitorPickerWindow.xaml | 59 +++ .../MonitorPickerWindow.xaml.cs | 104 ++++++ .../MonitorWarningDialog.xaml | 79 ++++ .../MonitorWarningDialog.xaml.cs | 29 ++ src/WindowController.App/ProfileApplier.cs | 64 +++- .../ViewModels/MainViewModel.cs | 306 ++++++++++----- .../MonitorTransformDecisionTests.cs | 184 ++++++++++ .../NormalizedRectTests.cs | 201 ++++++++++ .../Models/MonitorInfo.cs | 27 ++ .../Models/NormalizedRect.cs | 54 +++ src/WindowController.Core/Models/Profile.cs | 8 + src/WindowController.Core/Models/Settings.cs | 30 ++ .../Models/WindowEntry.cs | 15 + .../MonitorTransformDecision.cs | 122 ++++++ src/WindowController.Win32/MonitorHelper.cs | 56 ++- src/WindowController.Win32/NativeMethods.cs | 6 + .../VirtualDesktopMoveHelper.cs | 347 ++++++++++++++++++ .../VirtualDesktopService.cs | 212 +++++++++++ src/WindowController.Win32/WindowArranger.cs | 209 +++++++++-- 25 files changed, 2206 insertions(+), 162 deletions(-) create mode 100644 src/WindowController.App/DesktopPickerWindow.xaml create mode 100644 src/WindowController.App/DesktopPickerWindow.xaml.cs create mode 100644 src/WindowController.App/MonitorOverlayWindow.xaml create mode 100644 src/WindowController.App/MonitorOverlayWindow.xaml.cs create mode 100644 src/WindowController.App/MonitorPickerWindow.xaml create mode 100644 src/WindowController.App/MonitorPickerWindow.xaml.cs create mode 100644 src/WindowController.App/MonitorWarningDialog.xaml create mode 100644 src/WindowController.App/MonitorWarningDialog.xaml.cs create mode 100644 src/WindowController.Core.Tests/MonitorTransformDecisionTests.cs create mode 100644 src/WindowController.Core.Tests/NormalizedRectTests.cs create mode 100644 src/WindowController.Core/Models/NormalizedRect.cs create mode 100644 src/WindowController.Core/MonitorTransformDecision.cs create mode 100644 src/WindowController.Win32/VirtualDesktopMoveHelper.cs create mode 100644 src/WindowController.Win32/VirtualDesktopService.cs diff --git a/src/WindowController.App/App.xaml.cs b/src/WindowController.App/App.xaml.cs index b57b554..e860b68 100644 --- a/src/WindowController.App/App.xaml.cs +++ b/src/WindowController.App/App.xaml.cs @@ -26,6 +26,7 @@ public partial class App : Application private ProfileApplier? _profileApplier; private ProfileStore? _profileStore; private AppSettingsStore? _appSettingsStore; + private VirtualDesktopService? _vdService; private ILogger? _log; protected override void OnStartup(StartupEventArgs e) @@ -79,14 +80,15 @@ protected override void OnStartup(StartupEventArgs e) var urlRetriever = new BrowserUrlRetriever(_log); var enumerator = new WindowEnumerator(_log, (hwnd, exe) => urlRetriever.TryGetUrl(hwnd, exe)); - var arranger = new WindowArranger(_log); + var arranger = new WindowArranger(_log, _profileStore.Data.Settings); var hookManager = new WinEventHookManager(_log); _syncManager = new SyncManager(_profileStore, enumerator, hookManager, _log); + _vdService = new VirtualDesktopService(_log); // Profile applier for hotkey access - _profileApplier = new ProfileApplier(_profileStore, enumerator, arranger, _syncManager, _log); + _profileApplier = new ProfileApplier(_profileStore, enumerator, arranger, _syncManager, _vdService, _log); - _viewModel = new MainViewModel(_profileStore, enumerator, arranger, urlRetriever, _syncManager, _appSettingsStore, _log); + _viewModel = new MainViewModel(_profileStore, enumerator, arranger, urlRetriever, _syncManager, _vdService, _profileApplier, _appSettingsStore, _log); _viewModel.Initialize(); // Start sync hooks if enabled @@ -142,7 +144,15 @@ private void CreateSettingsWindow() applyProfileCallback: async (profileId, launchMissing) => { if (_profileApplier != null) - await _profileApplier.ApplyByIdAsync(profileId, launchMissing); + { + nint appHwnd = 0; + if (_mainWindow != null) + { + var helper = new WindowInteropHelper(_mainWindow); + appHwnd = helper.Handle; + } + await _profileApplier.ApplyByIdAsync(profileId, launchMissing, appHwnd); + } }); _settingsWindow = new SettingsWindow(); @@ -181,7 +191,13 @@ private void RegisterAllHotkeys() { if (_profileApplier != null) { - var result = await _profileApplier.ApplyByIdAsync(capturedProfileId, false); + nint appHwnd = 0; + if (_mainWindow != null) + { + var helper = new WindowInteropHelper(_mainWindow); + appHwnd = helper.Handle; + } + var result = await _profileApplier.ApplyByIdAsync(capturedProfileId, false, appHwnd); var profile = _profileStore?.FindById(capturedProfileId); var name = profile?.Name ?? capturedProfileId; if (_viewModel != null) @@ -255,6 +271,7 @@ private void ExitApp() _log?.Information("Window-Controller exiting"); _hotkeyManager?.Dispose(); _syncManager?.Dispose(); + _vdService?.Dispose(); if (_trayIcon != null) { _trayIcon.Dispose(); @@ -268,6 +285,7 @@ protected override void OnExit(ExitEventArgs e) { _hotkeyManager?.Dispose(); _syncManager?.Dispose(); + _vdService?.Dispose(); _trayIcon?.Dispose(); Log.CloseAndFlush(); _singleInstanceMutex?.ReleaseMutex(); diff --git a/src/WindowController.App/DesktopPickerWindow.xaml b/src/WindowController.App/DesktopPickerWindow.xaml new file mode 100644 index 0000000..f316b55 --- /dev/null +++ b/src/WindowController.App/DesktopPickerWindow.xaml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WindowController.App/DesktopPickerWindow.xaml.cs b/src/WindowController.App/DesktopPickerWindow.xaml.cs new file mode 100644 index 0000000..0906d01 --- /dev/null +++ b/src/WindowController.App/DesktopPickerWindow.xaml.cs @@ -0,0 +1,86 @@ +using System.Windows; +using System.Windows.Input; +using WindowController.Win32; +using Wpf.Ui.Controls; + +namespace WindowController.App; + +/// +/// Item shown in the desktop picker list. +/// +public class DesktopPickerItem +{ + public int Number { get; init; } + public Guid DesktopId { get; init; } + public string NumberLabel => $"{Number}:"; + public string DisplayName { get; init; } = ""; + public string CurrentBadge { get; init; } = ""; +} + +/// +/// Modal dialog that lists available virtual desktops and lets the user pick one. +/// Supports keyboard shortcuts (1–9, NumPad, Escape). +/// +public partial class DesktopPickerWindow : FluentWindow +{ + private readonly List _items; + public Guid? SelectedDesktopId { get; private set; } + + public DesktopPickerWindow( + List desktops, + Guid? currentDesktopId, + string monitorDescription) + { + InitializeComponent(); + MonitorInfoText.Text = monitorDescription; + + _items = desktops.Select(d => new DesktopPickerItem + { + Number = d.Number, + DesktopId = d.Id, + DisplayName = string.IsNullOrEmpty(d.Name) + ? $"デスクトップ {d.Number}" + : d.Name, + CurrentBadge = d.Id == currentDesktopId ? "(現在)" : "" + }).ToList(); + DesktopList.ItemsSource = _items; + } + + private void DesktopButton_Click(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement fe && fe.DataContext is DesktopPickerItem item) + { + SelectedDesktopId = item.DesktopId; + DialogResult = true; + } + } + + private void Cancel_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + } + + protected override void OnPreviewKeyDown(KeyEventArgs e) + { + base.OnPreviewKeyDown(e); + + int num = -1; + if (e.Key >= Key.D1 && e.Key <= Key.D9) + num = e.Key - Key.D0; + else if (e.Key >= Key.NumPad1 && e.Key <= Key.NumPad9) + num = e.Key - Key.NumPad0; + else if (e.Key == Key.Escape) + { + DialogResult = false; + e.Handled = true; + return; + } + + if (num >= 1 && num <= _items.Count) + { + SelectedDesktopId = _items[num - 1].DesktopId; + DialogResult = true; + e.Handled = true; + } + } +} diff --git a/src/WindowController.App/MainWindow.xaml b/src/WindowController.App/MainWindow.xaml index e1bf7c3..f6deaa2 100644 --- a/src/WindowController.App/MainWindow.xaml +++ b/src/WindowController.App/MainWindow.xaml @@ -171,6 +171,14 @@ CellStyle="{StaticResource TransparentCell}" PreviewMouseWheel="DataGrid_PreviewMouseWheel" Margin="0,0,0,10"> + + + + + + @@ -192,6 +200,7 @@ + diff --git a/src/WindowController.App/MonitorOverlayWindow.xaml b/src/WindowController.App/MonitorOverlayWindow.xaml new file mode 100644 index 0000000..37c0198 --- /dev/null +++ b/src/WindowController.App/MonitorOverlayWindow.xaml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/WindowController.App/MonitorOverlayWindow.xaml.cs b/src/WindowController.App/MonitorOverlayWindow.xaml.cs new file mode 100644 index 0000000..325af11 --- /dev/null +++ b/src/WindowController.App/MonitorOverlayWindow.xaml.cs @@ -0,0 +1,41 @@ +using System.Windows; +using System.Windows.Interop; +using WindowController.Win32; + +namespace WindowController.App; + +/// +/// Semi-transparent overlay window that shows a large monitor number. +/// One instance is placed on each physical monitor while the monitor picker is open. +/// +public partial class MonitorOverlayWindow : Window +{ + private readonly int _monX, _monY, _monW, _monH; + + public MonitorOverlayWindow(int number, string info, + int monX, int monY, int monW, int monH) + { + InitializeComponent(); + NumberText.Text = number.ToString(); + InfoText.Text = info; + _monX = monX; + _monY = monY; + _monW = monW; + _monH = monH; + SourceInitialized += OnSourceInitialized; + } + + /// + /// Position the overlay centered on the target monitor using physical pixel coordinates. + /// Done in SourceInitialized to avoid flicker (before the window is first rendered). + /// + private void OnSourceInitialized(object? sender, EventArgs e) + { + var hwnd = new WindowInteropHelper(this).Handle; + const int ow = 400, oh = 320; + int cx = _monX + (_monW - ow) / 2; + int cy = _monY + (_monH - oh) / 2; + NativeMethods.SetWindowPos(hwnd, 0, cx, cy, ow, oh, + NativeMethods.SWP_NOACTIVATE | NativeMethods.SWP_NOZORDER); + } +} diff --git a/src/WindowController.App/MonitorPickerWindow.xaml b/src/WindowController.App/MonitorPickerWindow.xaml new file mode 100644 index 0000000..2809fb5 --- /dev/null +++ b/src/WindowController.App/MonitorPickerWindow.xaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WindowController.App/MonitorPickerWindow.xaml.cs b/src/WindowController.App/MonitorPickerWindow.xaml.cs new file mode 100644 index 0000000..aea7cfc --- /dev/null +++ b/src/WindowController.App/MonitorPickerWindow.xaml.cs @@ -0,0 +1,104 @@ +using System.Windows; +using System.Windows.Input; +using WindowController.Win32; +using Wpf.Ui.Controls; + +namespace WindowController.App; + +/// +/// Item shown in the monitor picker list. +/// +public class MonitorPickerItem +{ + public int Number { get; init; } + public MonitorData MonitorData { get; init; } = null!; + public string Label { get; init; } = ""; +} + +/// +/// Modal dialog that lists available monitors and lets the user pick one. +/// Supports keyboard shortcuts (1–9, NumPad, Escape). +/// Uses FluentWindow (Mica + TitleBar) to match the rest of the app. +/// +public partial class MonitorPickerWindow : FluentWindow +{ + private readonly List _items; + public MonitorData? SelectedMonitor { get; private set; } + + public MonitorPickerWindow(List monitors) + { + InitializeComponent(); + + // Start offscreen to avoid flicker; reposition in Loaded + Left = -10000; + Top = -10000; + + _items = monitors.Select((m, i) => new MonitorPickerItem + { + Number = i + 1, + MonitorData = m, + Label = FormatLabel(i + 1, m) + }).ToList(); + MonitorList.ItemsSource = _items; + + Loaded += OnLoaded; + } + + /// + /// Position the dialog at the bottom-center of the primary work area + /// so it does not overlap with the numbered overlay windows that appear + /// at the center of each monitor. + /// + private void OnLoaded(object sender, RoutedEventArgs e) + { + var wa = SystemParameters.WorkArea; + Left = wa.Left + (wa.Width - ActualWidth) / 2; + Top = wa.Bottom - ActualHeight - 40; + } + + private static string FormatLabel(int num, MonitorData m) + { + var name = m.DeviceName; + if (name.StartsWith(@"\\.\")) + name = name.Substring(4); + return $"{num}: {name} ({m.PixelWidth}\u00d7{m.PixelHeight})"; + } + + private void MonitorButton_Click(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement fe && fe.DataContext is MonitorPickerItem item) + { + SelectedMonitor = item.MonitorData; + DialogResult = true; + } + } + + private void Cancel_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + } + + protected override void OnPreviewKeyDown(KeyEventArgs e) + { + base.OnPreviewKeyDown(e); + + int num = -1; + if (e.Key >= Key.D1 && e.Key <= Key.D9) + num = e.Key - Key.D0; + else if (e.Key >= Key.NumPad1 && e.Key <= Key.NumPad9) + num = e.Key - Key.NumPad0; + else if (e.Key == Key.Escape) + { + DialogResult = false; + e.Handled = true; + return; + } + + if (num >= 1 && num <= _items.Count) + { + SelectedMonitor = _items[num - 1].MonitorData; + DialogResult = true; + e.Handled = true; + } + } +} diff --git a/src/WindowController.App/MonitorWarningDialog.xaml b/src/WindowController.App/MonitorWarningDialog.xaml new file mode 100644 index 0000000..89bc0de --- /dev/null +++ b/src/WindowController.App/MonitorWarningDialog.xaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WindowController.App/MonitorWarningDialog.xaml.cs b/src/WindowController.App/MonitorWarningDialog.xaml.cs new file mode 100644 index 0000000..1072e89 --- /dev/null +++ b/src/WindowController.App/MonitorWarningDialog.xaml.cs @@ -0,0 +1,29 @@ +using System.Windows; +using Wpf.Ui.Controls; + +namespace WindowController.App; + +/// +/// Confirmation dialog that displays monitor-transform warnings before applying +/// a profile to a different monitor. Returns true via DialogResult +/// if the user chooses to proceed. +/// +public partial class MonitorWarningDialog : FluentWindow +{ + public MonitorWarningDialog(string monitorDescription, IReadOnlyList warnings) + { + InitializeComponent(); + MonitorInfoText.Text = monitorDescription; + WarningList.ItemsSource = warnings; + } + + private void Continue_Click(object sender, RoutedEventArgs e) + { + DialogResult = true; + } + + private void Cancel_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + } +} diff --git a/src/WindowController.App/ProfileApplier.cs b/src/WindowController.App/ProfileApplier.cs index 0c8f4a9..0188ba4 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, List Failures, List Warnings) { public bool Success => Failures.Count == 0; @@ -20,6 +20,8 @@ public string ToStatusMessage(string profileName) var msg = $"{profileName} を適用: {Applied}/{Total}"; if (Failures.Count > 0) msg += $"(失敗 {Failures.Count}件: {string.Join(", ", Failures.Take(3))})"; + if (Warnings.Count > 0) + msg += $"(警告 {Warnings.Count}件)"; return msg; } } @@ -33,54 +35,55 @@ public class ProfileApplier private readonly WindowEnumerator _enumerator; private readonly WindowArranger _arranger; private readonly SyncManager _syncManager; + private readonly VirtualDesktopService _vdService; private readonly ILogger _log; public ProfileApplier(ProfileStore store, WindowEnumerator enumerator, - WindowArranger arranger, SyncManager syncManager, ILogger log) + WindowArranger arranger, SyncManager syncManager, + VirtualDesktopService vdService, ILogger log) { _store = store; _enumerator = enumerator; _arranger = arranger; _syncManager = syncManager; + _vdService = vdService; _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) + public async Task ApplyByIdAsync(string profileId, bool launchMissing, + nint appHwnd = 0, MonitorData? targetMonitor = null, Guid? targetDesktopId = null) { var profile = _store.FindById(profileId); if (profile == null) - { - return new ApplyResult(0, 0, new List { "プロファイルが見つかりません" }); - } + return new ApplyResult(0, 0, new List { "プロファイルが見つかりません" }, new()); - return await ApplyProfileAsync(profile, launchMissing); + return await ApplyProfileAsync(profile, launchMissing, appHwnd, targetMonitor, targetDesktopId); } /// /// Apply a profile by name. /// - public async Task ApplyByNameAsync(string profileName, bool launchMissing) + public async Task ApplyByNameAsync(string profileName, bool launchMissing, + nint appHwnd = 0, MonitorData? targetMonitor = null, Guid? targetDesktopId = null) { var profile = _store.FindByName(profileName); if (profile == null) - { - return new ApplyResult(0, 0, new List { "プロファイルが見つかりません" }); - } + return new ApplyResult(0, 0, new List { "プロファイルが見つかりません" }, new()); - return await ApplyProfileAsync(profile, launchMissing); + return await ApplyProfileAsync(profile, launchMissing, appHwnd, targetMonitor, targetDesktopId); } - private async Task ApplyProfileAsync(Profile profile, bool launchMissing) + private async Task ApplyProfileAsync(Profile profile, bool launchMissing, + nint appHwnd, MonitorData? targetMonitor = null, Guid? targetDesktopId = null) { + var settings = _store.Data.Settings; var candidates = GetCandidates(); int applied = 0; var failures = new List(); + var warnings = new List(); foreach (var entry in profile.Windows) { @@ -100,7 +103,32 @@ private async Task ApplyProfileAsync(Profile profile, bool launchMi continue; } - _arranger.Arrange(hwnd, entry); + // --- Virtual Desktop handling (整備中のため無効化) --- + // クロスデスクトップ適用を許可しない場合は、別デスクトップ上のウィンドウをスキップする。 + // ※デスクトップ移動/切り替えは実行しない。 + var onCurrent = _vdService.IsWindowOnCurrentDesktop(hwnd); + if (onCurrent == false && !settings.AllowCrossDesktopApply) + { + warnings.Add($"{entry.Match.Exe} | {entry.Match.Title} : 別デスクトップ — スキップ"); + continue; + } + + // --- Arrange (with monitor transform warnings) --- + var result = _arranger.Arrange(hwnd, entry, targetMonitor); + if (!result.Applied) + { + var reason = result.MonitorTransform?.Reasons.FirstOrDefault()?.Message ?? "配置失敗"; + failures.Add($"{entry.Match.Exe} | {entry.Match.Title} : {reason}"); + continue; + } + + // Collect monitor warnings + if (result.MonitorTransform is { Level: MonitorTransformLevel.Warn } mt) + { + foreach (var r in mt.Reasons) + warnings.Add($"{entry.Match.Exe} : {r.Message}"); + } + applied++; } catch (Exception ex) @@ -111,7 +139,7 @@ private async Task ApplyProfileAsync(Profile profile, bool launchMi } _syncManager.ScheduleRebuild(); - return new ApplyResult(applied, profile.Windows.Count, failures); + return new ApplyResult(applied, profile.Windows.Count, failures, warnings); } private async Task LaunchAndWaitAsync(WindowEntry entry, List existingCandidates) diff --git a/src/WindowController.App/ViewModels/MainViewModel.cs b/src/WindowController.App/ViewModels/MainViewModel.cs index 948efe9..5186f15 100644 --- a/src/WindowController.App/ViewModels/MainViewModel.cs +++ b/src/WindowController.App/ViewModels/MainViewModel.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.Windows; +using System.Windows.Interop; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Win32; @@ -32,6 +33,7 @@ public partial class ProfileItem : ObservableObject { [ObservableProperty] private bool _syncMinMax; [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _targetDesktopLabel = ""; public string Id { get; init; } = ""; public int WindowCount { get; init; } } @@ -43,6 +45,8 @@ public partial class MainViewModel : ObservableObject private readonly WindowArranger _arranger; private readonly BrowserUrlRetriever _urlRetriever; private readonly SyncManager _syncManager; + private readonly VirtualDesktopService _vdService; + private readonly ProfileApplier _profileApplier; private readonly ILogger _log; private bool _isUpdatingProfileName; @@ -55,13 +59,17 @@ public partial class MainViewModel : ObservableObject public MainViewModel(ProfileStore store, WindowEnumerator enumerator, WindowArranger arranger, BrowserUrlRetriever urlRetriever, - SyncManager syncManager, AppSettingsStore appSettings, ILogger log) + SyncManager syncManager, VirtualDesktopService vdService, + ProfileApplier profileApplier, + AppSettingsStore appSettings, ILogger log) { _store = store; _enumerator = enumerator; _arranger = arranger; _urlRetriever = urlRetriever; _syncManager = syncManager; + _vdService = vdService; + _profileApplier = profileApplier; _log = log; } @@ -184,22 +192,42 @@ private void SaveProfile() var rect = WindowEnumerator.GetWindowRect(w.Hwnd); var minMax = WindowEnumerator.GetMinMax(w.Hwnd); + // --- Always resolve owning monitor --- Snap? snap = null; Core.Models.MonitorInfo? monitor = null; - if (minMax == 0) + NormalizedRect? rectNormalized = null; + + var mon = MonitorHelper.GetMonitorForRect(rect.X, rect.Y, rect.W, rect.H); + if (mon != null) { - var mon = MonitorHelper.GetMonitorForRect(rect.X, rect.Y, rect.W, rect.H); - if (mon != null) + monitor = new Core.Models.MonitorInfo + { + Index = mon.Index, + Name = mon.DeviceName, + PixelWidth = mon.PixelWidth, + PixelHeight = mon.PixelHeight + }; + + // Normalized rect (work-area relative) + rectNormalized = NormalizedRect.FromAbsolute( + rect.X, rect.Y, rect.W, rect.H, mon.WorkArea); + + // Snap detection (normal state only) + if (minMax == 0) { - var snapType = SnapCalculator.DetectSnap(rect.X, rect.Y, rect.W, rect.H, mon.WorkArea); + var snapType = SnapCalculator.DetectSnap( + rect.X, rect.Y, rect.W, rect.H, mon.WorkArea); if (!string.IsNullOrEmpty(snapType)) - { snap = new Snap { Type = snapType }; - monitor = new Core.Models.MonitorInfo { Index = mon.Index, Name = mon.DeviceName }; - } } } + // --- Virtual Desktop Id --- + string? desktopId = null; + var dId = _vdService.GetWindowDesktopId(w.Hwnd); + if (dId.HasValue && dId.Value != Guid.Empty) + desktopId = dId.Value.ToString("D"); + return new WindowEntry { Match = new MatchInfo @@ -213,9 +241,11 @@ private void SaveProfile() }, Path = w.Path, Rect = rect, + RectNormalized = rectNormalized, MinMax = minMax, Snap = snap, - Monitor = monitor + Monitor = monitor, + DesktopId = desktopId }; } catch (Exception ex) @@ -254,49 +284,14 @@ private async Task DoApplyAsync(string profileId, bool launchMissing) var profile = _store.FindById(profileId); if (profile == null) { - StatusText = $"プロファイルが見つかりません"; + StatusText = "プロファイルが見つかりません"; return; } - var profileName = profile.Name; - - 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"); - } - } + var appHwnd = GetMainWindowHandle(); - var msg = $"{profileName} を適用: {applied}/{profile.Windows.Count}"; - if (failures.Count > 0) - msg += $"(失敗 {failures.Count}件: {string.Join(", ", failures.Take(3))})"; - StatusText = msg; - _syncManager.ScheduleRebuild(); + var result = await _profileApplier.ApplyByIdAsync(profileId, launchMissing, appHwnd); + StatusText = result.ToStatusMessage(profile.Name); } catch (Exception ex) { @@ -305,76 +300,127 @@ private async Task DoApplyAsync(string profileId, bool launchMissing) } } - private async Task LaunchAndWaitAsync(WindowEntry entry, List existingCandidates) + [RelayCommand] + private async Task ApplyToMonitor() { - var exe = entry.Match.Exe; - var url = entry.Match.Url; - var path = entry.Path; + if (SelectedProfile == null) + { + StatusText = "プロファイルを選択してください。"; + return; + } + + var monitors = MonitorHelper.GetMonitors(); + if (monitors.Count == 0) + { + StatusText = "モニターが見つかりません。"; + return; + } - // Collect existing hwnds for this exe - var beforeHwnds = new HashSet( - existingCandidates.Where(c => c.Exe.Equals(exe, StringComparison.OrdinalIgnoreCase)).Select(c => c.Hwnd)); + var overlays = new List(); + MonitorData? selectedMonitor = null; try { - var startPath = !string.IsNullOrEmpty(path) && File.Exists(path) ? path : exe; - - var psi = new ProcessStartInfo(startPath); - if (!string.IsNullOrEmpty(url)) + for (int i = 0; i < monitors.Count; i++) { - // 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); - } + var m = monitors[i]; + var name = m.DeviceName; + if (name.StartsWith("\\\\.\\")) + name = name.Substring(4); + var overlay = new MonitorOverlayWindow( + i + 1, $"{name}\n{m.PixelWidth}\u00d7{m.PixelHeight}", + m.MonitorRect.Left, m.MonitorRect.Top, + m.MonitorRect.Width, m.MonitorRect.Height); + overlay.Show(); + overlays.Add(overlay); } - psi.UseShellExecute = true; - Process.Start(psi); + + var picker = new MonitorPickerWindow(monitors); + if (Application.Current.MainWindow is { } owner) + picker.Owner = owner; + if (picker.ShowDialog() == true) + selectedMonitor = picker.SelectedMonitor; } - catch (Exception ex) + finally { - _log.Warning(ex, "Launch failed for {Exe}", exe); - return 0; + foreach (var o in overlays) + o.Close(); } - // Wait for new window - var sw = Stopwatch.StartNew(); - while (sw.ElapsedMilliseconds < 12000) + if (selectedMonitor == null) return; + + // --- 事前警告チェック --- + var profile = _store.FindById(SelectedProfile.Id); + if (profile == null) { - await Task.Delay(300); - var wins = _enumerator.EnumerateWindows(); - foreach (var w in wins) + StatusText = "プロファイルが見つかりません"; + return; + } + + var settings = _store.Data.Settings; + var preWarnings = new List(); + foreach (var entry in profile.Windows) + { + bool isExact = entry.Monitor != null + && !string.IsNullOrEmpty(entry.Monitor.Name) + && entry.Monitor.Name == selectedMonitor.DeviceName; + + var result = MonitorTransformDecision.Evaluate( + entry.Monitor, + selectedMonitor.PixelWidth, + selectedMonitor.PixelHeight, + isExact, + settings); + + if (result.Level >= MonitorTransformLevel.Warn) { - if (w.Exe.Equals(exe, StringComparison.OrdinalIgnoreCase) && !beforeHwnds.Contains(w.Hwnd)) - return w.Hwnd; + var exeName = entry.Match?.Exe ?? "不明"; + foreach (var r in result.Reasons) + preWarnings.Add($"{exeName}: {r.Message}"); } } - // Last resort: try matching again - var newCandidates = GetCandidates(); - var match = WindowMatcher.FindBest(entry, newCandidates); - return match?.Hwnd ?? 0; + // 重複排除 (同一解像度警告など) + preWarnings = preWarnings.Distinct().ToList(); + + if (preWarnings.Count > 0) + { + var monName = selectedMonitor.DeviceName; + if (monName.StartsWith("\\\\.\\")) + monName = monName.Substring(4); + var desc = $"配置先: {monName} ({selectedMonitor.PixelWidth}\u00d7{selectedMonitor.PixelHeight})"; + + var dlg = new MonitorWarningDialog(desc, preWarnings); + if (Application.Current.MainWindow is { } warnOwner) + dlg.Owner = warnOwner; + if (dlg.ShowDialog() != true) + { + StatusText = "配置をキャンセルしました。"; + return; + } + } + + // --- 適用 --- + try + { + var appHwnd = GetMainWindowHandle(); + + var result = await _profileApplier.ApplyByIdAsync( + SelectedProfile.Id, false, appHwnd, selectedMonitor); + StatusText = result.ToStatusMessage(profile.Name); + } + catch (Exception ex) + { + _log.Error(ex, "ApplyToMonitor failed"); + StatusText = $"適用に失敗: {ex.Message}"; + } } - private List GetCandidates() + [RelayCommand] + private Task ApplyToDesktopAndMonitor() { - 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(); + // 仮想デスクトップ関連は整備中のため、現時点では何もしない。 + return Task.CompletedTask; } [RelayCommand] @@ -413,6 +459,65 @@ private void DeleteProfile() } } + [RelayCommand] + private void SetTargetDesktop() + { + if (SelectedProfile == null) + { + StatusText = "プロファイルを選択してください。"; + return; + } + + var appHwnd = GetMainWindowHandle(); + + var desktopId = _vdService.GetCurrentDesktopId(appHwnd); + if (!desktopId.HasValue || desktopId.Value == Guid.Empty) + { + StatusText = "現在のデスクトップIDを取得できませんでした。"; + return; + } + + var profile = _store.FindById(SelectedProfile.Id); + if (profile == null) return; + + profile.TargetDesktopId = desktopId.Value.ToString("D"); + _store.SaveProfile(profile); + + SelectedProfile.TargetDesktopLabel = FormatDesktopLabel(profile.TargetDesktopId); + StatusText = $"ターゲットデスクトップを設定しました: {profile.Name}"; + } + + [RelayCommand] + private void ClearTargetDesktop() + { + if (SelectedProfile == null) + { + StatusText = "プロファイルを選択してください。"; + return; + } + + var profile = _store.FindById(SelectedProfile.Id); + if (profile == null) return; + + profile.TargetDesktopId = null; + _store.SaveProfile(profile); + + SelectedProfile.TargetDesktopLabel = ""; + StatusText = $"ターゲットデスクトップを解除しました: {profile.Name}"; + } + + private static string FormatDesktopLabel(string? desktopId) + => desktopId ?? ""; + + private static nint GetMainWindowHandle() + { + if (Application.Current.MainWindow is not { } mainWindow) + return 0; + + var helper = new WindowInteropHelper(mainWindow); + return helper.Handle; + } + public void ReloadProfiles() { Profiles.Clear(); @@ -423,7 +528,8 @@ public void ReloadProfiles() Id = p.Id, Name = p.Name, SyncMinMax = p.SyncMinMax != 0, - WindowCount = p.Windows.Count + WindowCount = p.Windows.Count, + TargetDesktopLabel = FormatDesktopLabel(p.TargetDesktopId) }; item.PropertyChanged += (s, e) => { diff --git a/src/WindowController.Core.Tests/MonitorTransformDecisionTests.cs b/src/WindowController.Core.Tests/MonitorTransformDecisionTests.cs new file mode 100644 index 0000000..688ccb1 --- /dev/null +++ b/src/WindowController.Core.Tests/MonitorTransformDecisionTests.cs @@ -0,0 +1,184 @@ +using WindowController.Core.Models; + +namespace WindowController.Core.Tests; + +public class MonitorTransformDecisionTests +{ + private static Settings DefaultSettings => new() + { + AspectRatioWarnThreshold = 0.02, + WarnOnResolutionMismatch = true, + WarnOnMonitorMismatch = true, + AllowCrossDesktopApply = true + }; + + private static MonitorInfo MakeSaved(int w = 1920, int h = 1080, string name = "\\\\.\\DISPLAY1", int index = 0) + => new() { PixelWidth = w, PixelHeight = h, Name = name, Index = index }; + + // ────────── Allow: exact same monitor + resolution ────────── + + [Fact] + public void ExactMatch_SameResolution_Allow() + { + var saved = MakeSaved(); + var result = MonitorTransformDecision.Evaluate(saved, 1920, 1080, isExactMonitorMatch: true, DefaultSettings); + + Assert.Equal(MonitorTransformLevel.Allow, result.Level); + Assert.Empty(result.Reasons); + } + + // ────────── Deny: invalid target monitor ────────── + + [Fact] + public void InvalidTarget_ZeroSize_Deny() + { + var saved = MakeSaved(); + var result = MonitorTransformDecision.Evaluate(saved, 0, 0, isExactMonitorMatch: false, DefaultSettings); + + Assert.Equal(MonitorTransformLevel.Deny, result.Level); + Assert.Single(result.Reasons); + } + + [Fact] + public void InvalidTarget_Negative_Deny() + { + var saved = MakeSaved(); + var result = MonitorTransformDecision.Evaluate(saved, -1, 1080, isExactMonitorMatch: false, DefaultSettings); + + Assert.Equal(MonitorTransformLevel.Deny, result.Level); + } + + // ────────── Warn: no saved monitor info ────────── + + [Fact] + public void SavedNull_Warn() + { + var result = MonitorTransformDecision.Evaluate(null, 1920, 1080, isExactMonitorMatch: false, DefaultSettings); + + Assert.Equal(MonitorTransformLevel.Warn, result.Level); + Assert.Single(result.Reasons); + Assert.Contains("保存時モニタ情報なし", result.Reasons[0].Message); + } + + [Fact] + public void SavedNoPixels_Warn() + { + var saved = new MonitorInfo { Index = 0, Name = "DISPLAY1" }; + var result = MonitorTransformDecision.Evaluate(saved, 1920, 1080, isExactMonitorMatch: false, DefaultSettings); + + Assert.Equal(MonitorTransformLevel.Warn, result.Level); + } + + [Fact] + public void SavedNull_WarnDisabled_Allow() + { + var settings = DefaultSettings; + settings.WarnOnMonitorMismatch = false; + + var result = MonitorTransformDecision.Evaluate(null, 1920, 1080, isExactMonitorMatch: false, settings); + + Assert.Equal(MonitorTransformLevel.Allow, result.Level); + } + + // ────────── Warn: same ratio, different resolution (1080p → 4K) ────────── + + [Fact] + public void SameRatio_DifferentResolution_Warn() + { + var saved = MakeSaved(1920, 1080); + var result = MonitorTransformDecision.Evaluate(saved, 3840, 2160, isExactMonitorMatch: true, DefaultSettings); + + Assert.Equal(MonitorTransformLevel.Warn, result.Level); + Assert.Contains(result.Reasons, r => r.Message.Contains("解像度")); + } + + [Fact] + public void SameRatio_DifferentResolution_WarnDisabled_Allow() + { + var settings = DefaultSettings; + settings.WarnOnResolutionMismatch = false; + + var saved = MakeSaved(1920, 1080); + var result = MonitorTransformDecision.Evaluate(saved, 3840, 2160, isExactMonitorMatch: true, settings); + + Assert.Equal(MonitorTransformLevel.Allow, result.Level); + } + + // ────────── Warn: different aspect ratio ────────── + + [Fact] + public void DifferentAspectRatio_Warn() + { + // 16:9 saved → 21:9 target + var saved = MakeSaved(1920, 1080); + var result = MonitorTransformDecision.Evaluate(saved, 2560, 1080, isExactMonitorMatch: false, DefaultSettings); + + Assert.Equal(MonitorTransformLevel.Warn, result.Level); + Assert.Contains(result.Reasons, r => r.Message.Contains("アスペクト比")); + } + + [Fact] + public void DifferentAspectRatio_WithinThreshold_NoAspectWarning() + { + // Tiny aspect ratio difference within 2% threshold + var saved = MakeSaved(1920, 1080); // AR = 1.778 + // Target: 1921/1080 ≈ 1.779 difference < 0.02 + var result = MonitorTransformDecision.Evaluate(saved, 1921, 1080, isExactMonitorMatch: true, DefaultSettings); + + // Should warn about resolution mismatch but NOT aspect ratio + Assert.DoesNotContain(result.Reasons, r => r.Message.Contains("アスペクト比")); + } + + // ────────── Warn: monitor fallback ────────── + + [Fact] + public void MonitorFallback_SameResolution_Warn() + { + var saved = MakeSaved(1920, 1080); + var result = MonitorTransformDecision.Evaluate(saved, 1920, 1080, isExactMonitorMatch: false, DefaultSettings); + + Assert.Equal(MonitorTransformLevel.Warn, result.Level); + Assert.Contains(result.Reasons, r => r.Message.Contains("別のモニタ")); + } + + [Fact] + public void MonitorFallback_WarnDisabled_Allow() + { + var settings = DefaultSettings; + settings.WarnOnMonitorMismatch = false; + + var saved = MakeSaved(1920, 1080); + var result = MonitorTransformDecision.Evaluate(saved, 1920, 1080, isExactMonitorMatch: false, settings); + + Assert.Equal(MonitorTransformLevel.Allow, result.Level); + } + + // ────────── Multiple warnings combined ────────── + + [Fact] + public void DifferentAspect_DifferentResolution_DifferentMonitor_MultipleWarnings() + { + var saved = MakeSaved(1920, 1080); // 16:9 + var result = MonitorTransformDecision.Evaluate(saved, 2560, 1440, isExactMonitorMatch: false, DefaultSettings); + + Assert.Equal(MonitorTransformLevel.Warn, result.Level); + // Should have both resolution warning and monitor fallback warning + Assert.Contains(result.Reasons, r => r.Message.Contains("解像度")); + Assert.Contains(result.Reasons, r => r.Message.Contains("別のモニタ")); + } + + // ────────── Conversion table from plan ────────── + + [Theory] + [InlineData(1920, 1080, 1920, 1080, true, MonitorTransformLevel.Allow)] // Same monitor, same resolution + [InlineData(1920, 1080, 3840, 2160, true, MonitorTransformLevel.Warn)] // Same monitor, 1080→4K + [InlineData(1920, 1080, 2560, 1080, false, MonitorTransformLevel.Warn)] // Different monitor, different AR + [InlineData(1920, 1080, 1920, 1080, false, MonitorTransformLevel.Warn)] // Different monitor, same res + public void ConversionTable(int savedW, int savedH, int targetW, int targetH, bool exact, MonitorTransformLevel expected) + { + var saved = MakeSaved(savedW, savedH); + var result = MonitorTransformDecision.Evaluate(saved, targetW, targetH, exact, DefaultSettings); + + Assert.Equal(expected, result.Level); + } +} diff --git a/src/WindowController.Core.Tests/NormalizedRectTests.cs b/src/WindowController.Core.Tests/NormalizedRectTests.cs new file mode 100644 index 0000000..25a12ad --- /dev/null +++ b/src/WindowController.Core.Tests/NormalizedRectTests.cs @@ -0,0 +1,201 @@ +using WindowController.Core.Models; + +namespace WindowController.Core.Tests; + +public class NormalizedRectTests +{ + // Standard 1920×1080 work area starting at (0, 0), taskbar 40px + private static readonly WorkArea Wa1080 = new(0, 0, 1920, 1040); + // 4K work area with same aspect ratio, taskbar 40px scaled + private static readonly WorkArea Wa4K = new(0, 0, 3840, 2120); + // Secondary monitor offset + private static readonly WorkArea WaSecondary = new(1920, 0, 1920, 1040); + + // ────────────────── FromAbsolute ────────────────── + + [Fact] + public void FromAbsolute_FullWorkArea_Returns_1_1() + { + var norm = NormalizedRect.FromAbsolute(0, 0, 1920, 1040, Wa1080); + + Assert.Equal(0.0, norm.XN, 6); + Assert.Equal(0.0, norm.YN, 6); + Assert.Equal(1.0, norm.WN, 6); + Assert.Equal(1.0, norm.HN, 6); + } + + [Fact] + public void FromAbsolute_LeftHalf_Returns_0_5_Width() + { + var norm = NormalizedRect.FromAbsolute(0, 0, 960, 1040, Wa1080); + + Assert.Equal(0.0, norm.XN, 6); + Assert.Equal(0.0, norm.YN, 6); + Assert.Equal(0.5, norm.WN, 6); + Assert.Equal(1.0, norm.HN, 6); + } + + [Fact] + public void FromAbsolute_RightHalf_Returns_0_5_X() + { + var norm = NormalizedRect.FromAbsolute(960, 0, 960, 1040, Wa1080); + + Assert.Equal(0.5, norm.XN, 6); + Assert.Equal(0.0, norm.YN, 6); + Assert.Equal(0.5, norm.WN, 6); + Assert.Equal(1.0, norm.HN, 6); + } + + [Fact] + public void FromAbsolute_SecondaryMonitor_UsesRelativeCoordinates() + { + // Window at left half of secondary monitor + var norm = NormalizedRect.FromAbsolute(1920, 0, 960, 1040, WaSecondary); + + Assert.Equal(0.0, norm.XN, 6); + Assert.Equal(0.0, norm.YN, 6); + Assert.Equal(0.5, norm.WN, 6); + Assert.Equal(1.0, norm.HN, 6); + } + + [Fact] + public void FromAbsolute_ZeroSizeWorkArea_ReturnsZeros() + { + var zeroWa = new WorkArea(0, 0, 0, 0); + var norm = NormalizedRect.FromAbsolute(100, 200, 400, 300, zeroWa); + + Assert.Equal(0.0, norm.XN); + Assert.Equal(0.0, norm.YN); + Assert.Equal(0.0, norm.WN); + Assert.Equal(0.0, norm.HN); + } + + // ────────────────── ToAbsolute ────────────────── + + [Fact] + public void ToAbsolute_FullWorkArea_ReturnsFullRect() + { + var norm = new NormalizedRect { XN = 0, YN = 0, WN = 1, HN = 1 }; + var rect = norm.ToAbsolute(Wa1080); + + Assert.Equal(0, rect.X); + Assert.Equal(0, rect.Y); + Assert.Equal(1920, rect.W); + Assert.Equal(1040, rect.H); + } + + [Fact] + public void ToAbsolute_SecondaryMonitor_OffsetsCorrectly() + { + var norm = new NormalizedRect { XN = 0.5, YN = 0, WN = 0.5, HN = 1 }; + var rect = norm.ToAbsolute(WaSecondary); + + Assert.Equal(1920 + 960, rect.X); + Assert.Equal(0, rect.Y); + Assert.Equal(960, rect.W); + Assert.Equal(1040, rect.H); + } + + // ────────────────── Round-trip ────────────────── + + [Fact] + public void RoundTrip_SameWorkArea_ReturnsOriginal() + { + int origX = 200, origY = 50, origW = 800, origH = 600; + var norm = NormalizedRect.FromAbsolute(origX, origY, origW, origH, Wa1080); + var restored = norm.ToAbsolute(Wa1080); + + Assert.Equal(origX, restored.X); + Assert.Equal(origY, restored.Y); + Assert.Equal(origW, restored.W); + Assert.Equal(origH, restored.H); + } + + [Fact] + public void RoundTrip_1080p_To_4K_ScalesProportionally() + { + // Window at left-half on 1080p + var norm = NormalizedRect.FromAbsolute(0, 0, 960, 1040, Wa1080); + var restored = norm.ToAbsolute(Wa4K); + + // Should be left-half on 4K + Assert.Equal(0, restored.X); + Assert.Equal(0, restored.Y); + Assert.Equal(1920, restored.W); // 3840 * 0.5 + Assert.Equal(2120, restored.H); // full height of 4K work area + } + + [Fact] + public void RoundTrip_4K_To_1080p_ScalesDown() + { + // Window using right 25% of 4K work area + var norm = NormalizedRect.FromAbsolute(2880, 0, 960, 2120, Wa4K); + var restored = norm.ToAbsolute(Wa1080); + + // xN = 2880/3840 = 0.75, wN = 960/3840 = 0.25 + Assert.Equal(0.75, norm.XN, 6); + Assert.Equal(0.25, norm.WN, 6); + Assert.Equal(1440, restored.X); // 1920 * 0.75 + Assert.Equal(480, restored.W); // 1920 * 0.25 + } + + // ────────────────── Drop-shadow / negative overflow ────────────────── + // Windows 10/11 invisible borders cause xN < 0 and wN > 1 + + [Fact] + public void RoundTrip_NegativeOverflow_DropShadow_PreservedExactly() + { + // Simulates a window on a portrait secondary monitor at (-1080, 30, 1080, 1890) + // with 7px drop-shadow overflow: x=-1087, w=1094 + var waPortrait = new WorkArea(-1080, 30, 1080, 1890); + + var norm = NormalizedRect.FromAbsolute(-1087, 30, 1094, 192, waPortrait); + + // xN should be negative, wN should exceed 1 + Assert.True(norm.XN < 0); + Assert.True(norm.WN > 1); + + // Round-trip back to same work area should be pixel-perfect + var restored = norm.ToAbsolute(waPortrait); + Assert.Equal(-1087, restored.X); + Assert.Equal(30, restored.Y); + Assert.Equal(1094, restored.W); + Assert.Equal(192, restored.H); + } + + [Fact] + public void RoundTrip_NegativeOverflow_AnotherWindow_PreservedExactly() + { + // Another window on the same portrait monitor: x=-1087, y=1119, w=1094, h=808 + var waPortrait = new WorkArea(-1080, 30, 1080, 1890); + + var norm = NormalizedRect.FromAbsolute(-1087, 1119, 1094, 808, waPortrait); + var restored = norm.ToAbsolute(waPortrait); + + Assert.Equal(-1087, restored.X); + Assert.Equal(1119, restored.Y); + Assert.Equal(1094, restored.W); + Assert.Equal(808, restored.H); + } + + [Fact] + public void ToAbsolute_UsesRounding_NotTruncation() + { + // xN * width = -6.999... should round to -7, not truncate to -6 + var wa = new WorkArea(-1080, 30, 1080, 1890); + var norm = new NormalizedRect + { + XN = -7.0 / 1080, // = -0.00648148... + YN = 0, + WN = 1094.0 / 1080, // = 1.01296296... + HN = 192.0 / 1890 // = 0.10158730... + }; + + var restored = norm.ToAbsolute(wa); + + // With Math.Round these should be exact + Assert.Equal(-1080 + (-7), restored.X); // -1087 + Assert.Equal(1094, restored.W); + Assert.Equal(192, restored.H); + } +} diff --git a/src/WindowController.Core/Models/MonitorInfo.cs b/src/WindowController.Core/Models/MonitorInfo.cs index 149e633..9fbdc1d 100644 --- a/src/WindowController.Core/Models/MonitorInfo.cs +++ b/src/WindowController.Core/Models/MonitorInfo.cs @@ -9,4 +9,31 @@ public class MonitorInfo [JsonPropertyName("name")] public string Name { get; set; } = ""; + + /// + /// DisplayConfig target device path — the most stable physical monitor identifier. + /// + [JsonPropertyName("devicePath")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DevicePath { get; set; } + + /// + /// Full monitor pixel width (rcMonitor), used for aspect-ratio / resolution warnings. + /// + [JsonPropertyName("pixelWidth")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int PixelWidth { get; set; } + + /// + /// Full monitor pixel height (rcMonitor), used for aspect-ratio / resolution warnings. + /// + [JsonPropertyName("pixelHeight")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int PixelHeight { get; set; } + + /// + /// Aspect ratio (width / height). Computed helper — written for diagnostics. + /// + [JsonIgnore] + public double AspectRatio => PixelHeight > 0 ? (double)PixelWidth / PixelHeight : 0; } diff --git a/src/WindowController.Core/Models/NormalizedRect.cs b/src/WindowController.Core/Models/NormalizedRect.cs new file mode 100644 index 0000000..968a738 --- /dev/null +++ b/src/WindowController.Core/Models/NormalizedRect.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace WindowController.Core.Models; + +/// +/// Window rectangle expressed as fractions (0..1) of the owning monitor's work area. +/// Enables resolution-independent restore across monitors with different pixel dimensions. +/// +public class NormalizedRect +{ + [JsonPropertyName("xN")] + public double XN { get; set; } + + [JsonPropertyName("yN")] + public double YN { get; set; } + + [JsonPropertyName("wN")] + public double WN { get; set; } + + [JsonPropertyName("hN")] + public double HN { get; set; } + + /// + /// Create a NormalizedRect from absolute pixel values relative to a work area. + /// + public static NormalizedRect FromAbsolute(int x, int y, int w, int h, WorkArea wa) + { + if (wa.Width <= 0 || wa.Height <= 0) + return new NormalizedRect(); + + return new NormalizedRect + { + XN = (double)(x - wa.Left) / wa.Width, + YN = (double)(y - wa.Top) / wa.Height, + WN = (double)w / wa.Width, + HN = (double)h / wa.Height + }; + } + + /// + /// Convert back to absolute pixel rect using the given work area. + /// Uses Math.Round to prevent truncation-based drift (e.g. -6.999 → -7, not -6). + /// + public Rect ToAbsolute(WorkArea wa) + { + return new Rect + { + X = wa.Left + (int)Math.Round(XN * wa.Width), + Y = wa.Top + (int)Math.Round(YN * wa.Height), + W = (int)Math.Round(WN * wa.Width), + H = (int)Math.Round(HN * wa.Height) + }; + } +} diff --git a/src/WindowController.Core/Models/Profile.cs b/src/WindowController.Core/Models/Profile.cs index 5c4d767..c5abe17 100644 --- a/src/WindowController.Core/Models/Profile.cs +++ b/src/WindowController.Core/Models/Profile.cs @@ -21,4 +21,12 @@ public class Profile [JsonPropertyName("windows")] public List Windows { get; set; } = new(); + + /// + /// Target virtual desktop GUID. When set, windows are moved here on apply. + /// Set via right-click → "このデスクトップをターゲットに設定". + /// + [JsonPropertyName("targetDesktopId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TargetDesktopId { get; set; } } diff --git a/src/WindowController.Core/Models/Settings.cs b/src/WindowController.Core/Models/Settings.cs index 165308f..7548b11 100644 --- a/src/WindowController.Core/Models/Settings.cs +++ b/src/WindowController.Core/Models/Settings.cs @@ -9,4 +9,34 @@ public class Settings [JsonPropertyName("showGuiOnStartup")] public int ShowGuiOnStartup { get; set; } + + // ── Monitor mismatch policies ── + + /// + /// Warn when the target monitor has a different aspect ratio. + /// Threshold expressed as absolute difference of w/h ratios (default 0.02 ≈ 2 %). + /// + [JsonPropertyName("aspectRatioWarnThreshold")] + public double AspectRatioWarnThreshold { get; set; } = 0.02; + + /// + /// Warn when the window is placed on a monitor with a different resolution + /// (even if the aspect ratio is the same, e.g. 1080p → 4K). + /// + [JsonPropertyName("warnOnResolutionMismatch")] + public bool WarnOnResolutionMismatch { get; set; } = true; + + /// + /// Warn when the target monitor cannot be resolved and a fallback is used. + /// + [JsonPropertyName("warnOnMonitorMismatch")] + public bool WarnOnMonitorMismatch { get; set; } = true; + + // ── Virtual Desktop policies ── + + /// + /// Allow applying / moving windows across virtual desktops. + /// + [JsonPropertyName("allowCrossDesktopApply")] + public bool AllowCrossDesktopApply { get; set; } = true; } diff --git a/src/WindowController.Core/Models/WindowEntry.cs b/src/WindowController.Core/Models/WindowEntry.cs index 16c70d6..44352b6 100644 --- a/src/WindowController.Core/Models/WindowEntry.cs +++ b/src/WindowController.Core/Models/WindowEntry.cs @@ -13,6 +13,14 @@ public class WindowEntry [JsonPropertyName("rect")] public Rect Rect { get; set; } = new(); + /// + /// Window rect normalised to the owning monitor's work area (0..1). + /// Used for resolution-independent restore. + /// + [JsonPropertyName("rectNormalized")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public NormalizedRect? RectNormalized { get; set; } + [JsonPropertyName("minMax")] public int MinMax { get; set; } @@ -23,4 +31,11 @@ public class WindowEntry [JsonPropertyName("monitor")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public MonitorInfo? Monitor { get; set; } + + /// + /// Virtual Desktop GUID that owned this window at capture time. + /// + [JsonPropertyName("desktopId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DesktopId { get; set; } } diff --git a/src/WindowController.Core/MonitorTransformDecision.cs b/src/WindowController.Core/MonitorTransformDecision.cs new file mode 100644 index 0000000..978cd84 --- /dev/null +++ b/src/WindowController.Core/MonitorTransformDecision.cs @@ -0,0 +1,122 @@ +using WindowController.Core.Models; + +namespace WindowController.Core; + +/// +/// Decision result for monitor transform checks. +/// +public enum MonitorTransformLevel +{ + /// No issues — apply normally. + Allow, + /// Differences detected — apply but warn the user. + Warn, + /// Restore is not possible. + Deny +} + +/// +/// Detail of a single monitor transform warning/deny reason. +/// +public record MonitorTransformReason(MonitorTransformLevel Level, string Message); + +/// +/// Aggregate result of monitor transform evaluation. +/// +public class MonitorTransformResult +{ + public MonitorTransformLevel Level { get; init; } = MonitorTransformLevel.Allow; + public List Reasons { get; init; } = new(); + + public static MonitorTransformResult Ok() => new(); +} + +/// +/// Pure-function evaluator that compares a saved MonitorInfo against a target monitor +/// and produces Allow / Warn / Deny with reasons. +/// +public static class MonitorTransformDecision +{ + /// + /// Evaluate whether restoring onto from is safe. + /// + public static MonitorTransformResult Evaluate( + MonitorInfo? saved, + int targetPixelWidth, + int targetPixelHeight, + bool isExactMonitorMatch, + Settings settings) + { + // If the target monitor has no valid size, deny. + if (targetPixelWidth <= 0 || targetPixelHeight <= 0) + { + return new MonitorTransformResult + { + Level = MonitorTransformLevel.Deny, + Reasons = { new(MonitorTransformLevel.Deny, "ターゲットモニタの情報を取得できません") } + }; + } + + // If saved monitor info is missing entirely, we only know the absolute rect — warn. + if (saved == null || (saved.PixelWidth <= 0 && saved.PixelHeight <= 0)) + { + if (!settings.WarnOnMonitorMismatch) + return MonitorTransformResult.Ok(); + + return new MonitorTransformResult + { + Level = MonitorTransformLevel.Warn, + Reasons = { new(MonitorTransformLevel.Warn, "保存時モニタ情報なし — 絶対座標で復元します") } + }; + } + + // Same physical monitor — no warnings needed. + if (isExactMonitorMatch + && saved.PixelWidth == targetPixelWidth + && saved.PixelHeight == targetPixelHeight) + { + return MonitorTransformResult.Ok(); + } + + var reasons = new List(); + + // Aspect ratio check + double savedAR = (double)saved.PixelWidth / saved.PixelHeight; + double targetAR = (double)targetPixelWidth / targetPixelHeight; + double arDiff = Math.Abs(savedAR - targetAR); + + if (arDiff > settings.AspectRatioWarnThreshold) + { + reasons.Add(new(MonitorTransformLevel.Warn, + $"アスペクト比が異なります: 保存={savedAR:F3}, 適用先={targetAR:F3} (差={arDiff:F3})")); + } + + // Resolution check (same aspect but different pixel count → default warn) + if (settings.WarnOnResolutionMismatch + && (saved.PixelWidth != targetPixelWidth || saved.PixelHeight != targetPixelHeight)) + { + // "Same model multiple monitors" case: exact match means no warning. + // That is already handled by the isExactMonitorMatch + resolution equality check above. + // If we get here, resolution differs. + reasons.Add(new(MonitorTransformLevel.Warn, + $"解像度が異なります: 保存={saved.PixelWidth}x{saved.PixelHeight}, 適用先={targetPixelWidth}x{targetPixelHeight}")); + } + + // Monitor fallback (name/index mismatch) + if (!isExactMonitorMatch && settings.WarnOnMonitorMismatch) + { + reasons.Add(new(MonitorTransformLevel.Warn, + $"別のモニタに配置します (保存={saved.Name} #{saved.Index})")); + } + + if (reasons.Count == 0) + return MonitorTransformResult.Ok(); + + var maxLevel = reasons.Max(r => r.Level); + return new MonitorTransformResult + { + Level = maxLevel, + Reasons = reasons + }; + } +} diff --git a/src/WindowController.Win32/MonitorHelper.cs b/src/WindowController.Win32/MonitorHelper.cs index 372e9af..40ca6c8 100644 --- a/src/WindowController.Win32/MonitorHelper.cs +++ b/src/WindowController.Win32/MonitorHelper.cs @@ -10,12 +10,24 @@ public class MonitorData public int Index { get; init; } public string DeviceName { get; init; } = ""; public WorkArea WorkArea { get; init; } = new(0, 0, 0, 0); + + /// Full monitor pixel width (rcMonitor). + public int PixelWidth { get; init; } + + /// Full monitor pixel height (rcMonitor). + public int PixelHeight { get; init; } + + /// Full monitor bounds (rcMonitor) as a WorkArea for convenience. + public WorkArea MonitorRect { get; init; } = new(0, 0, 0, 0); + + /// Aspect ratio (width / height). 0 if height is 0. + public double AspectRatio => PixelHeight > 0 ? (double)PixelWidth / PixelHeight : 0; } public static class MonitorHelper { /// - /// Get all monitors with their work areas. + /// Get all monitors with their work areas and pixel dimensions. /// public static List GetMonitors() { @@ -30,6 +42,9 @@ public static List GetMonitors() if (NativeMethods.GetMonitorInfoW(hMonitor, ref mi)) { + var monW = mi.rcMonitor.Right - mi.rcMonitor.Left; + var monH = mi.rcMonitor.Bottom - mi.rcMonitor.Top; + monitors.Add(new MonitorData { Index = index, @@ -38,7 +53,13 @@ public static List GetMonitors() mi.rcWork.Left, mi.rcWork.Top, mi.rcWork.Right - mi.rcWork.Left, - mi.rcWork.Bottom - mi.rcWork.Top) + mi.rcWork.Bottom - mi.rcWork.Top), + PixelWidth = monW, + PixelHeight = monH, + MonitorRect = new WorkArea( + mi.rcMonitor.Left, + mi.rcMonitor.Top, + monW, monH) }); } return true; @@ -68,4 +89,35 @@ public static List GetMonitors() // Fallback to first return monitors[0]; } + + /// + /// Resolve a saved MonitorInfo to a current MonitorData using best-match priority: + /// devicePath → name → index → primary. + /// + public static (MonitorData Monitor, bool IsExactMatch) ResolveMonitor( + Core.Models.MonitorInfo? saved, List? monitors = null) + { + monitors ??= GetMonitors(); + if (monitors.Count == 0) + return (new MonitorData(), false); + + if (saved == null) + return (monitors[0], false); + + // Priority 1: devicePath (not yet populated — future-proofing) + // Priority 2: name + if (!string.IsNullOrEmpty(saved.Name)) + { + var byName = monitors.FirstOrDefault(m => m.DeviceName == saved.Name); + if (byName != null) + return (byName, true); + } + + // Priority 3: index + if (saved.Index >= 1 && saved.Index <= monitors.Count) + return (monitors[saved.Index - 1], false); + + // Fallback: primary + return (monitors[0], false); + } } diff --git a/src/WindowController.Win32/NativeMethods.cs b/src/WindowController.Win32/NativeMethods.cs index 69d77e3..6233568 100644 --- a/src/WindowController.Win32/NativeMethods.cs +++ b/src/WindowController.Win32/NativeMethods.cs @@ -131,6 +131,12 @@ public static extern bool QueryFullProcessImageNameW(nint hProcess, uint dwFlags public const uint MOD_NOREPEAT = 0x4000; public const uint VK_W = 0x57; + // DWM — cloaked detection (virtual desktop / store apps) + public const int DWMWA_CLOAKED = 14; + + [DllImport("dwmapi.dll")] + public static extern int DwmGetWindowAttribute(nint hwnd, int dwAttribute, out int pvAttribute, int cbAttribute); + [StructLayout(LayoutKind.Sequential)] public struct RECT { diff --git a/src/WindowController.Win32/VirtualDesktopMoveHelper.cs b/src/WindowController.Win32/VirtualDesktopMoveHelper.cs new file mode 100644 index 0000000..e279800 --- /dev/null +++ b/src/WindowController.Win32/VirtualDesktopMoveHelper.cs @@ -0,0 +1,347 @@ +using System.Runtime.InteropServices; +using Serilog; + +namespace WindowController.Win32; + +/// +/// Uses undocumented internal shell COM interfaces to move windows between +/// virtual desktops without the per-process restriction of the public API. +/// +/// The public IVirtualDesktopManager::MoveWindowToDesktop returns E_ACCESSDENIED +/// (0x80070005) for windows that don't belong to the calling process. +/// This helper uses the shell's internal IVirtualDesktopManagerInternal which +/// bypasses that restriction. +/// +/// Each Windows build changes the IVirtualDesktopManagerInternal IID and +/// may insert/remove vtable methods. We declare a separate [ComImport] interface +/// per build family so the CLR handles vtable dispatch safely. If the IID +/// doesn't match, QueryService fails cleanly instead of crashing. +/// +internal static class VirtualDesktopMoveHelper +{ + // ── Stable GUIDs ── + + private static readonly Guid CLSID_ImmersiveShell = + new("C2F03A33-21F5-47FA-B4BB-156362A2F239"); + + private static readonly Guid GUID_VdmInternalService = + new("C5E0CDCA-7B6E-41B2-9FC4-D93975CC467B"); + + private static readonly Guid GUID_AppViewCollection = + new("1841C6D7-4F9D-42C0-AF41-8747538F10E5"); + + // ── Stable COM helpers ── + + [ComImport, Guid("6D5140C1-7436-11CE-8034-00AA006009FA")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IServiceProvider10 + { + [PreserveSig] + int QueryService(ref Guid guidService, ref Guid riid, + [MarshalAs(UnmanagedType.IUnknown)] out object? ppvObject); + } + + // ── IApplicationViewCollection (stable across 21H2 – 24H2) ── + + [ComImport, Guid("1841C6D7-4F9D-42C0-AF41-8747538F10E5")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IApplicationViewCollection + { + [PreserveSig] int _Stub_GetViews(); // slot 3 + [PreserveSig] int _Stub_GetViewsByZOrder(); // slot 4 + [PreserveSig] int _Stub_GetViewsByAppUserModelId();// slot 5 + [PreserveSig] int GetViewForHwnd(nint hwnd, out nint ppView); // slot 6 + } + + // ── IVirtualDesktopManagerInternal — 24H2 (Build 26100.2033+) ── + // + // Vtable layout (methods start at IUnknown slot 3): + // 3: GetCount + // 4: MoveViewToDesktop(view, desktop) + // 5: CanViewMoveDesktops + // 6: GetCurrentDesktop + // 7: GetAllCurrentDesktops ← NEW in 24H2 + // 8: GetDesktops + // 9: GetAdjacentDesktop + // 10: SwitchDesktop + // 11: SwitchDesktopAndMoveForegroundView ← added in 26100.2033 (KB5044384) + // 12: CreateDesktop + // 13: MoveDesktop + // 14: RemoveDesktop + // 15: FindDesktop(ref Guid, out desktop) + + [ComImport, Guid("53F5CA0B-158F-4124-900C-057158060B27")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IVdmInternal_24H2 + { + [PreserveSig] int _GetCount(); // 3 + [PreserveSig] int MoveViewToDesktop(nint pView, nint pDesktop); // 4 + [PreserveSig] int _CanViewMoveDesktops(); // 5 + [PreserveSig] int _GetCurrentDesktop(); // 6 + [PreserveSig] int _GetAllCurrentDesktops(); // 7 (24H2 only) + [PreserveSig] int _GetDesktops(); // 8 + [PreserveSig] int _GetAdjacentDesktop(); // 9 + [PreserveSig] int _SwitchDesktop(); // 10 + [PreserveSig] int _SwitchDesktopAndMoveForegroundView(); // 11 (26100.2033+) + [PreserveSig] int _CreateDesktop(); // 12 + [PreserveSig] int _MoveDesktop(); // 13 + [PreserveSig] int _RemoveDesktop(); // 14 + [PreserveSig] int FindDesktop(ref Guid desktopId, out nint ppDesktop); // 15 + } + + // ── IVirtualDesktopManagerInternal — 22H2 / 23H2 (Build 22621–22631) ── + // + // Same layout minus GetAllCurrentDesktops, so FindDesktop is at slot 13. + + [ComImport, Guid("B2F925B9-5A0F-4D2E-9F4D-2B1507593C10")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IVdmInternal_22H2 + { + [PreserveSig] int _GetCount(); // 3 + [PreserveSig] int MoveViewToDesktop(nint pView, nint pDesktop); // 4 + [PreserveSig] int _CanViewMoveDesktops(); // 5 + [PreserveSig] int _GetCurrentDesktop(); // 6 + // NO GetAllCurrentDesktops in 22H2 + [PreserveSig] int _GetDesktops(); // 7 + [PreserveSig] int _GetAdjacentDesktop(); // 8 + [PreserveSig] int _SwitchDesktop(); // 9 + [PreserveSig] int _CreateDesktop(); // 10 + [PreserveSig] int _MoveDesktop(); // 11 + [PreserveSig] int _RemoveDesktop(); // 12 + [PreserveSig] int FindDesktop(ref Guid desktopId, out nint ppDesktop); // 13 + } + + // ── Public API ── + + /// + /// Move a window to the specified virtual desktop using undocumented internal COM. + /// Tries each known build config; returns true on the first successful move. + /// + /// + /// All COM operations are dispatched to a dedicated MTA thread. + /// WPF runs on STA, and the shell's internal interfaces have no registered + /// proxy/stub — calling through a cross-apartment proxy causes + /// AccessViolationException. Running in MTA avoids the proxy entirely. + /// + public static bool TryMoveWindowToDesktop(nint hwnd, Guid desktopId, ILogger log) + { + bool result = false; + Exception? threadEx = null; + + var thread = new Thread(() => + { + try + { + result = TryMoveCore(hwnd, desktopId, log); + } + catch (Exception ex) + { + threadEx = ex; + } + }) + { + IsBackground = true, + Name = "VDMoveHelper-MTA", + }; + thread.SetApartmentState(ApartmentState.MTA); + thread.Start(); + thread.Join(); + + if (threadEx != null) + log.Warning(threadEx, "VDMoveHelper: MTA thread error"); + + return result; + } + + /// + /// Core implementation — MUST run on an MTA thread. + /// + private static bool TryMoveCore(nint hwnd, Guid desktopId, ILogger log) + { + object? shellObj = null; + object? avcObj = null; + + try + { + var shellType = Type.GetTypeFromCLSID(CLSID_ImmersiveShell); + if (shellType == null) + { + log.Debug("VDMoveHelper: ImmersiveShell CLSID not registered"); + return false; + } + + shellObj = Activator.CreateInstance(shellType); + if (shellObj is not IServiceProvider10 sp) + { + log.Debug("VDMoveHelper: shell does not implement IServiceProvider"); + return false; + } + + // 1. Get IApplicationViewCollection + var guidAvc = GUID_AppViewCollection; + int hr = sp.QueryService(ref guidAvc, ref guidAvc, out avcObj); + if (hr != 0 || avcObj is not IApplicationViewCollection avc) + { + log.Debug("VDMoveHelper: IApplicationViewCollection not available, hr=0x{Hr:X8}", hr); + return false; + } + + // 2. Get the IApplicationView for the target window + hr = avc.GetViewForHwnd(hwnd, out nint pView); + if (hr != 0 || pView == 0) + { + log.Debug("VDMoveHelper: GetViewForHwnd failed, hr=0x{Hr:X8}", hr); + return false; + } + + try + { + // 3. Try 24H2 first, then 22H2 + if (TryWith24H2(sp, pView, desktopId, log)) + return true; + if (TryWith22H2(sp, pView, desktopId, log)) + return true; + } + finally + { + Marshal.Release(pView); + } + + log.Warning("VDMoveHelper: no matching internal interface for this Windows build"); + return false; + } + catch (InvalidCastException) + { + log.Warning("VDMoveHelper: COM interface cast failed — unsupported build"); + return false; + } + catch (COMException ex) + { + log.Warning("VDMoveHelper: COM error 0x{Hr:X8}", ex.HResult); + return false; + } + catch (Exception ex) + { + log.Warning(ex, "VDMoveHelper: unexpected error"); + return false; + } + finally + { + if (avcObj != null) Marshal.ReleaseComObject(avcObj); + if (shellObj != null) Marshal.ReleaseComObject(shellObj); + } + } + + // ── Build-specific helpers ── + + private static bool TryWith24H2(IServiceProvider10 sp, nint pView, Guid desktopId, ILogger log) + { + object? vdmObj = null; + try + { + var svc = GUID_VdmInternalService; + var iid = typeof(IVdmInternal_24H2).GUID; + if (sp.QueryService(ref svc, ref iid, out vdmObj) != 0 || vdmObj == null) + return false; + + if (vdmObj is not IVdmInternal_24H2 vdm) + return false; + + log.Debug("VDMoveHelper: matched 24H2 interface"); + return DoMove(vdm, pView, desktopId, log); + } + catch (InvalidCastException) + { + return false; + } + finally + { + if (vdmObj != null) Marshal.ReleaseComObject(vdmObj); + } + } + + private static bool TryWith22H2(IServiceProvider10 sp, nint pView, Guid desktopId, ILogger log) + { + object? vdmObj = null; + try + { + var svc = GUID_VdmInternalService; + var iid = typeof(IVdmInternal_22H2).GUID; + if (sp.QueryService(ref svc, ref iid, out vdmObj) != 0 || vdmObj == null) + return false; + + if (vdmObj is not IVdmInternal_22H2 vdm) + return false; + + log.Debug("VDMoveHelper: matched 22H2 interface"); + return DoMove(vdm, pView, desktopId, log); + } + catch (InvalidCastException) + { + return false; + } + finally + { + if (vdmObj != null) Marshal.ReleaseComObject(vdmObj); + } + } + + /// + /// FindDesktop + MoveViewToDesktop — generic over both build interfaces. + /// + private static bool DoMove(IVdmInternal_24H2 vdm, nint pView, Guid desktopId, ILogger log) + { + nint pDesktop = 0; + try + { + int hr = vdm.FindDesktop(ref desktopId, out pDesktop); + if (hr != 0 || pDesktop == 0) + { + log.Debug("VDMoveHelper: FindDesktop hr=0x{Hr:X8}", hr); + return false; + } + + hr = vdm.MoveViewToDesktop(pView, pDesktop); + if (hr != 0) + { + log.Debug("VDMoveHelper: MoveViewToDesktop hr=0x{Hr:X8}", hr); + return false; + } + + log.Debug("VDMoveHelper: moved window to desktop {Desktop}", desktopId); + return true; + } + finally + { + if (pDesktop != 0) Marshal.Release(pDesktop); + } + } + + private static bool DoMove(IVdmInternal_22H2 vdm, nint pView, Guid desktopId, ILogger log) + { + nint pDesktop = 0; + try + { + int hr = vdm.FindDesktop(ref desktopId, out pDesktop); + if (hr != 0 || pDesktop == 0) + { + log.Debug("VDMoveHelper: FindDesktop hr=0x{Hr:X8}", hr); + return false; + } + + hr = vdm.MoveViewToDesktop(pView, pDesktop); + if (hr != 0) + { + log.Debug("VDMoveHelper: MoveViewToDesktop hr=0x{Hr:X8}", hr); + return false; + } + + log.Debug("VDMoveHelper: moved window to desktop {Desktop}", desktopId); + return true; + } + finally + { + if (pDesktop != 0) Marshal.Release(pDesktop); + } + } +} diff --git a/src/WindowController.Win32/VirtualDesktopService.cs b/src/WindowController.Win32/VirtualDesktopService.cs new file mode 100644 index 0000000..097bf9a --- /dev/null +++ b/src/WindowController.Win32/VirtualDesktopService.cs @@ -0,0 +1,212 @@ +using System.Runtime.InteropServices; +using Microsoft.Win32; +using Serilog; + +namespace WindowController.Win32; + +/// +/// COM interface IVirtualDesktopManager (public, documented). +/// +[ComImport] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +[Guid("a5cd92ff-29be-454c-8d04-d82879fb3f1b")] +internal interface IVirtualDesktopManager +{ + [PreserveSig] + int IsWindowOnCurrentVirtualDesktop(nint topLevelWindow, out bool onCurrentDesktop); + + [PreserveSig] + int GetWindowDesktopId(nint topLevelWindow, out Guid desktopId); + + [PreserveSig] + int MoveWindowToDesktop(nint topLevelWindow, ref Guid desktopId); +} + +/// +/// Safe wrapper around IVirtualDesktopManager COM object. +/// All methods swallow COM / RPC errors and return safe defaults. +/// +public class VirtualDesktopService : IDisposable +{ + private static readonly Guid CLSID_VirtualDesktopManager = + new("aa509086-5ca9-4c25-8f95-589d3c07b48a"); + + private readonly ILogger _log; + private IVirtualDesktopManager? _manager; + private bool _disposed; + + public VirtualDesktopService(ILogger logger) + { + _log = logger; + try + { + var obj = Activator.CreateInstance( + Type.GetTypeFromCLSID(CLSID_VirtualDesktopManager)!); + _manager = (IVirtualDesktopManager?)obj; + } + catch (Exception ex) + { + _log.Warning(ex, "VirtualDesktopManager COM init failed — virtual desktop features will be unavailable"); + _manager = null; + } + } + + /// Whether the underlying COM object was created successfully. + public bool IsAvailable => _manager != null; + + /// + /// Get the virtual desktop GUID for a window. Returns null on failure. + /// + public Guid? GetWindowDesktopId(nint hwnd) + { + if (_manager == null) return null; + try + { + int hr = _manager.GetWindowDesktopId(hwnd, out var id); + return hr == 0 ? id : null; + } + catch (Exception ex) + { + _log.Debug(ex, "GetWindowDesktopId failed for hwnd {Hwnd}", hwnd); + return null; + } + } + + /// + /// Check if a window is on the current virtual desktop. Returns null on failure. + /// + public bool? IsWindowOnCurrentDesktop(nint hwnd) + { + if (_manager == null) return null; + try + { + int hr = _manager.IsWindowOnCurrentVirtualDesktop(hwnd, out var onCurrent); + return hr == 0 ? onCurrent : null; + } + catch (Exception ex) + { + _log.Debug(ex, "IsWindowOnCurrentVirtualDesktop failed for hwnd {Hwnd}", hwnd); + return null; + } + } + + /// + /// Move a window to another virtual desktop. Returns true on success. + /// Falls back to undocumented internal COM when the public API returns + /// E_ACCESSDENIED (cross-process restriction). + /// + public bool MoveWindowToDesktop(nint hwnd, Guid desktopId) + { + if (_manager == null) return false; + try + { + int hr = _manager.MoveWindowToDesktop(hwnd, ref desktopId); + if (hr == 0) return true; + + // Public API only works for same-process windows. + // For cross-process windows, fall back to the internal COM helper. + if (hr == unchecked((int)0x80070005)) // E_ACCESSDENIED + { + _log.Debug("MoveWindowToDesktop E_ACCESSDENIED for hwnd {Hwnd} — trying internal COM", hwnd); + return VirtualDesktopMoveHelper.TryMoveWindowToDesktop(hwnd, desktopId, _log); + } + + _log.Warning("MoveWindowToDesktop failed for hwnd {Hwnd}, hr=0x{Hr:X8}", hwnd, hr); + return false; + } + catch (Exception ex) + { + _log.Warning(ex, "MoveWindowToDesktop failed for hwnd {Hwnd}", hwnd); + return false; + } + } + + /// + /// Get the desktop Id of the "current" desktop by probing a known hwnd + /// (typically the app's own main window). + /// + public Guid? GetCurrentDesktopId(nint appHwnd) + { + return GetWindowDesktopId(appHwnd); + } + + /// + /// Check if a window is "cloaked" (DWM), which usually means it's on another virtual desktop + /// or is a hidden Store app. + /// + public static bool IsWindowCloaked(nint hwnd) + { + int hr = NativeMethods.DwmGetWindowAttribute(hwnd, NativeMethods.DWMWA_CLOAKED, out int cloaked, sizeof(int)); + return hr == 0 && cloaked != 0; + } + + // ── Registry-based desktop enumeration ── + + private const string VdRegRoot = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VirtualDesktops"; + + /// + /// Information about a single virtual desktop. + /// + public record VirtualDesktopInfo(Guid Id, int Number, string Name); + + /// + /// Enumerate all existing virtual desktops via the registry. + /// Returns them in order. Name is the user-assigned name (empty string if not renamed). + /// + public List GetAllDesktops() + { + var result = new List(); + try + { + using var key = Registry.CurrentUser.OpenSubKey(VdRegRoot); + if (key == null) return result; + + // VirtualDesktopIDs is a REG_BINARY containing concatenated 16-byte GUIDs. + var raw = key.GetValue("VirtualDesktopIDs") as byte[]; + if (raw == null || raw.Length < 16) return result; + + int count = raw.Length / 16; + for (int i = 0; i < count; i++) + { + var guid = new Guid(raw.AsSpan(i * 16, 16)); + var name = GetDesktopName(guid); + result.Add(new VirtualDesktopInfo(guid, i + 1, name)); + } + } + catch (Exception ex) + { + _log.Warning(ex, "GetAllDesktops registry read failed"); + } + return result; + } + + /// + /// Read the user-assigned name for a desktop from the registry. + /// Returns empty string if not set. + /// + private static string GetDesktopName(Guid desktopId) + { + try + { + var subPath = $@"{VdRegRoot}\Desktops\{{{desktopId}}}"; + using var key = Registry.CurrentUser.OpenSubKey(subPath); + return key?.GetValue("Name") as string ?? ""; + } + catch + { + return ""; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_manager != null) + { + Marshal.ReleaseComObject(_manager); + _manager = null; + } + } +} diff --git a/src/WindowController.Win32/WindowArranger.cs b/src/WindowController.Win32/WindowArranger.cs index f70a481..c352ba5 100644 --- a/src/WindowController.Win32/WindowArranger.cs +++ b/src/WindowController.Win32/WindowArranger.cs @@ -4,53 +4,184 @@ namespace WindowController.Win32; +/// +/// Result of a single window arrange operation (for warning/reporting). +/// +public class ArrangeResult +{ + public bool Applied { get; init; } + public MonitorTransformResult? MonitorTransform { get; init; } +} + /// /// Restore window position/size/state. +/// Priority: snap → rectNormalized → absolute rect. +/// All paths finish with a work-area clamp to prevent off-screen placement. /// public class WindowArranger { private readonly ILogger _log; + private readonly Settings _settings; - public WindowArranger(ILogger logger) + public WindowArranger(ILogger logger, Settings settings) { _log = logger; + _settings = settings; } /// /// Arrange a window according to the saved entry. + /// Returns an ArrangeResult with monitor-transform warnings (if any). /// - public void Arrange(nint hwnd, WindowEntry entry) + public ArrangeResult Arrange(nint hwnd, WindowEntry entry, MonitorData? forceMonitor = null) { if (!NativeMethods.IsWindow(hwnd)) - return; + return new ArrangeResult { Applied = false }; + + var monitors = MonitorHelper.GetMonitors(); + + // --- Resolve target monitor --- + MonitorData targetMon; + MonitorTransformResult? transform = null; + + if (forceMonitor != null) + { + targetMon = forceMonitor; + + // User explicitly chose this monitor — still evaluate for warnings + // but downgrade Deny → Warn so the choice is always honoured. + bool isExactForce = entry.Monitor != null + && !string.IsNullOrEmpty(entry.Monitor.Name) + && entry.Monitor.Name == forceMonitor.DeviceName; + + transform = MonitorTransformDecision.Evaluate( + entry.Monitor, + forceMonitor.PixelWidth, + forceMonitor.PixelHeight, + isExactForce, + _settings); + + // Downgrade Deny to Warn — user explicitly picked this monitor + if (transform.Level == MonitorTransformLevel.Deny) + { + transform = new MonitorTransformResult + { + Level = MonitorTransformLevel.Warn, + Reasons = transform.Reasons + .Select(r => new MonitorTransformReason(MonitorTransformLevel.Warn, r.Message)) + .ToList() + }; + } + } + else + { + bool isLegacyProfile = entry.Monitor == null + && entry.RectNormalized == null; + bool isExact; + + if (isLegacyProfile) + { + var fromRect = MonitorHelper.GetMonitorForRect( + entry.Rect.X, entry.Rect.Y, entry.Rect.W, entry.Rect.H); + targetMon = fromRect ?? monitors.FirstOrDefault() ?? new MonitorData(); + isExact = fromRect != null; + } + else + { + (targetMon, isExact) = MonitorHelper.ResolveMonitor(entry.Monitor, monitors); + } + + transform = MonitorTransformDecision.Evaluate( + entry.Monitor, + targetMon.PixelWidth, + targetMon.PixelHeight, + isExact, + _settings); + + if (transform.Level == MonitorTransformLevel.Deny) + { + _log.Warning("Arrange denied for {Exe}: {Reasons}", + entry.Match.Exe, string.Join("; ", transform.Reasons.Select(r => r.Message))); + return new ArrangeResult { Applied = false, MonitorTransform = transform }; + } + } int x, y, w, h; + var wa = targetMon.WorkArea; - // If snap info is available, recalculate from current monitor's work area - if (entry.Snap is { Type: var snapType } && !string.IsNullOrEmpty(snapType)) + // Priority 1: Snap + if (entry.Snap is { Type: var snapType } && !string.IsNullOrEmpty(snapType) && wa.Width > 0 && wa.Height > 0) { - var wa = GetWorkAreaForEntry(entry); - if (wa != null) + var snapRect = SnapCalculator.RectFromSnap(wa, snapType); + if (snapRect != null) { - var snapRect = SnapCalculator.RectFromSnap(wa, snapType); - if (snapRect != null) + x = snapRect.X; y = snapRect.Y; w = snapRect.W; h = snapRect.H; + ApplyRect(hwnd, x, y, w, h, entry.MinMax); + return new ArrangeResult { Applied = true, MonitorTransform = transform }; + } + } + + // Priority 2: Normalized rect + // Use when: (a) forcing to a different monitor, or (b) resolution differs + bool useNormalized; + if (forceMonitor != null) + { + useNormalized = true; + } + else + { + useNormalized = entry.Monitor != null + && (entry.Monitor.PixelWidth != targetMon.PixelWidth + || entry.Monitor.PixelHeight != targetMon.PixelHeight); + } + + if (useNormalized && wa.Width > 0 && wa.Height > 0) + { + NormalizedRect? norm = entry.RectNormalized; + + // If no stored normalized rect, compute on-the-fly from absolute rect + if (norm == null) + { + var origWa = ResolveOriginalWorkArea(entry, monitors); + if (origWa is { Width: > 0, Height: > 0 }) { - x = snapRect.X; - y = snapRect.Y; - w = snapRect.W; - h = snapRect.H; - ApplyRect(hwnd, x, y, w, h, entry.MinMax); - return; + norm = NormalizedRect.FromAbsolute( + entry.Rect.X, entry.Rect.Y, entry.Rect.W, entry.Rect.H, origWa); } } + + if (norm != null) + { + var absRect = norm.ToAbsolute(wa); + x = absRect.X; y = absRect.Y; w = absRect.W; h = absRect.H; + Clamp(ref x, ref y, ref w, ref h, wa); + ApplyRect(hwnd, x, y, w, h, entry.MinMax); + return new ArrangeResult { Applied = true, MonitorTransform = transform }; + } } - // Fall back to saved rect - x = entry.Rect.X; - y = entry.Rect.Y; - w = entry.Rect.W; - h = entry.Rect.H; + // Priority 3: Absolute rect (same-resolution or legacy) + x = entry.Rect.X; y = entry.Rect.Y; w = entry.Rect.W; h = entry.Rect.H; + if (wa.Width > 0 && wa.Height > 0) + Clamp(ref x, ref y, ref w, ref h, wa); ApplyRect(hwnd, x, y, w, h, entry.MinMax); + return new ArrangeResult { Applied = true, MonitorTransform = transform }; + } + + /// + /// Resolve the original monitor's work area from the entry. + /// Used to compute NormalizedRect on-the-fly when forcing a different monitor. + /// + private static WorkArea? ResolveOriginalWorkArea(WindowEntry entry, List monitors) + { + if (entry.Monitor != null) + { + var (mon, _) = MonitorHelper.ResolveMonitor(entry.Monitor, monitors); + if (mon.WorkArea.Width > 0) return mon.WorkArea; + } + var fromRect = MonitorHelper.GetMonitorForRect( + entry.Rect.X, entry.Rect.Y, entry.Rect.W, entry.Rect.H); + return fromRect?.WorkArea; } private void ApplyRect(nint hwnd, int x, int y, int w, int h, int targetState) @@ -77,27 +208,31 @@ private void ApplyRect(nint hwnd, int x, int y, int w, int h, int targetState) } /// - /// Get work area for the monitor specified in the entry. + /// Clamp rect so that it stays (mostly) within the work area. + /// Allows a small overflow margin to accommodate DWM invisible borders + /// (drop shadows) which extend ~7 px beyond the visible window edge + /// on Windows 10/11. /// - private WorkArea? GetWorkAreaForEntry(WindowEntry entry) + internal const int DwmFrameMargin = 10; + + private static void Clamp(ref int x, ref int y, ref int w, ref int h, WorkArea wa) { - var monitors = MonitorHelper.GetMonitors(); - if (monitors.Count == 0) - return null; + const int MinVisible = 100; - // Try by name first - if (entry.Monitor is { Name: var name } && !string.IsNullOrEmpty(name)) - { - var byName = monitors.FirstOrDefault(m => m.DeviceName == name); - if (byName != null) - return byName.WorkArea; - } + // Ensure minimum size + if (w < MinVisible) w = MinVisible; + if (h < MinVisible) h = MinVisible; - // Try by index - if (entry.Monitor is { Index: var idx } && idx >= 1 && idx <= monitors.Count) - return monitors[idx - 1].WorkArea; + // Clamp size to work area + margin (DWM border can exceed work area) + int maxW = wa.Width + 2 * DwmFrameMargin; + int maxH = wa.Height + 2 * DwmFrameMargin; + if (w > maxW) w = maxW; + if (h > maxH) h = maxH; - // Fallback: primary - return monitors[0].WorkArea; + // Clamp position (allow DWM border overflow) + if (x < wa.Left - DwmFrameMargin) x = wa.Left - DwmFrameMargin; + if (y < wa.Top - DwmFrameMargin) y = wa.Top - DwmFrameMargin; + if (x + w > wa.Right + DwmFrameMargin) x = wa.Right + DwmFrameMargin - w; + if (y + h > wa.Bottom + DwmFrameMargin) y = wa.Bottom + DwmFrameMargin - h; } } From 8a5c54ad2cb40d73f61053b0ed2913f526915d39 Mon Sep 17 00:00:00 2001 From: hu-ja-ja Date: Sat, 14 Feb 2026 19:09:05 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E3=82=A6=E3=82=A3=E3=83=B3=E3=83=89?= =?UTF-8?q?=E3=82=A6=E6=9E=A0=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/WindowController.App/MonitorPickerWindow.xaml | 3 ++- src/WindowController.App/MonitorWarningDialog.xaml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/WindowController.App/MonitorPickerWindow.xaml b/src/WindowController.App/MonitorPickerWindow.xaml index 2809fb5..e4b765f 100644 --- a/src/WindowController.App/MonitorPickerWindow.xaml +++ b/src/WindowController.App/MonitorPickerWindow.xaml @@ -4,6 +4,7 @@ xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="モニターを選択して配置" Width="440" SizeToContent="Height" + MinHeight="0" WindowStartupLocation="Manual" ResizeMode="NoResize" Topmost="True" @@ -13,7 +14,7 @@ - + - + Date: Sat, 14 Feb 2026 23:45:58 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=EF=BE=98=EF=BE=8C=EF=BD=A7=EF=BD=B8?= =?UTF-8?q?=EF=BE=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/WindowController.App/HotkeyManager.cs | 3 + src/WindowController.App/ProfileApplier.cs | 36 +++++++++- src/WindowController.App/SyncManager.cs | 71 ++++++++++++------- src/WindowController.Core/AppSettingsStore.cs | 3 +- src/WindowController.Core/PathNormalizer.cs | 36 ++++++++-- src/WindowController.Core/ProfileStore.cs | 15 ++-- .../VirtualDesktopMoveHelper.cs | 53 +++++--------- src/WindowController.Win32/WindowArranger.cs | 10 +-- .../WindowEnumerator.cs | 3 +- 9 files changed, 148 insertions(+), 82 deletions(-) diff --git a/src/WindowController.App/HotkeyManager.cs b/src/WindowController.App/HotkeyManager.cs index 0a60bcc..c653aa5 100644 --- a/src/WindowController.App/HotkeyManager.cs +++ b/src/WindowController.App/HotkeyManager.cs @@ -157,7 +157,10 @@ public HotkeyRegistrationResult TestHotkey(HotkeyBinding binding) // Try to register with a temporary ID that will not collide with real hotkey IDs. // Use unique IDs to avoid cross-thread interference. + // Guard against wraparound into the profile hotkey ID range. var testId = unchecked(_nextTestHotkeyId--); + if (_nextTestHotkeyId <= HOTKEY_ID_PROFILE_BASE + 100_000) + _nextTestHotkeyId = int.MaxValue; var result = NativeMethods.RegisterHotKey(hwnd, testId, modifiers | NativeMethods.MOD_NOREPEAT, (uint)vkCode); if (result) { diff --git a/src/WindowController.App/ProfileApplier.cs b/src/WindowController.App/ProfileApplier.cs index 1349a77..97943d0 100644 --- a/src/WindowController.App/ProfileApplier.cs +++ b/src/WindowController.App/ProfileApplier.cs @@ -150,6 +150,31 @@ private async Task ApplyProfileAsync(Profile profile, bool launchMi return new ApplyResult(applied, profile.Windows.Count, failures, warnings); } + // Extensions blocked from being launched via UseShellExecute to prevent + // arbitrary script execution through malicious profile data. + private static readonly HashSet BlockedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".bat", ".cmd", ".ps1", ".psm1", ".psd1", + ".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh", + ".msi", ".msp", ".scr", ".hta", ".inf", ".reg", + ".com", ".pif" + }; + + private const int LaunchTimeoutMs = 12_000; + private const int LaunchPollIntervalMs = 300; + + /// + /// Returns true if the path points to a genuine executable (.exe) and not + /// a script or installer that could be used for arbitrary code execution. + /// + private static bool IsAllowedLaunchPath(string filePath) + { + var ext = Path.GetExtension(filePath); + if (BlockedExtensions.Contains(ext)) + return false; + return true; + } + private async Task LaunchAndWaitAsync(WindowEntry entry, List existingCandidates) { var exe = entry.Match.Exe; @@ -164,6 +189,13 @@ private async Task LaunchAndWaitAsync(WindowEntry entry, List LaunchAndWaitAsync(WindowEntry entry, List _lastMinMaxByHwnd = new(); private Dictionary _lastForegroundByProfile = new(); private Dictionary _lastForegroundTickByProfile = new(); - private bool _isPropagating; + private volatile bool _isPropagating; private long _lastRebuildTick; private bool _disposed; + // Protects _syncGroups, _profileNames, _lastMinMaxByHwnd, + // _lastForegroundByProfile, _lastForegroundTickByProfile against + // concurrent access from WinEvent callbacks and ScheduleRebuild (Task.Run). + private readonly object _syncLock = new(); + // Debounce rebuild: avoid multiple rapid rebuilds private CancellationTokenSource? _rebuildCts; private readonly object _rebuildLock = new(); @@ -36,6 +41,9 @@ public class SyncManager : IDisposable private long _lastEventTick; private const long EventThrottleMs = 30; + private const long ForegroundDebounceMs = 250; + private const long RebuildCooldownMs = 2000; + public SyncManager(ProfileStore store, WindowEnumerator enumerator, WinEventHookManager hookManager, ILogger log) { @@ -74,9 +82,12 @@ public void RebuildGroups() } } - _syncGroups = newGroups; - _profileNames = newNames; - _lastRebuildTick = Environment.TickCount64; + lock (_syncLock) + { + _syncGroups = newGroups; + _profileNames = newNames; + _lastRebuildTick = Environment.TickCount64; + } } /// @@ -117,9 +128,12 @@ public void UpdateHooksIfNeeded(bool skipRebuild = false) else { _hookManager.Uninstall(); - _syncGroups.Clear(); - _profileNames.Clear(); - _lastMinMaxByHwnd.Clear(); + lock (_syncLock) + { + _syncGroups.Clear(); + _profileNames.Clear(); + _lastMinMaxByHwnd.Clear(); + } } } @@ -152,9 +166,12 @@ private void OnWinEvent(uint eventType, nint hwnd) if (!NativeMethods.IsWindow(hwnd)) return; var mm = WindowEnumerator.GetMinMax(hwnd); - if (_lastMinMaxByHwnd.TryGetValue(hwnd, out var prev) && prev == mm) - return; - _lastMinMaxByHwnd[hwnd] = mm; + lock (_syncLock) + { + if (_lastMinMaxByHwnd.TryGetValue(hwnd, out var prev) && prev == mm) + return; + _lastMinMaxByHwnd[hwnd] = mm; + } var groups = GetGroupsContainingHwnd(hwnd); if (groups.Count == 0) @@ -239,7 +256,7 @@ private void PropagateMinMax(string profileId, HashSet group, nint sourceH { NativeMethods.ShowWindow(target, NativeMethods.SW_RESTORE); } - _lastMinMaxByHwnd[target] = mm; + lock (_syncLock) { _lastMinMaxByHwnd[target] = mm; } count++; } catch (Exception ex) { _log.Debug(ex, "PropagateMinMax failed for target {Target}", target); } @@ -254,14 +271,17 @@ private void PropagateMinMax(string profileId, HashSet group, nint sourceH private void PropagateForeground(string profileId, HashSet group, nint sourceHwnd) { var now = Environment.TickCount64; - if (_lastForegroundTickByProfile.TryGetValue(profileId, out var lastTick) && - now - lastTick < 250 && - _lastForegroundByProfile.TryGetValue(profileId, out var lastHwnd) && - lastHwnd == sourceHwnd) - return; + lock (_syncLock) + { + if (_lastForegroundTickByProfile.TryGetValue(profileId, out var lastTick) && + now - lastTick < ForegroundDebounceMs && + _lastForegroundByProfile.TryGetValue(profileId, out var lastHwnd) && + lastHwnd == sourceHwnd) + return; - _lastForegroundTickByProfile[profileId] = now; - _lastForegroundByProfile[profileId] = sourceHwnd; + _lastForegroundTickByProfile[profileId] = now; + _lastForegroundByProfile[profileId] = sourceHwnd; + } int count = 0; foreach (var target in group) @@ -287,18 +307,21 @@ private void PropagateForeground(string profileId, HashSet group, nint sou private List<(string Id, HashSet Group)> GetGroupsContainingHwnd(nint hwnd) { - var result = new List<(string, HashSet)>(); - foreach (var (id, group) in _syncGroups) + lock (_syncLock) { - if (group.Contains(hwnd)) - result.Add((id, group)); + var result = new List<(string, HashSet)>(); + foreach (var (id, group) in _syncGroups) + { + if (group.Contains(hwnd)) + result.Add((id, group)); + } + return result; } - return result; } private void TryRebuild() { - if (Environment.TickCount64 - _lastRebuildTick > 2000) + if (Environment.TickCount64 - _lastRebuildTick > RebuildCooldownMs) ScheduleRebuild(50); } diff --git a/src/WindowController.Core/AppSettingsStore.cs b/src/WindowController.Core/AppSettingsStore.cs index 250198d..d7a63fb 100644 --- a/src/WindowController.Core/AppSettingsStore.cs +++ b/src/WindowController.Core/AppSettingsStore.cs @@ -15,8 +15,7 @@ public class AppSettingsStore private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly string _filePath; diff --git a/src/WindowController.Core/PathNormalizer.cs b/src/WindowController.Core/PathNormalizer.cs index 09cee1f..8d225a9 100644 --- a/src/WindowController.Core/PathNormalizer.cs +++ b/src/WindowController.Core/PathNormalizer.cs @@ -13,20 +13,44 @@ public static string Normalize(string? path) // Drive path: C:\\\\foo -> C:\foo if (path.Length >= 3 && char.IsLetter(path[0]) && path[1] == ':' && path[2] == '\\') { - while (path.Contains("\\\\")) - path = path.Replace("\\\\", "\\"); - return path; + return NormalizeBackslashes(path); } // UNC: \\\\server\\share -> \\server\share if (path.StartsWith("\\\\")) { var tail = path.TrimStart('\\'); - while (tail.Contains("\\\\")) - tail = tail.Replace("\\\\", "\\"); - return "\\\\" + tail; + return "\\\\" + NormalizeBackslashes(tail); } return path; } + + /// + /// Replace consecutive backslashes with a single backslash in one pass. + /// + private static string NormalizeBackslashes(string value) + { + // Fast-path: no double backslash present + if (!value.Contains("\\\\")) + return value; + + var sb = new System.Text.StringBuilder(value.Length); + bool prevWasBackslash = false; + foreach (var ch in value) + { + if (ch == '\\') + { + if (!prevWasBackslash) + sb.Append(ch); + prevWasBackslash = true; + } + else + { + sb.Append(ch); + prevWasBackslash = false; + } + } + return sb.ToString(); + } } diff --git a/src/WindowController.Core/ProfileStore.cs b/src/WindowController.Core/ProfileStore.cs index 05224e9..a4edf01 100644 --- a/src/WindowController.Core/ProfileStore.cs +++ b/src/WindowController.Core/ProfileStore.cs @@ -14,8 +14,7 @@ public class ProfileStore { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = null, // respect JsonPropertyName attributes - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + PropertyNamingPolicy = null // respect JsonPropertyName attributes }; private string _filePath; @@ -62,12 +61,12 @@ public void Load() { var json = File.ReadAllText(_filePath); var parsed = JsonSerializer.Deserialize(json, JsonOptions); - + // Detect migration need before normalization - bool needsMigration = parsed != null && parsed.Profiles.Any(p => - string.IsNullOrEmpty(p.Id) || + bool needsMigration = parsed != null && parsed.Profiles.Any(p => + string.IsNullOrEmpty(p.Id) || parsed.Profiles.Count(x => x.Id == p.Id) > 1); - + var normalized = NormalizeData(parsed ?? new ProfilesRoot()); Data = normalized; @@ -156,11 +155,11 @@ public bool DeleteProfileById(string id) var trimmed = desiredName.Trim(); var finalName = ResolveUniqueName(trimmed, profileId); - + // Skip save if the name hasn't actually changed if (finalName == profile.Name) return finalName; - + profile.Name = finalName; profile.UpdatedAt = DateTime.Now.ToString("yyyy-MM-dd'T'HH:mm:ss"); Save(); diff --git a/src/WindowController.Win32/VirtualDesktopMoveHelper.cs b/src/WindowController.Win32/VirtualDesktopMoveHelper.cs index 734cd48..bf58538 100644 --- a/src/WindowController.Win32/VirtualDesktopMoveHelper.cs +++ b/src/WindowController.Win32/VirtualDesktopMoveHelper.cs @@ -113,6 +113,11 @@ private interface IVdmInternal_22H2 // ── Public API ── + /// + /// Maximum time to wait for the MTA thread to complete COM operations. + /// + private static readonly TimeSpan MtaThreadTimeout = TimeSpan.FromSeconds(5); + /// /// Move a window to the specified virtual desktop using undocumented internal COM. /// Tries each known build config; returns true on the first successful move. @@ -147,7 +152,7 @@ public static bool TryMoveWindowToDesktop(nint hwnd, Guid desktopId, ILogger log thread.Start(); // Avoid blocking indefinitely in case the shell COM call hangs. - var completed = thread.Join(TimeSpan.FromSeconds(5)); + var completed = thread.Join(MtaThreadTimeout); if (!completed) { log.Warning("VDMoveHelper: MTA thread did not complete within the timeout; aborting move"); @@ -255,7 +260,7 @@ private static bool TryWith24H2(IServiceProvider10 sp, nint pView, Guid desktopI return false; log.Debug("VDMoveHelper: matched 24H2 interface"); - return DoMove(vdm, pView, desktopId, log); + return DoMove(vdm.FindDesktop, vdm.MoveViewToDesktop, pView, desktopId, log); } catch (InvalidCastException) { @@ -281,7 +286,7 @@ private static bool TryWith22H2(IServiceProvider10 sp, nint pView, Guid desktopI return false; log.Debug("VDMoveHelper: matched 22H2 interface"); - return DoMove(vdm, pView, desktopId, log); + return DoMove(vdm.FindDesktop, vdm.MoveViewToDesktop, pView, desktopId, log); } catch (InvalidCastException) { @@ -293,50 +298,28 @@ private static bool TryWith22H2(IServiceProvider10 sp, nint pView, Guid desktopI } } + // Delegates matching the COM method signatures, shared between build-specific interfaces. + private delegate int FindDesktopFunc(ref Guid desktopId, out nint ppDesktop); + private delegate int MoveViewFunc(nint pView, nint pDesktop); + /// - /// FindDesktop + MoveViewToDesktop — generic over both build interfaces. + /// FindDesktop + MoveViewToDesktop — generic over both build interfaces + /// via delegates to avoid duplicating the same logic. /// - private static bool DoMove(IVdmInternal_24H2 vdm, nint pView, Guid desktopId, ILogger log) - { - nint pDesktop = 0; - try - { - int hr = vdm.FindDesktop(ref desktopId, out pDesktop); - if (hr != 0 || pDesktop == 0) - { - log.Debug("VDMoveHelper: FindDesktop hr=0x{Hr:X8}", hr); - return false; - } - - hr = vdm.MoveViewToDesktop(pView, pDesktop); - if (hr != 0) - { - log.Debug("VDMoveHelper: MoveViewToDesktop hr=0x{Hr:X8}", hr); - return false; - } - - log.Debug("VDMoveHelper: moved window to desktop {Desktop}", desktopId); - return true; - } - finally - { - if (pDesktop != 0) Marshal.Release(pDesktop); - } - } - - private static bool DoMove(IVdmInternal_22H2 vdm, nint pView, Guid desktopId, ILogger log) + private static bool DoMove(FindDesktopFunc findDesktop, MoveViewFunc moveView, + nint pView, Guid desktopId, ILogger log) { nint pDesktop = 0; try { - int hr = vdm.FindDesktop(ref desktopId, out pDesktop); + int hr = findDesktop(ref desktopId, out pDesktop); if (hr != 0 || pDesktop == 0) { log.Debug("VDMoveHelper: FindDesktop hr=0x{Hr:X8}", hr); return false; } - hr = vdm.MoveViewToDesktop(pView, pDesktop); + hr = moveView(pView, pDesktop); if (hr != 0) { log.Debug("VDMoveHelper: MoveViewToDesktop hr=0x{Hr:X8}", hr); diff --git a/src/WindowController.Win32/WindowArranger.cs b/src/WindowController.Win32/WindowArranger.cs index 772d121..c392339 100644 --- a/src/WindowController.Win32/WindowArranger.cs +++ b/src/WindowController.Win32/WindowArranger.cs @@ -191,11 +191,13 @@ public ArrangeResult Arrange(nint hwnd, WindowEntry entry, MonitorData? forceMon private void ApplyRect(nint hwnd, int x, int y, int w, int h, int targetState) { + const int RestoreSettleMs = 30; + try { // Restore first to allow positioning NativeMethods.ShowWindow(hwnd, NativeMethods.SW_RESTORE); - Thread.Sleep(30); + Thread.Sleep(RestoreSettleMs); // Set position and size NativeMethods.SetWindowPos(hwnd, 0, x, y, w, h, NativeMethods.SWP_NOZORDER | NativeMethods.SWP_NOACTIVATE); @@ -222,11 +224,11 @@ private void ApplyRect(nint hwnd, int x, int y, int w, int h, int targetState) private static void Clamp(ref int x, ref int y, ref int w, ref int h, WorkArea wa) { - const int MinVisible = 100; + const int MinVisibleSize = 100; // Ensure minimum size - if (w < MinVisible) w = MinVisible; - if (h < MinVisible) h = MinVisible; + if (w < MinVisibleSize) w = MinVisibleSize; + if (h < MinVisibleSize) h = MinVisibleSize; // Clamp size to work area + margin (DWM border can exceed work area) int maxW = wa.Width + 2 * DwmFrameMargin; diff --git a/src/WindowController.Win32/WindowEnumerator.cs b/src/WindowController.Win32/WindowEnumerator.cs index 7e56577..2b1d71b 100644 --- a/src/WindowController.Win32/WindowEnumerator.cs +++ b/src/WindowController.Win32/WindowEnumerator.cs @@ -36,6 +36,7 @@ public class WindowEnumerator // WMI command-line cache: PID → (commandLine, tickWhenCached) private readonly ConcurrentDictionary _cmdLineCache = new(); private const long CmdLineCacheTtlMs = 60_000; // 1 minute + private const int CmdLineCacheMaxEntries = 200; public WindowEnumerator(ILogger logger, Func? urlGetter = null) { @@ -259,7 +260,7 @@ public string GetCommandLineCached(uint pid) _cmdLineCache[pid] = (cmdLine, now); // Prune stale entries periodically - if (_cmdLineCache.Count > 200) + if (_cmdLineCache.Count > CmdLineCacheMaxEntries) { var staleKeys = _cmdLineCache .Where(kv => now - kv.Value.Tick > CmdLineCacheTtlMs * 2)