Initial commit to new repository
This commit is contained in:
332
src/AxCopilot/Core/ContextManager.cs
Normal file
332
src/AxCopilot/Core/ContextManager.cs
Normal file
@@ -0,0 +1,332 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user