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;
}
}