diff --git a/src/WindowController.App/App.xaml.cs b/src/WindowController.App/App.xaml.cs index 186c1d1..4356dce 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.ScheduleRebuild(), _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 @@ -176,7 +178,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) @@ -250,6 +258,7 @@ private void ExitApp() _log?.Information("Window-Controller exiting"); _hotkeyManager?.Dispose(); _syncManager?.Dispose(); + _vdService?.Dispose(); if (_trayIcon != null) { _trayIcon.Dispose(); @@ -263,6 +272,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..5722d75 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..9c35be9 --- /dev/null +++ b/src/WindowController.App/MonitorOverlayWindow.xaml.cs @@ -0,0 +1,53 @@ +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 owPx = 400, ohPx = 320; + + // WPF sizes are in DIPs; SetWindowPos expects device pixels. + // Set Width/Height so the physical size matches owPx/ohPx at current DPI. + var source = HwndSource.FromHwnd(hwnd); + var m = source?.CompositionTarget?.TransformToDevice; + var scaleX = m?.M11 ?? 1.0; + var scaleY = m?.M22 ?? 1.0; + if (scaleX <= 0) scaleX = 1.0; + if (scaleY <= 0) scaleY = 1.0; + Width = owPx / scaleX; + Height = ohPx / scaleY; + + int cx = _monX + (_monW - owPx) / 2; + int cy = _monY + (_monH - ohPx) / 2; + NativeMethods.SetWindowPos(hwnd, 0, cx, cy, owPx, ohPx, + 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..e4b765f --- /dev/null +++ b/src/WindowController.App/MonitorPickerWindow.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..47c4791 --- /dev/null +++ b/src/WindowController.App/MonitorWarningDialog.xaml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 1a747fb..1349a77 100644 --- a/src/WindowController.App/ProfileApplier.cs +++ b/src/WindowController.App/ProfileApplier.cs @@ -11,8 +11,13 @@ namespace WindowController.App; /// /// Result of a profile apply operation. /// -public record ApplyResult(int Applied, int Total, IReadOnlyList Failures) +public record ApplyResult(int Applied, int Total, IReadOnlyList Failures, IReadOnlyList Warnings) { + public ApplyResult(int applied, int total, IReadOnlyList failures) + : this(applied, total, failures, Array.Empty()) + { + } + public bool Success => Failures.Count == 0; public string ToStatusMessage(string profileName) @@ -20,6 +25,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; } } @@ -57,39 +64,44 @@ public ProfileApplier( /// /// 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 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 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 candidates = _candidatesProvider(); int applied = 0; var failures = new List(); + var warnings = new List(); + + if (appHwnd != 0 || targetDesktopId != null) + { + _log.Debug( + "ApplyProfileAsync called with appHwnd {AppHwnd} and targetDesktopId {TargetDesktopId}, but virtual-desktop-specific handling is not yet implemented.", + appHwnd, + targetDesktopId); + } foreach (var entry in profile.Windows) { @@ -109,7 +121,22 @@ private async Task ApplyProfileAsync(Profile profile, bool launchMi continue; } - _arranger.Arrange(hwnd, entry); + // --- 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) @@ -120,7 +147,7 @@ private async Task ApplyProfileAsync(Profile profile, bool launchMi } _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..2515313 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 for in-memory diagnostics; not serialized. + /// + [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..ba626f4 --- /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 or invalid, 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..dbb015b 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; @@ -53,6 +74,14 @@ public static List GetMonitors() public static MonitorData? GetMonitorForRect(int x, int y, int w, int h) { var monitors = GetMonitors(); + return GetMonitorForRect(monitors, x, y, w, h); + } + + /// + /// Find the monitor that contains the center of the given rect within an existing list. + /// + public static MonitorData? GetMonitorForRect(List monitors, int x, int y, int w, int h) + { if (monitors.Count == 0) return null; int cx = x + w / 2; @@ -68,4 +97,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..734cd48 --- /dev/null +++ b/src/WindowController.Win32/VirtualDesktopMoveHelper.cs @@ -0,0 +1,354 @@ +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(); + + // Avoid blocking indefinitely in case the shell COM call hangs. + var completed = thread.Join(TimeSpan.FromSeconds(5)); + if (!completed) + { + log.Warning("VDMoveHelper: MTA thread did not complete within the timeout; aborting move"); + return false; + } + + 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..772d121 100644 --- a/src/WindowController.Win32/WindowArranger.cs +++ b/src/WindowController.Win32/WindowArranger.cs @@ -4,53 +4,189 @@ 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) + : this(logger, new Settings()) + { + } + + 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( + monitors, 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; + + // Priority 1: Snap + if (entry.Snap is { Type: var snapType } && !string.IsNullOrEmpty(snapType) && wa.Width > 0 && wa.Height > 0) + { + 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 }; + } + } - // If snap info is available, recalculate from current monitor's work area - if (entry.Snap is { Type: var snapType } && !string.IsNullOrEmpty(snapType)) + // Priority 2: Normalized rect + // Use when: (a) forcing to a different monitor, or (b) resolution differs + bool useNormalized; + if (forceMonitor != null) + { + useNormalized = true; + } + else { - var wa = GetWorkAreaForEntry(entry); - if (wa != null) + 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 snapRect = SnapCalculator.RectFromSnap(wa, snapType); - if (snapRect != 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( + monitors, 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 +213,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; } }