From e823ff83e3fb4b47d2eeacc9a43e251d60df469e Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 15 Apr 2026 17:28:22 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=B5=EC=9B=90=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EA=B3=A0=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 목적: ~ 워크스페이스 복원 시 창 배치뿐 아니라 브라우저 탭/URL 상태까지 가능한 범위에서 함께 재현하도록 저장/복원 경로를 확장한다. 핵심 수정사항: BrowserWorkspaceStateHelper를 추가해 Chromium/Firefox 계열 창의 프로필 인자, 탭 URL, 활성 탭 인덱스를 수집하고, ContextManager가 브라우저 상태가 저장된 창은 부분 제목 매칭으로 기존 창을 재사용하지 않고 새 브라우저 창을 띄워 동일한 URL 세트를 복원한 뒤 위치와 활성 탭을 맞추도록 변경했다. Launcher 설정에 브라우저 상태 복원 토글을 추가하고 SettingsViewModel 및 설정 UI와 연결했으며, ContextManagerTests와 SettingsServiceTests를 확장했다. README와 DEVELOPMENT 문서에도 2026-04-15 17:26 (KST) 기준 작업 이력과 검증 명령을 반영했다. 검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_browser_restore\ -p:IntermediateOutputPath=obj\verify_browser_restore\ 에서 경고 0/오류 0을 확인했고, dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter WorkspaceHandlerTests|ContextManagerTests|SettingsServiceTests -p:OutputPath=bin\verify_browser_restore_workspace_tests\ -p:IntermediateOutputPath=obj\verify_browser_restore_workspace_tests\ 에서 44개 테스트 통과를 확인했다. --- README.md | 5 + docs/DEVELOPMENT.md | 9 + .../Core/ContextManagerTests.cs | 92 +++ .../Services/SettingsServiceTests.cs | 44 ++ src/AxCopilot/AxCopilot.csproj | 2 +- .../Core/BrowserWorkspaceStateHelper.cs | 628 ++++++++++++++++++ src/AxCopilot/Core/ContextManager.cs | 121 +++- src/AxCopilot/Models/AppSettings.cs | 28 + src/AxCopilot/ViewModels/SettingsViewModel.cs | 9 + src/AxCopilot/Views/SettingsWindow.xaml | 13 + 10 files changed, 941 insertions(+), 10 deletions(-) create mode 100644 src/AxCopilot/Core/BrowserWorkspaceStateHelper.cs diff --git a/README.md b/README.md index 8859878..32e3981 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # AX Commander +- 업데이트: 2026-04-15 17:26 (KST) +- `~` 워크스페이스 저장/복원에 브라우저 상태 복원을 추가했습니다. `src/AxCopilot/Core/BrowserWorkspaceStateHelper.cs`가 Edge/Chrome/Whale/Brave/Opera/Firefox 창에서 프로필 인자와 탭 URL, 활성 탭 인덱스를 best-effort로 수집하고, `src/AxCopilot/Core/ContextManager.cs`가 브라우저 상태가 저장된 경우 새 창을 띄워 동일한 탭 묶음을 다시 열어 배치합니다. +- `src/AxCopilot/Models/AppSettings.cs`, `src/AxCopilot/ViewModels/SettingsViewModel.cs`, `src/AxCopilot/Views/SettingsWindow.xaml`에 `브라우저 상태 복원` 토글을 연결해 저장 시 탭 포커스 이동이 부담되는 환경에서는 기능을 끌 수 있게 했습니다. +- 테스트를 `src/AxCopilot.Tests/Core/ContextManagerTests.cs`, `src/AxCopilot.Tests/Services/SettingsServiceTests.cs`에 확장했고, `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_browser_restore\\ -p:IntermediateOutputPath=obj\\verify_browser_restore\\` 경고 0 / 오류 0, `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "WorkspaceHandlerTests|ContextManagerTests|SettingsServiceTests" -p:OutputPath=bin\\verify_browser_restore_workspace_tests\\ -p:IntermediateOutputPath=obj\\verify_browser_restore_workspace_tests\\` 44개 통과를 확인했습니다. + Windows 전용 시맨틱 런처 & 워크스페이스 매니저 > Alfred (macOS)에서 영감을 받아 Windows 환경에 최적화된 키보드 중심 생산성 도구입니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 9e5b5de..7e32d06 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -70,6 +70,15 @@ - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_status_narrative\\ -p:IntermediateOutputPath=obj\\verify_status_narrative\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentStatusNarrativeCatalogTests|AgentLoopIterationPreparationServiceTests|AgentToolResultBudgetTests|ChatStorageServiceTests|AgentMessageInvariantHelperTests" -p:OutputPath=bin\\verify_status_narrative_tests\\ -p:IntermediateOutputPath=obj\\verify_status_narrative_tests\\` 통과 15 +업데이트: 2026-04-15 17:26 (KST) +- 런처 `~` 워크스페이스에 브라우저 세션 복원 경로를 추가했습니다. `src/AxCopilot/Core/BrowserWorkspaceStateHelper.cs`가 Chromium 계열과 Firefox의 실행 프로필 인자, 현재 탭 URL 목록, 활성 탭 인덱스를 수집하고, `src/AxCopilot/Core/ContextManager.cs`가 브라우저 상태가 저장된 창은 부분 제목 매칭으로 기존 창을 재사용하지 않고 새 창을 띄워 동일한 URL 세트를 복원한 뒤 위치/크기와 활성 탭을 맞춥니다. +- 설정 토글도 함께 연결했습니다. `src/AxCopilot/Models/AppSettings.cs`에 `Launcher.EnableBrowserSessionRestore` 기본값 `true`를 추가하고, `src/AxCopilot/ViewModels/SettingsViewModel.cs`와 `src/AxCopilot/Views/SettingsWindow.xaml`에 `브라우저 상태 복원` 항목을 노출해 탭 전환 기반 캡처를 사용자가 제어할 수 있게 했습니다. +- 테스트는 `src/AxCopilot.Tests/Core/ContextManagerTests.cs`에 브라우저 실행 인자/새 창 실행 정책 케이스를, `src/AxCopilot.Tests/Services/SettingsServiceTests.cs`에 기본값/직렬화 라운드트립 케이스를 추가했습니다. +- 검증: +- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_browser_restore\\ -p:IntermediateOutputPath=obj\\verify_browser_restore\\` → 경고 0 / 오류 0 +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ContextManagerTests|SettingsServiceTests" -p:OutputPath=bin\\verify_browser_restore_tests\\ -p:IntermediateOutputPath=obj\\verify_browser_restore_tests\\` → 42개 통과 +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "WorkspaceHandlerTests|ContextManagerTests|SettingsServiceTests" -p:OutputPath=bin\\verify_browser_restore_workspace_tests\\ -p:IntermediateOutputPath=obj\\verify_browser_restore_workspace_tests\\` → 44개 통과 + 업데이트: 2026-04-14 19:50 (KST) 업데이트: 2026-04-15 15:45 (KST) - Cowork PPT 생성 경로를 특정 업종 전용 archetype이 아니라 공통 품질 루프로 강화했습니다. `src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs`와 `src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs`는 presentation/deck 요청에서 `document_plan`을 무조건 선행하지 않고, 계획 요청이 명시되지 않으면 `pptx_create`를 우선하도록 안내합니다. diff --git a/src/AxCopilot.Tests/Core/ContextManagerTests.cs b/src/AxCopilot.Tests/Core/ContextManagerTests.cs index 51bf600..adae478 100644 --- a/src/AxCopilot.Tests/Core/ContextManagerTests.cs +++ b/src/AxCopilot.Tests/Core/ContextManagerTests.cs @@ -62,4 +62,96 @@ public class ContextManagerTests selected.Should().Be(IntPtr.Zero); } + + [Fact] + public void BrowserLaunchPlan_CreatesChromiumWindowWithProfileAndTabs() + { + var state = new BrowserWindowState + { + Kind = "edge", + UserDataDir = @"C:\Users\tester\AppData\Local\Microsoft\Edge\User Data", + ProfileDirectory = "Profile 2", + TabUrls = ["https://example.com", "edge://settings/profiles"] + }; + + var plan = BrowserWorkspaceStateHelper.CreateLaunchPlan( + @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + state); + + plan.Should().NotBeNull(); + plan!.Arguments.Should().ContainInOrder( + "--new-window", + "--user-data-dir=C:\\Users\\tester\\AppData\\Local\\Microsoft\\Edge\\User Data", + "--profile-directory=Profile 2", + "https://example.com", + "edge://settings/profiles"); + } + + [Fact] + public void BrowserLaunchPlan_CreatesFirefoxWindowWithTabs() + { + var state = new BrowserWindowState + { + Kind = "firefox", + ProfileDirectory = "work-profile", + TabUrls = ["https://www.mozilla.org", "about:newtab"] + }; + + var plan = BrowserWorkspaceStateHelper.CreateLaunchPlan( + @"C:\Program Files\Mozilla Firefox\firefox.exe", + state); + + plan.Should().NotBeNull(); + plan!.Arguments.Should().ContainInOrder( + "-P", + "work-profile", + "-new-window", + "https://www.mozilla.org", + "-new-tab", + "about:newtab"); + } + + [Fact] + public void ShouldLaunchNewWindow_WithBrowserStateAndPartialTitleMatch_ReturnsTrue() + { + var snapshot = new WindowSnapshot + { + Exe = @"C:\Program Files\Google\Chrome\Application\chrome.exe", + Title = "Inbox - Google Chrome", + Browser = new BrowserWindowState + { + Kind = "chrome", + TabUrls = ["https://mail.google.com"] + } + }; + + var candidate = new ContextManager.WindowCandidate( + new IntPtr(5), + snapshot.Exe, + "Docs - Google Chrome"); + + BrowserWorkspaceStateHelper.ShouldLaunchNewWindow(snapshot, candidate).Should().BeTrue(); + } + + [Fact] + public void ShouldLaunchNewWindow_WithBrowserStateAndExactTitleMatch_ReturnsFalse() + { + var snapshot = new WindowSnapshot + { + Exe = @"C:\Program Files\Google\Chrome\Application\chrome.exe", + Title = "Inbox - Google Chrome", + Browser = new BrowserWindowState + { + Kind = "chrome", + TabUrls = ["https://mail.google.com"] + } + }; + + var candidate = new ContextManager.WindowCandidate( + new IntPtr(5), + snapshot.Exe, + "Inbox - Google Chrome"); + + BrowserWorkspaceStateHelper.ShouldLaunchNewWindow(snapshot, candidate).Should().BeFalse(); + } } diff --git a/src/AxCopilot.Tests/Services/SettingsServiceTests.cs b/src/AxCopilot.Tests/Services/SettingsServiceTests.cs index 92105d5..e5cf868 100644 --- a/src/AxCopilot.Tests/Services/SettingsServiceTests.cs +++ b/src/AxCopilot.Tests/Services/SettingsServiceTests.cs @@ -34,6 +34,12 @@ public class SettingsServiceTests opacity.Should().BeInRange(0.0, 1.0); } + [Fact] + public void LauncherSettings_DefaultBrowserSessionRestore_IsEnabled() + { + new LauncherSettings().EnableBrowserSessionRestore.Should().BeTrue(); + } + [Fact] public void AppSettings_DefaultMonitorMismatch_IsWarn() { @@ -200,6 +206,44 @@ public class SettingsServiceTests restored.Windows[0].Monitor.Should().Be(1); } + [Fact] + public void WorkspaceProfile_Serialization_PreservesBrowserState() + { + var profile = new WorkspaceProfile + { + Name = "브라우저 프로필", + Windows = + [ + new() + { + Exe = "msedge.exe", + Title = "업무 포털 - Microsoft Edge", + Browser = new BrowserWindowState + { + Kind = "edge", + UserDataDir = @"C:\Users\tester\AppData\Local\Microsoft\Edge\User Data", + ProfileDirectory = "Profile 3", + ActiveUrl = "https://portal.example.com", + ActiveTabIndex = 1, + TabUrls = ["https://mail.example.com", "https://portal.example.com"] + } + } + ] + }; + + var json = JsonSerializer.Serialize(profile, JsonOptions); + var restored = JsonSerializer.Deserialize(json, JsonOptions)!; + + restored.Windows.Should().HaveCount(1); + restored.Windows[0].Browser.Should().NotBeNull(); + restored.Windows[0].Browser!.Kind.Should().Be("edge"); + restored.Windows[0].Browser!.ProfileDirectory.Should().Be("Profile 3"); + restored.Windows[0].Browser!.ActiveTabIndex.Should().Be(1); + restored.Windows[0].Browser!.TabUrls.Should().ContainInOrder( + "https://mail.example.com", + "https://portal.example.com"); + } + // ─── ClipboardTransformer ──────────────────────────────────────────────── [Fact] diff --git a/src/AxCopilot/AxCopilot.csproj b/src/AxCopilot/AxCopilot.csproj index 3bfddc0..aae989a 100644 --- a/src/AxCopilot/AxCopilot.csproj +++ b/src/AxCopilot/AxCopilot.csproj @@ -75,6 +75,7 @@ + @@ -170,4 +171,3 @@ - diff --git a/src/AxCopilot/Core/BrowserWorkspaceStateHelper.cs b/src/AxCopilot/Core/BrowserWorkspaceStateHelper.cs new file mode 100644 index 0000000..667a2f0 --- /dev/null +++ b/src/AxCopilot/Core/BrowserWorkspaceStateHelper.cs @@ -0,0 +1,628 @@ +using System.IO; +using System.Management; +using System.Runtime.InteropServices; +using System.Windows.Automation; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Core; + +internal static class BrowserWorkspaceStateHelper +{ + private static readonly string[] AddressBarKeywords = + [ + "address", + "search", + "\uC8FC\uC18C", + "\uAC80\uC0C9", + "omnibox", + "url" + ]; + private static readonly string[] NewTabKeywords = + [ + "new tab", + "\uC0C8 \uD0ED", + "start page", + "\uC2DC\uC791 \uD398\uC774\uC9C0" + ]; + + public static BrowserWindowState? TryCapture(IntPtr hWnd, string exePath) + { + if (!TryGetBrowserKind(exePath, out var kind)) + return null; + + var state = new BrowserWindowState + { + Kind = kind + }; + + try + { + GetWindowThreadProcessId(hWnd, out var processId); + if (processId != 0) + PopulateProfileArguments((int)processId, state); + } + catch (Exception ex) + { + LogService.Warn($"브라우저 프로필 정보 수집 실패: {Path.GetFileName(exePath)} - {ex.Message}"); + } + + try + { + CaptureTabs(hWnd, state); + } + catch (Exception ex) + { + LogService.Warn($"브라우저 탭 상태 수집 실패: {Path.GetFileName(exePath)} - {ex.Message}"); + } + + NormalizeState(state); + return state; + } + + public static bool TryGetBrowserKind(string exePath, out string kind) + { + kind = NormalizeKind(Path.GetFileNameWithoutExtension(exePath) ?? string.Empty); + return kind is "chrome" or "edge" or "whale" or "brave" or "opera" or "firefox"; + } + + internal static bool ShouldLaunchNewWindow(WindowSnapshot snapshot, ContextManager.WindowCandidate? candidate) + { + if (!HasRestorableState(snapshot.Browser)) + return false; + + return candidate is null || !TitlesRepresentSameWindow(snapshot.Title, candidate.Value.Title); + } + + internal static bool TitlesRepresentSameWindow(string expectedTitle, string actualTitle) + { + if (string.IsNullOrWhiteSpace(expectedTitle) || string.IsNullOrWhiteSpace(actualTitle)) + return false; + + if (string.Equals(expectedTitle, actualTitle, StringComparison.OrdinalIgnoreCase)) + return true; + + return string.Equals( + ContextManager.NormalizeWindowTitle(expectedTitle), + ContextManager.NormalizeWindowTitle(actualTitle), + StringComparison.OrdinalIgnoreCase); + } + + internal static bool HasRestorableState(BrowserWindowState? state) + => state != null && GetLaunchUrls(state).Count > 0; + + internal static BrowserLaunchPlan? CreateLaunchPlan(string exePath, BrowserWindowState? state) + { + if (state == null || string.IsNullOrWhiteSpace(exePath)) + return null; + + return state.Kind switch + { + "firefox" => CreateFirefoxLaunchPlan(exePath, state), + _ => CreateChromiumLaunchPlan(exePath, state) + }; + } + + internal static bool TryRestoreActiveTab(IntPtr hWnd, BrowserWindowState? state) + { + if (state == null || state.ActiveTabIndex <= 0) + return false; + + try + { + var root = AutomationElement.FromHandle(hWnd); + var tabs = FindBrowserTabs(root).ToList(); + if (state.ActiveTabIndex >= tabs.Count) + return false; + + return TrySelectTab(tabs[state.ActiveTabIndex]); + } + catch (Exception ex) + { + LogService.Warn($"브라우저 활성 탭 복원 실패: {ex.Message}"); + return false; + } + } + + private static BrowserLaunchPlan? CreateChromiumLaunchPlan(string exePath, BrowserWindowState state) + { + var urls = GetLaunchUrls(state); + if (urls.Count == 0) + return null; + + var arguments = new List { "--new-window" }; + + if (!string.IsNullOrWhiteSpace(state.UserDataDir)) + arguments.Add($"--user-data-dir={state.UserDataDir}"); + + if (!string.IsNullOrWhiteSpace(state.ProfileDirectory)) + arguments.Add($"--profile-directory={state.ProfileDirectory}"); + + arguments.AddRange(urls); + return new BrowserLaunchPlan(exePath, arguments); + } + + private static BrowserLaunchPlan? CreateFirefoxLaunchPlan(string exePath, BrowserWindowState state) + { + var urls = GetLaunchUrls(state); + if (urls.Count == 0) + return null; + + var arguments = new List(); + + if (!string.IsNullOrWhiteSpace(state.UserDataDir)) + { + arguments.Add("-profile"); + arguments.Add(state.UserDataDir); + } + else if (!string.IsNullOrWhiteSpace(state.ProfileDirectory)) + { + arguments.Add("-P"); + arguments.Add(state.ProfileDirectory); + } + + arguments.Add("-new-window"); + arguments.Add(urls[0]); + + for (var i = 1; i < urls.Count; i++) + { + arguments.Add("-new-tab"); + arguments.Add(urls[i]); + } + + return new BrowserLaunchPlan(exePath, arguments); + } + + private static List GetLaunchUrls(BrowserWindowState state) + { + var urls = state.TabUrls + .Where(LooksLikeRestorableUrl) + .ToList(); + + if (urls.Count == 0 && LooksLikeRestorableUrl(state.ActiveUrl)) + urls.Add(state.ActiveUrl!); + + return urls; + } + + private static void PopulateProfileArguments(int processId, BrowserWindowState state) + { + var commandLine = TryGetProcessCommandLine(processId); + if (string.IsNullOrWhiteSpace(commandLine)) + return; + + var args = SplitCommandLine(commandLine); + if (args.Count == 0) + return; + + if (state.Kind == "firefox") + { + state.UserDataDir = GetArgumentValue(args, "-profile"); + state.ProfileDirectory = GetArgumentValue(args, "-P"); + return; + } + + state.UserDataDir = GetArgumentValue(args, "--user-data-dir"); + state.ProfileDirectory = GetArgumentValue(args, "--profile-directory"); + } + + private static string? TryGetProcessCommandLine(int processId) + { + try + { + using var searcher = new ManagementObjectSearcher( + $"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {processId}"); + using var results = searcher.Get(); + + foreach (ManagementObject process in results) + return process["CommandLine"]?.ToString(); + } + catch (Exception ex) + { + LogService.Warn($"프로세스 명령줄 조회 실패: PID={processId} - {ex.Message}"); + } + + return null; + } + + private static List SplitCommandLine(string commandLine) + { + var argv = CommandLineToArgvW(commandLine, out var argc); + if (argv == IntPtr.Zero || argc <= 0) + return new List(); + + try + { + var args = new List(argc); + for (var i = 0; i < argc; i++) + { + var value = Marshal.ReadIntPtr(argv, i * IntPtr.Size); + args.Add(Marshal.PtrToStringUni(value) ?? string.Empty); + } + + return args; + } + finally + { + LocalFree(argv); + } + } + + private static string? GetArgumentValue(IReadOnlyList args, string key) + { + for (var i = 1; i < args.Count; i++) + { + var current = args[i]; + if (current.StartsWith(key + "=", StringComparison.OrdinalIgnoreCase)) + return current[(key.Length + 1)..].Trim().Trim('"'); + + if (!current.Equals(key, StringComparison.OrdinalIgnoreCase) || i + 1 >= args.Count) + continue; + + return args[i + 1].Trim().Trim('"'); + } + + return null; + } + + private static void CaptureTabs(IntPtr hWnd, BrowserWindowState state) + { + var root = AutomationElement.FromHandle(hWnd); + var tabs = FindBrowserTabs(root).ToList(); + + if (tabs.Count == 0) + { + var activeOnly = TryReadCurrentUrl(root, state.Kind, null); + if (!string.IsNullOrWhiteSpace(activeOnly)) + { + state.ActiveUrl = activeOnly; + state.ActiveTabIndex = 0; + state.TabUrls = [activeOnly]; + } + + return; + } + + var originalForeground = GetForegroundWindow(); + var activated = TryActivateWindow(hWnd); + var selectedIndex = GetSelectedTabIndex(tabs); + if (selectedIndex < 0) + selectedIndex = 0; + + try + { + var urls = new List(); + + for (var i = 0; i < tabs.Count; i++) + { + if (!TrySelectTab(tabs[i])) + continue; + + WaitForTabSwitch(); + + var freshRoot = AutomationElement.FromHandle(hWnd); + var freshTabs = FindBrowserTabs(freshRoot).ToList(); + var currentTab = i < freshTabs.Count ? freshTabs[i] : tabs[i]; + + var capturedUrl = TryReadCurrentUrl(freshRoot, state.Kind, currentTab); + if (string.IsNullOrWhiteSpace(capturedUrl)) + capturedUrl = GuessSpecialTabUrl(state.Kind, currentTab); + + if (string.IsNullOrWhiteSpace(capturedUrl)) + continue; + + urls.Add(capturedUrl); + } + + state.ActiveTabIndex = Math.Clamp(selectedIndex, 0, Math.Max(0, urls.Count - 1)); + state.TabUrls = urls; + + if (selectedIndex < urls.Count) + state.ActiveUrl = urls[selectedIndex]; + } + finally + { + if (selectedIndex >= 0 && selectedIndex < tabs.Count) + { + try + { + TrySelectTab(tabs[selectedIndex]); + } + catch + { + // 탭 복원 실패는 저장 흐름을 중단하지 않습니다. + } + } + + if (activated && originalForeground != IntPtr.Zero && originalForeground != hWnd) + SetForegroundWindow(originalForeground); + } + } + + private static IEnumerable FindBrowserTabs(AutomationElement root) + { + var tabControls = root.FindAll( + TreeScope.Descendants, + new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Tab)) + .Cast() + .Where(IsVisible) + .OrderBy(element => element.Current.BoundingRectangle.Top) + .ToList(); + + foreach (var tabControl in tabControls) + { + var items = tabControl.FindAll( + TreeScope.Children, + new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.TabItem)) + .Cast() + .Where(IsVisible) + .ToList(); + + if (items.Count > 0) + return items; + } + + var rootBounds = root.Current.BoundingRectangle; + return root.FindAll( + TreeScope.Descendants, + new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.TabItem)) + .Cast() + .Where(IsVisible) + .Where(tab => + { + var bounds = tab.Current.BoundingRectangle; + return bounds.Width > 40 + && bounds.Height > 10 + && bounds.Top <= rootBounds.Top + Math.Max(140, rootBounds.Height * 0.25); + }) + .OrderBy(tab => tab.Current.BoundingRectangle.Left) + .ToList(); + } + + private static int GetSelectedTabIndex(IReadOnlyList tabs) + { + for (var i = 0; i < tabs.Count; i++) + { + if (tabs[i].TryGetCurrentPattern(SelectionItemPattern.Pattern, out var patternObj) && + patternObj is SelectionItemPattern selectionItem && + selectionItem.Current.IsSelected) + { + return i; + } + } + + return -1; + } + + private static bool TrySelectTab(AutomationElement tab) + { + try + { + if (tab.TryGetCurrentPattern(SelectionItemPattern.Pattern, out var selectionObj) && + selectionObj is SelectionItemPattern selectionItem) + { + selectionItem.Select(); + return true; + } + + if (tab.TryGetCurrentPattern(InvokePattern.Pattern, out var invokeObj) && + invokeObj is InvokePattern invokePattern) + { + invokePattern.Invoke(); + return true; + } + + tab.SetFocus(); + return true; + } + catch + { + return false; + } + } + + private static string? TryReadCurrentUrl(AutomationElement root, string kind, AutomationElement? currentTab) + { + var edits = root.FindAll( + TreeScope.Descendants, + new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Edit)) + .Cast() + .Where(IsVisible) + .OrderByDescending(edit => IsLikelyAddressBar(edit, root)) + .ThenBy(edit => edit.Current.BoundingRectangle.Top) + .ThenByDescending(edit => edit.Current.BoundingRectangle.Width) + .ToList(); + + foreach (var edit in edits) + { + var raw = TryGetEditValue(edit); + var normalized = NormalizeCapturedUrl(raw, kind, currentTab); + if (!string.IsNullOrWhiteSpace(normalized)) + return normalized; + } + + return GuessSpecialTabUrl(kind, currentTab); + } + + private static bool IsLikelyAddressBar(AutomationElement element, AutomationElement root) + { + var bounds = element.Current.BoundingRectangle; + var rootBounds = root.Current.BoundingRectangle; + if (bounds.Width < 180 || bounds.Height < 18) + return false; + + if (bounds.Top > rootBounds.Top + Math.Max(180, rootBounds.Height * 0.35)) + return false; + + var combinedText = string.Join(' ', + element.Current.Name ?? string.Empty, + element.Current.AutomationId ?? string.Empty, + element.Current.HelpText ?? string.Empty).ToLowerInvariant(); + + if (AddressBarKeywords.Any(keyword => combinedText.Contains(keyword, StringComparison.OrdinalIgnoreCase))) + return true; + + return bounds.Width >= rootBounds.Width * 0.25; + } + + private static string? TryGetEditValue(AutomationElement element) + { + if (element.TryGetCurrentPattern(ValuePattern.Pattern, out var valueObj) && + valueObj is ValuePattern valuePattern) + { + return valuePattern.Current.Value; + } + + return null; + } + + private static string? NormalizeCapturedUrl(string? raw, string kind, AutomationElement? currentTab) + { + var trimmed = raw?.Trim().Trim('"'); + if (string.IsNullOrWhiteSpace(trimmed)) + return GuessSpecialTabUrl(kind, currentTab); + + if (LooksLikeRestorableUrl(trimmed)) + return trimmed; + + return GuessSpecialTabUrl(kind, currentTab); + } + + private static string? GuessSpecialTabUrl(string kind, AutomationElement? currentTab) + { + var tabName = currentTab?.Current.Name?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(tabName)) + return null; + + if (NewTabKeywords.Any(keyword => tabName.Contains(keyword, StringComparison.OrdinalIgnoreCase))) + { + return kind == "firefox" + ? "about:newtab" + : $"{GetInternalScheme(kind)}://newtab/"; + } + + return null; + } + + private static string GetInternalScheme(string kind) => kind switch + { + "edge" => "edge", + "whale" => "whale", + "brave" => "brave", + "opera" => "opera", + _ => "chrome" + }; + + private static bool LooksLikeRestorableUrl(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + var trimmed = value.Trim(); + if (trimmed.Contains(' ') && !trimmed.Contains("://", StringComparison.Ordinal)) + return false; + + if (Uri.TryCreate(trimmed, UriKind.Absolute, out _)) + return true; + + return trimmed.StartsWith("about:", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("chrome://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("edge://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("brave://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("whale://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("opera://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("view-source:", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("devtools://", StringComparison.OrdinalIgnoreCase); + } + + private static void NormalizeState(BrowserWindowState state) + { + state.Kind = NormalizeKind(state.Kind); + state.UserDataDir = NormalizeOptionalValue(state.UserDataDir); + state.ProfileDirectory = NormalizeOptionalValue(state.ProfileDirectory); + state.ActiveUrl = NormalizeOptionalValue(state.ActiveUrl); + state.TabUrls = state.TabUrls + .Where(LooksLikeRestorableUrl) + .ToList(); + + if (!string.IsNullOrWhiteSpace(state.ActiveUrl) && + !state.TabUrls.Contains(state.ActiveUrl, StringComparer.OrdinalIgnoreCase)) + { + var insertIndex = Math.Clamp(state.ActiveTabIndex, 0, state.TabUrls.Count); + state.TabUrls.Insert(insertIndex, state.ActiveUrl); + } + + if (state.TabUrls.Count == 0) + { + state.ActiveTabIndex = 0; + return; + } + + state.ActiveTabIndex = Math.Clamp(state.ActiveTabIndex, 0, state.TabUrls.Count - 1); + + if (string.IsNullOrWhiteSpace(state.ActiveUrl)) + state.ActiveUrl = state.TabUrls[state.ActiveTabIndex]; + } + + private static string NormalizeKind(string kind) => kind.Trim().ToLowerInvariant() switch + { + "msedge" => "edge", + _ => kind.Trim().ToLowerInvariant() + }; + + private static string? NormalizeOptionalValue(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static bool IsVisible(AutomationElement element) + { + try + { + return !element.Current.IsOffscreen; + } + catch + { + return false; + } + } + + private static bool TryActivateWindow(IntPtr hWnd) + { + try + { + ShowWindow(hWnd, SW_RESTORE); + return SetForegroundWindow(hWnd); + } + catch + { + return false; + } + } + + private static void WaitForTabSwitch() + => Thread.Sleep(150); + + private const int SW_RESTORE = 9; + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("shell32.dll", SetLastError = true)] + private static extern IntPtr CommandLineToArgvW( + [MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine, + out int pNumArgs); + + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr hMem); +} + +internal sealed record BrowserLaunchPlan(string FileName, IReadOnlyList Arguments); diff --git a/src/AxCopilot/Core/ContextManager.cs b/src/AxCopilot/Core/ContextManager.cs index a752b7f..0e65ce6 100644 --- a/src/AxCopilot/Core/ContextManager.cs +++ b/src/AxCopilot/Core/ContextManager.cs @@ -30,6 +30,7 @@ public class ContextManager { var snapshots = new List(); var monitorMap = BuildMonitorMap(); + var shouldCaptureBrowserState = _settings.Settings.Launcher.EnableBrowserSessionRestore; EnumWindows((hWnd, _) => { @@ -57,6 +58,10 @@ public class ContextManager }; int monitorIndex = GetMonitorIndex(hWnd, monitorMap); + BrowserWindowState? browserState = null; + + if (shouldCaptureBrowserState) + browserState = BrowserWorkspaceStateHelper.TryCapture(hWnd, exePath); snapshots.Add(new WindowSnapshot { @@ -70,7 +75,8 @@ public class ContextManager Height = rect.Bottom - rect.Top }, ShowCmd = showCmd, - Monitor = monitorIndex + Monitor = monitorIndex, + Browser = browserState }); return true; @@ -117,10 +123,43 @@ public class ContextManager ct.ThrowIfCancellationRequested(); // 1. 실행 중인 창 찾기 - var hWnd = FindMatchingWindow(snapshot, usedHandles); + var matchedCandidate = FindMatchingWindowCandidate(snapshot, usedHandles); + var hWnd = matchedCandidate?.Handle ?? IntPtr.Zero; + var launchedBrowserWindow = false; - // 2. 창이 없으면 EXE 실행 후 대기 - if (hWnd == IntPtr.Zero && File.Exists(snapshot.Exe)) + // 2. 브라우저 상태가 있으면 새 창 복원을 우선 시도 + if (File.Exists(snapshot.Exe) && BrowserWorkspaceStateHelper.ShouldLaunchNewWindow(snapshot, matchedCandidate)) + { + try + { + var preLaunchHandles = GetOpenWindowCandidates() + .Select(candidate => candidate.Handle) + .ToHashSet(); + + launchedBrowserWindow = LaunchBrowserWindow(snapshot); + if (launchedBrowserWindow) + { + hWnd = await WaitForLaunchedWindowAsync( + snapshot, + preLaunchHandles, + usedHandles, + TimeSpan.FromSeconds(6), + ct); + + if (hWnd == IntPtr.Zero) + hWnd = await WaitForWindowAsync(snapshot, usedHandles, TimeSpan.FromSeconds(2), ct); + } + } + catch (Exception ex) + { + results.Add($"⚠ {snapshot.Title}: 실행 실패 ({ex.Message})"); + LogService.Warn($"앱 실행 실패: {snapshot.Exe} - {ex.Message}"); + continue; + } + } + + // 3. 창이 없으면 EXE 실행 후 대기 + if (hWnd == IntPtr.Zero && !launchedBrowserWindow && File.Exists(snapshot.Exe)) { try { @@ -143,7 +182,7 @@ public class ContextManager usedHandles.Add(hWnd); - // 3. 모니터 불일치 처리 + // 4. 모니터 불일치 처리 if (snapshot.Monitor >= monitorCount) { var policy = _settings.Settings.MonitorMismatch; @@ -155,7 +194,7 @@ public class ContextManager // "fit" 또는 "warn" → 첫 번째 모니터에 배치 } - // 4. 창 위치/크기 복원 + // 5. 창 위치/크기 복원 try { ShowWindow(hWnd, snapshot.ShowCmd switch @@ -173,6 +212,9 @@ public class ContextManager SWP_NOZORDER | SWP_NOACTIVATE); } + if (launchedBrowserWindow && snapshot.Browser != null) + BrowserWorkspaceStateHelper.TryRestoreActiveTab(hWnd, snapshot.Browser); + results.Add($"✓ {snapshot.Title}: 복원 완료"); } catch (Exception ex) @@ -211,9 +253,14 @@ public class ContextManager // ─── Helpers ───────────────────────────────────────────────────────────── private static IntPtr FindMatchingWindow(WindowSnapshot snapshot, ISet usedHandles) + { + return FindMatchingWindowCandidate(snapshot, usedHandles)?.Handle ?? IntPtr.Zero; + } + + private static WindowCandidate? FindMatchingWindowCandidate(WindowSnapshot snapshot, ISet usedHandles) { var candidates = GetOpenWindowCandidates(); - return SelectBestMatchingWindow(snapshot, candidates, usedHandles); + return SelectBestMatchingWindowCandidate(snapshot, candidates, usedHandles); } private static async Task WaitForWindowAsync( @@ -233,6 +280,54 @@ public class ContextManager return IntPtr.Zero; } + private static async Task WaitForLaunchedWindowAsync( + WindowSnapshot snapshot, + ISet preLaunchHandles, + ISet usedHandles, + TimeSpan timeout, + CancellationToken ct) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + + var launchedCandidate = GetOpenWindowCandidates() + .Where(candidate => string.Equals(candidate.Exe, snapshot.Exe, StringComparison.OrdinalIgnoreCase)) + .Where(candidate => !preLaunchHandles.Contains(candidate.Handle)) + .Where(candidate => !usedHandles.Contains(candidate.Handle)) + .OrderByDescending(candidate => CalculateWindowMatchScore(snapshot, candidate)) + .ThenBy(candidate => candidate.Handle) + .FirstOrDefault(); + + if (launchedCandidate.Handle != IntPtr.Zero) + return launchedCandidate.Handle; + + await Task.Delay(250, ct); + } + + return IntPtr.Zero; + } + + private static bool LaunchBrowserWindow(WindowSnapshot snapshot) + { + var plan = BrowserWorkspaceStateHelper.CreateLaunchPlan(snapshot.Exe, snapshot.Browser); + if (plan == null) + return false; + + var startInfo = new ProcessStartInfo(plan.FileName) + { + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(plan.FileName) ?? Environment.CurrentDirectory + }; + + foreach (var argument in plan.Arguments) + startInfo.ArgumentList.Add(argument); + + Process.Start(startInfo); + return true; + } + private static List GetOpenWindowCandidates() { var candidates = new List(); @@ -259,6 +354,14 @@ public class ContextManager WindowSnapshot snapshot, IReadOnlyCollection candidates, ISet? usedHandles = null) + { + return SelectBestMatchingWindowCandidate(snapshot, candidates, usedHandles)?.Handle ?? IntPtr.Zero; + } + + internal static WindowCandidate? SelectBestMatchingWindowCandidate( + WindowSnapshot snapshot, + IReadOnlyCollection candidates, + ISet? usedHandles = null) { var matches = candidates .Where(candidate => string.Equals(candidate.Exe, snapshot.Exe, StringComparison.OrdinalIgnoreCase)) @@ -273,9 +376,9 @@ public class ContextManager .ToList(); if (matches.Count == 0) - return IntPtr.Zero; + return null; - return matches[0].Handle; + return candidates.First(candidate => candidate.Handle == matches[0].Handle); } internal static int CalculateWindowMatchScore(WindowSnapshot snapshot, WindowCandidate candidate) diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 00df85b..deea71f 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -231,6 +231,10 @@ public class LauncherSettings [JsonPropertyName("showLauncherBottomQuickActions")] public bool ShowLauncherBottomQuickActions { get; set; } = false; + /// 워크스페이스 저장 시 브라우저 탭과 URL 상태를 함께 캡처/복원합니다. 기본 true. + [JsonPropertyName("enableBrowserSessionRestore")] + public bool EnableBrowserSessionRestore { get; set; } = true; + /// 단축키 헬프 창에서 아이콘 색상을 테마 AccentColor 기준으로 표시. 기본 true(테마색). [JsonPropertyName("shortcutHelpUseThemeColor")] public bool ShortcutHelpUseThemeColor { get; set; } = true; @@ -395,6 +399,30 @@ public class WindowSnapshot [JsonPropertyName("monitor")] public int Monitor { get; set; } = 0; + + [JsonPropertyName("browser")] + public BrowserWindowState? Browser { get; set; } +} + +public class BrowserWindowState +{ + [JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + + [JsonPropertyName("userDataDir")] + public string? UserDataDir { get; set; } + + [JsonPropertyName("profileDirectory")] + public string? ProfileDirectory { get; set; } + + [JsonPropertyName("activeUrl")] + public string? ActiveUrl { get; set; } + + [JsonPropertyName("activeTabIndex")] + public int ActiveTabIndex { get; set; } + + [JsonPropertyName("tabUrls")] + public List TabUrls { get; set; } = new(); } public class WindowRect diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index 25fb0ea..eca7e95 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -181,6 +181,7 @@ public class SettingsViewModel : INotifyPropertyChanged private bool _showWidgetCalendar; private bool _showWidgetBattery; private bool _showLauncherBottomQuickActions; + private bool _enableBrowserSessionRestore; private bool _shortcutHelpUseThemeColor; // LLM 공통 설정 @@ -948,6 +949,12 @@ public class SettingsViewModel : INotifyPropertyChanged set { _showLauncherBottomQuickActions = value; OnPropertyChanged(); } } + public bool EnableBrowserSessionRestore + { + get => _enableBrowserSessionRestore; + set { _enableBrowserSessionRestore = value; OnPropertyChanged(); } + } + public bool EnableIconAnimation { get => _enableIconAnimation; @@ -1245,6 +1252,7 @@ public class SettingsViewModel : INotifyPropertyChanged _showWidgetCalendar = s.Launcher.ShowWidgetCalendar; _showWidgetBattery = s.Launcher.ShowWidgetBattery; _showLauncherBottomQuickActions = s.Launcher.ShowLauncherBottomQuickActions; + _enableBrowserSessionRestore = s.Launcher.EnableBrowserSessionRestore; _shortcutHelpUseThemeColor = s.Launcher.ShortcutHelpUseThemeColor; _enableTextAction = s.Launcher.EnableTextAction; // v1.7.1: 파일 대화상자 통합 기본값을 false로 변경 (브라우저 업로드 오작동 방지) @@ -1720,6 +1728,7 @@ public class SettingsViewModel : INotifyPropertyChanged s.Launcher.ShowWidgetCalendar = _showWidgetCalendar; s.Launcher.ShowWidgetBattery = _showWidgetBattery; s.Launcher.ShowLauncherBottomQuickActions = _showLauncherBottomQuickActions; + s.Launcher.EnableBrowserSessionRestore = _enableBrowserSessionRestore; s.Launcher.ShortcutHelpUseThemeColor = _shortcutHelpUseThemeColor; s.Launcher.EnableTextAction = _enableTextAction; s.Launcher.EnableFileDialogIntegration = _enableFileDialogIntegration; diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index 0d1ca47..d117b92 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -3063,6 +3063,19 @@ + + + + + + + + + +