333 lines
12 KiB
C#
333 lines
12 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using AxCopilot.Models;
|
|
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.Core;
|
|
|
|
/// <summary>
|
|
/// Windows 창의 HWND, Rect, 프로세스 경로를 수집하고 복원합니다 (The Shifter).
|
|
/// </summary>
|
|
public class ContextManager
|
|
{
|
|
private readonly SettingsService _settings;
|
|
|
|
public ContextManager(SettingsService settings)
|
|
{
|
|
_settings = settings;
|
|
|
|
// 모니터 구성 변경 감지는 MainWindow의 WndProc에서 WM_DISPLAYCHANGE를 통해 처리
|
|
}
|
|
|
|
// ─── Snapshot ────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 현재 화면에 열린 모든 업무용 창을 캡처하여 프로필로 저장합니다.
|
|
/// </summary>
|
|
public WorkspaceProfile CaptureProfile(string name)
|
|
{
|
|
var snapshots = new List<WindowSnapshot>();
|
|
var monitorMap = BuildMonitorMap();
|
|
|
|
EnumWindows((hWnd, _) =>
|
|
{
|
|
if (!IsWindowVisible(hWnd)) return true;
|
|
if (IsIconic(hWnd)) return true; // 최소화된 창 제외 여부는 설정으로 조정 가능
|
|
|
|
var title = GetWindowTitle(hWnd);
|
|
if (string.IsNullOrWhiteSpace(title)) return true;
|
|
|
|
// 작업표시줄, 바탕화면 등 시스템 창 제외
|
|
if (IsSystemWindow(hWnd)) return true;
|
|
|
|
var exePath = GetProcessPath(hWnd);
|
|
if (string.IsNullOrEmpty(exePath)) return true;
|
|
|
|
GetWindowRect(hWnd, out RECT rect);
|
|
GetWindowPlacement(hWnd, out WINDOWPLACEMENT placement);
|
|
|
|
var showCmd = placement.showCmd switch
|
|
{
|
|
1 => "Normal",
|
|
2 => "Minimized",
|
|
3 => "Maximized",
|
|
_ => "Normal"
|
|
};
|
|
|
|
int monitorIndex = GetMonitorIndex(hWnd, monitorMap);
|
|
|
|
snapshots.Add(new WindowSnapshot
|
|
{
|
|
Exe = exePath,
|
|
Title = title,
|
|
Rect = new WindowRect
|
|
{
|
|
X = rect.Left,
|
|
Y = rect.Top,
|
|
Width = rect.Right - rect.Left,
|
|
Height = rect.Bottom - rect.Top
|
|
},
|
|
ShowCmd = showCmd,
|
|
Monitor = monitorIndex
|
|
});
|
|
|
|
return true;
|
|
}, IntPtr.Zero);
|
|
|
|
var profile = new WorkspaceProfile
|
|
{
|
|
Name = name,
|
|
Windows = snapshots,
|
|
CreatedAt = DateTime.Now
|
|
};
|
|
|
|
// settings.json에 저장
|
|
var existing = _settings.Settings.Profiles.FirstOrDefault(p =>
|
|
p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
if (existing != null)
|
|
_settings.Settings.Profiles.Remove(existing);
|
|
_settings.Settings.Profiles.Add(profile);
|
|
_settings.Save();
|
|
|
|
LogService.Info($"프로필 '{name}' 저장 완료: {snapshots.Count}개 창");
|
|
return profile;
|
|
}
|
|
|
|
// ─── Restore ─────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 저장된 프로필을 복원합니다.
|
|
/// </summary>
|
|
public async Task<RestoreResult> RestoreProfileAsync(string name, CancellationToken ct = default)
|
|
{
|
|
var profile = _settings.Settings.Profiles
|
|
.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (profile == null)
|
|
return new RestoreResult(false, $"프로필 '{name}'을 찾을 수 없습니다.");
|
|
|
|
var results = new List<string>();
|
|
var monitorCount = GetMonitorCount();
|
|
|
|
foreach (var snapshot in profile.Windows)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
// 1. 실행 중인 창 찾기
|
|
var hWnd = FindMatchingWindow(snapshot);
|
|
|
|
// 2. 창이 없으면 EXE 실행 후 대기
|
|
if (hWnd == IntPtr.Zero && File.Exists(snapshot.Exe))
|
|
{
|
|
try
|
|
{
|
|
Process.Start(new ProcessStartInfo(snapshot.Exe) { UseShellExecute = true });
|
|
hWnd = await WaitForWindowAsync(snapshot.Exe, TimeSpan.FromSeconds(3), ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
results.Add($"⚠ {snapshot.Title}: 실행 실패 ({ex.Message})");
|
|
LogService.Warn($"앱 실행 실패: {snapshot.Exe} - {ex.Message}");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (hWnd == IntPtr.Zero)
|
|
{
|
|
results.Add($"⏭ {snapshot.Title}: 창 없음, 건너뜀");
|
|
continue;
|
|
}
|
|
|
|
// 3. 모니터 불일치 처리
|
|
if (snapshot.Monitor >= monitorCount)
|
|
{
|
|
var policy = _settings.Settings.MonitorMismatch;
|
|
if (policy == "skip")
|
|
{
|
|
results.Add($"⏭ {snapshot.Title}: 모니터 불일치, 건너뜀");
|
|
continue;
|
|
}
|
|
// "fit" 또는 "warn" → 첫 번째 모니터에 배치
|
|
}
|
|
|
|
// 4. 창 위치/크기 복원
|
|
try
|
|
{
|
|
ShowWindow(hWnd, snapshot.ShowCmd switch
|
|
{
|
|
"Maximized" => 3,
|
|
"Minimized" => 2,
|
|
_ => 9 // SW_RESTORE
|
|
});
|
|
|
|
if (snapshot.ShowCmd == "Normal")
|
|
{
|
|
SetWindowPos(hWnd, IntPtr.Zero,
|
|
snapshot.Rect.X, snapshot.Rect.Y,
|
|
snapshot.Rect.Width, snapshot.Rect.Height,
|
|
SWP_NOZORDER | SWP_NOACTIVATE);
|
|
}
|
|
|
|
results.Add($"✓ {snapshot.Title}: 복원 완료");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
results.Add($"⚠ {snapshot.Title}: 복원 실패 ({ex.Message})");
|
|
LogService.Warn($"창 복원 실패 (권한 문제 가능): {snapshot.Exe}");
|
|
}
|
|
}
|
|
|
|
LogService.Info($"프로필 '{name}' 복원: {string.Join(", ", results)}");
|
|
return new RestoreResult(true, string.Join("\n", results));
|
|
}
|
|
|
|
// ─── Profile Management ──────────────────────────────────────────────────
|
|
|
|
public bool DeleteProfile(string name)
|
|
{
|
|
var profile = _settings.Settings.Profiles
|
|
.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
if (profile == null) return false;
|
|
_settings.Settings.Profiles.Remove(profile);
|
|
_settings.Save();
|
|
return true;
|
|
}
|
|
|
|
public bool RenameProfile(string oldName, string newName)
|
|
{
|
|
var profile = _settings.Settings.Profiles
|
|
.FirstOrDefault(p => p.Name.Equals(oldName, StringComparison.OrdinalIgnoreCase));
|
|
if (profile == null) return false;
|
|
profile.Name = newName;
|
|
_settings.Save();
|
|
return true;
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
private static IntPtr FindMatchingWindow(WindowSnapshot snapshot)
|
|
{
|
|
IntPtr found = IntPtr.Zero;
|
|
EnumWindows((hWnd, _) =>
|
|
{
|
|
var path = GetProcessPath(hWnd);
|
|
if (string.Equals(path, snapshot.Exe, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
found = hWnd;
|
|
return false; // 첫 번째 매칭 창에서 중단
|
|
}
|
|
return true;
|
|
}, IntPtr.Zero);
|
|
return found;
|
|
}
|
|
|
|
private static async Task<IntPtr> WaitForWindowAsync(string exePath, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var hWnd = FindMatchingWindow(new WindowSnapshot { Exe = exePath });
|
|
if (hWnd != IntPtr.Zero) return hWnd;
|
|
await Task.Delay(200, ct);
|
|
}
|
|
return IntPtr.Zero;
|
|
}
|
|
|
|
private static string GetWindowTitle(IntPtr hWnd)
|
|
{
|
|
var sb = new StringBuilder(256);
|
|
GetWindowText(hWnd, sb, sb.Capacity);
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string GetProcessPath(IntPtr hWnd)
|
|
{
|
|
try
|
|
{
|
|
GetWindowThreadProcessId(hWnd, out uint pid);
|
|
if (pid == 0) return "";
|
|
var proc = Process.GetProcessById((int)pid);
|
|
return proc.MainModule?.FileName ?? "";
|
|
}
|
|
catch (Exception) { return ""; }
|
|
}
|
|
|
|
private static bool IsSystemWindow(IntPtr hWnd)
|
|
{
|
|
var cls = new StringBuilder(256);
|
|
GetClassName(hWnd, cls, cls.Capacity);
|
|
var name = cls.ToString();
|
|
return name is "Shell_TrayWnd" or "Progman" or "WorkerW" or "DV2ControlHost";
|
|
}
|
|
|
|
private static Dictionary<IntPtr, int> BuildMonitorMap()
|
|
{
|
|
var monitors = new List<IntPtr>();
|
|
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero,
|
|
(IntPtr hMonitor, IntPtr hdcMonitor, ref RECT lprc, IntPtr dwData) =>
|
|
{
|
|
monitors.Add(hMonitor);
|
|
return true;
|
|
}, IntPtr.Zero);
|
|
|
|
return monitors
|
|
.Select((hm, idx) => (hm, idx))
|
|
.ToDictionary(t => t.hm, t => t.idx);
|
|
}
|
|
|
|
private static int GetMonitorIndex(IntPtr hWnd, Dictionary<IntPtr, int> map)
|
|
{
|
|
var monitor = MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST);
|
|
return map.TryGetValue(monitor, out int idx) ? idx : 0;
|
|
}
|
|
|
|
private static int GetMonitorCount()
|
|
{
|
|
int count = 0;
|
|
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (IntPtr hMonitor, IntPtr hdcMonitor, ref RECT lprc, IntPtr dwData) => { count++; return true; }, IntPtr.Zero);
|
|
return count;
|
|
}
|
|
|
|
// ─── P/Invoke ────────────────────────────────────────────────────────────
|
|
|
|
private const uint SWP_NOZORDER = 0x0004;
|
|
private const uint SWP_NOACTIVATE = 0x0010;
|
|
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct RECT { public int Left, Top, Right, Bottom; }
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct WINDOWPLACEMENT
|
|
{
|
|
public uint length, flags;
|
|
public uint showCmd;
|
|
public POINT ptMinPosition, ptMaxPosition;
|
|
public RECT rcNormalPosition;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct POINT { public int x, y; }
|
|
|
|
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref RECT lprcMonitor, IntPtr dwData);
|
|
|
|
[DllImport("user32.dll")] private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
|
[DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hWnd);
|
|
[DllImport("user32.dll")] private static extern bool IsIconic(IntPtr hWnd);
|
|
[DllImport("user32.dll")] private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
|
|
[DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
|
[DllImport("user32.dll")] private static extern bool GetWindowPlacement(IntPtr hWnd, out WINDOWPLACEMENT lpwndpl);
|
|
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
|
[DllImport("user32.dll")] private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
|
|
[DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
|
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
|
[DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
|
|
[DllImport("user32.dll")] private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData);
|
|
}
|
|
|
|
public record RestoreResult(bool Success, string Message);
|