워크스페이스 브라우저 상태 복원 경로를 추가하고 설정 및 검증을 정리한다

변경 목적: ~ 워크스페이스 복원 시 창 배치뿐 아니라 브라우저 탭/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개 테스트 통과를 확인했다.
This commit is contained in:
2026-04-15 17:28:22 +09:00
parent 9344cf83d6
commit e823ff83e3
10 changed files with 941 additions and 10 deletions

View File

@@ -1,5 +1,10 @@
# AX Commander # 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 전용 시맨틱 런처 & 워크스페이스 매니저 Windows 전용 시맨틱 런처 & 워크스페이스 매니저
> Alfred (macOS)에서 영감을 받아 Windows 환경에 최적화된 키보드 중심 생산성 도구입니다. > Alfred (macOS)에서 영감을 받아 Windows 환경에 최적화된 키보드 중심 생산성 도구입니다.

View File

@@ -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 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 - 검증: `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-14 19:50 (KST)
업데이트: 2026-04-15 15:45 (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`를 우선하도록 안내합니다. - Cowork PPT 생성 경로를 특정 업종 전용 archetype이 아니라 공통 품질 루프로 강화했습니다. `src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs``src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs`는 presentation/deck 요청에서 `document_plan`을 무조건 선행하지 않고, 계획 요청이 명시되지 않으면 `pptx_create`를 우선하도록 안내합니다.

View File

@@ -62,4 +62,96 @@ public class ContextManagerTests
selected.Should().Be(IntPtr.Zero); 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();
}
} }

View File

@@ -34,6 +34,12 @@ public class SettingsServiceTests
opacity.Should().BeInRange(0.0, 1.0); opacity.Should().BeInRange(0.0, 1.0);
} }
[Fact]
public void LauncherSettings_DefaultBrowserSessionRestore_IsEnabled()
{
new LauncherSettings().EnableBrowserSessionRestore.Should().BeTrue();
}
[Fact] [Fact]
public void AppSettings_DefaultMonitorMismatch_IsWarn() public void AppSettings_DefaultMonitorMismatch_IsWarn()
{ {
@@ -200,6 +206,44 @@ public class SettingsServiceTests
restored.Windows[0].Monitor.Should().Be(1); 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<WorkspaceProfile>(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 ──────────────────────────────────────────────── // ─── ClipboardTransformer ────────────────────────────────────────────────
[Fact] [Fact]

View File

@@ -75,6 +75,7 @@
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" /> <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageReference Include="QRCoder" Version="1.6.0" /> <PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="System.IO.Hashing" Version="8.0.0" /> <PackageReference Include="System.IO.Hashing" Version="8.0.0" />
<PackageReference Include="System.Management" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" /> <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" /> <PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" /> <PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
@@ -170,4 +171,3 @@
</AssemblyAttribute> </AssemblyAttribute>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -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<string> { "--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<string>();
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<string> 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<string> SplitCommandLine(string commandLine)
{
var argv = CommandLineToArgvW(commandLine, out var argc);
if (argv == IntPtr.Zero || argc <= 0)
return new List<string>();
try
{
var args = new List<string>(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<string> 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<string>();
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<AutomationElement> FindBrowserTabs(AutomationElement root)
{
var tabControls = root.FindAll(
TreeScope.Descendants,
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Tab))
.Cast<AutomationElement>()
.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<AutomationElement>()
.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<AutomationElement>()
.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<AutomationElement> 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<AutomationElement>()
.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<string> Arguments);

View File

@@ -30,6 +30,7 @@ public class ContextManager
{ {
var snapshots = new List<WindowSnapshot>(); var snapshots = new List<WindowSnapshot>();
var monitorMap = BuildMonitorMap(); var monitorMap = BuildMonitorMap();
var shouldCaptureBrowserState = _settings.Settings.Launcher.EnableBrowserSessionRestore;
EnumWindows((hWnd, _) => EnumWindows((hWnd, _) =>
{ {
@@ -57,6 +58,10 @@ public class ContextManager
}; };
int monitorIndex = GetMonitorIndex(hWnd, monitorMap); int monitorIndex = GetMonitorIndex(hWnd, monitorMap);
BrowserWindowState? browserState = null;
if (shouldCaptureBrowserState)
browserState = BrowserWorkspaceStateHelper.TryCapture(hWnd, exePath);
snapshots.Add(new WindowSnapshot snapshots.Add(new WindowSnapshot
{ {
@@ -70,7 +75,8 @@ public class ContextManager
Height = rect.Bottom - rect.Top Height = rect.Bottom - rect.Top
}, },
ShowCmd = showCmd, ShowCmd = showCmd,
Monitor = monitorIndex Monitor = monitorIndex,
Browser = browserState
}); });
return true; return true;
@@ -117,10 +123,43 @@ public class ContextManager
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// 1. 실행 중인 창 찾기 // 1. 실행 중인 창 찾기
var hWnd = FindMatchingWindow(snapshot, usedHandles); var matchedCandidate = FindMatchingWindowCandidate(snapshot, usedHandles);
var hWnd = matchedCandidate?.Handle ?? IntPtr.Zero;
var launchedBrowserWindow = false;
// 2. 창이 없으면 EXE 실행 후 대기 // 2. 브라우저 상태가 있으면 새 창 복원을 우선 시도
if (hWnd == IntPtr.Zero && File.Exists(snapshot.Exe)) 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 try
{ {
@@ -143,7 +182,7 @@ public class ContextManager
usedHandles.Add(hWnd); usedHandles.Add(hWnd);
// 3. 모니터 불일치 처리 // 4. 모니터 불일치 처리
if (snapshot.Monitor >= monitorCount) if (snapshot.Monitor >= monitorCount)
{ {
var policy = _settings.Settings.MonitorMismatch; var policy = _settings.Settings.MonitorMismatch;
@@ -155,7 +194,7 @@ public class ContextManager
// "fit" 또는 "warn" → 첫 번째 모니터에 배치 // "fit" 또는 "warn" → 첫 번째 모니터에 배치
} }
// 4. 창 위치/크기 복원 // 5. 창 위치/크기 복원
try try
{ {
ShowWindow(hWnd, snapshot.ShowCmd switch ShowWindow(hWnd, snapshot.ShowCmd switch
@@ -173,6 +212,9 @@ public class ContextManager
SWP_NOZORDER | SWP_NOACTIVATE); SWP_NOZORDER | SWP_NOACTIVATE);
} }
if (launchedBrowserWindow && snapshot.Browser != null)
BrowserWorkspaceStateHelper.TryRestoreActiveTab(hWnd, snapshot.Browser);
results.Add($"✓ {snapshot.Title}: 복원 완료"); results.Add($"✓ {snapshot.Title}: 복원 완료");
} }
catch (Exception ex) catch (Exception ex)
@@ -211,9 +253,14 @@ public class ContextManager
// ─── Helpers ───────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────
private static IntPtr FindMatchingWindow(WindowSnapshot snapshot, ISet<IntPtr> usedHandles) private static IntPtr FindMatchingWindow(WindowSnapshot snapshot, ISet<IntPtr> usedHandles)
{
return FindMatchingWindowCandidate(snapshot, usedHandles)?.Handle ?? IntPtr.Zero;
}
private static WindowCandidate? FindMatchingWindowCandidate(WindowSnapshot snapshot, ISet<IntPtr> usedHandles)
{ {
var candidates = GetOpenWindowCandidates(); var candidates = GetOpenWindowCandidates();
return SelectBestMatchingWindow(snapshot, candidates, usedHandles); return SelectBestMatchingWindowCandidate(snapshot, candidates, usedHandles);
} }
private static async Task<IntPtr> WaitForWindowAsync( private static async Task<IntPtr> WaitForWindowAsync(
@@ -233,6 +280,54 @@ public class ContextManager
return IntPtr.Zero; return IntPtr.Zero;
} }
private static async Task<IntPtr> WaitForLaunchedWindowAsync(
WindowSnapshot snapshot,
ISet<IntPtr> preLaunchHandles,
ISet<IntPtr> 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<WindowCandidate> GetOpenWindowCandidates() private static List<WindowCandidate> GetOpenWindowCandidates()
{ {
var candidates = new List<WindowCandidate>(); var candidates = new List<WindowCandidate>();
@@ -259,6 +354,14 @@ public class ContextManager
WindowSnapshot snapshot, WindowSnapshot snapshot,
IReadOnlyCollection<WindowCandidate> candidates, IReadOnlyCollection<WindowCandidate> candidates,
ISet<IntPtr>? usedHandles = null) ISet<IntPtr>? usedHandles = null)
{
return SelectBestMatchingWindowCandidate(snapshot, candidates, usedHandles)?.Handle ?? IntPtr.Zero;
}
internal static WindowCandidate? SelectBestMatchingWindowCandidate(
WindowSnapshot snapshot,
IReadOnlyCollection<WindowCandidate> candidates,
ISet<IntPtr>? usedHandles = null)
{ {
var matches = candidates var matches = candidates
.Where(candidate => string.Equals(candidate.Exe, snapshot.Exe, StringComparison.OrdinalIgnoreCase)) .Where(candidate => string.Equals(candidate.Exe, snapshot.Exe, StringComparison.OrdinalIgnoreCase))
@@ -273,9 +376,9 @@ public class ContextManager
.ToList(); .ToList();
if (matches.Count == 0) 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) internal static int CalculateWindowMatchScore(WindowSnapshot snapshot, WindowCandidate candidate)

View File

@@ -231,6 +231,10 @@ public class LauncherSettings
[JsonPropertyName("showLauncherBottomQuickActions")] [JsonPropertyName("showLauncherBottomQuickActions")]
public bool ShowLauncherBottomQuickActions { get; set; } = false; public bool ShowLauncherBottomQuickActions { get; set; } = false;
/// <summary>워크스페이스 저장 시 브라우저 탭과 URL 상태를 함께 캡처/복원합니다. 기본 true.</summary>
[JsonPropertyName("enableBrowserSessionRestore")]
public bool EnableBrowserSessionRestore { get; set; } = true;
/// <summary>단축키 헬프 창에서 아이콘 색상을 테마 AccentColor 기준으로 표시. 기본 true(테마색).</summary> /// <summary>단축키 헬프 창에서 아이콘 색상을 테마 AccentColor 기준으로 표시. 기본 true(테마색).</summary>
[JsonPropertyName("shortcutHelpUseThemeColor")] [JsonPropertyName("shortcutHelpUseThemeColor")]
public bool ShortcutHelpUseThemeColor { get; set; } = true; public bool ShortcutHelpUseThemeColor { get; set; } = true;
@@ -395,6 +399,30 @@ public class WindowSnapshot
[JsonPropertyName("monitor")] [JsonPropertyName("monitor")]
public int Monitor { get; set; } = 0; 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<string> TabUrls { get; set; } = new();
} }
public class WindowRect public class WindowRect

View File

@@ -181,6 +181,7 @@ public class SettingsViewModel : INotifyPropertyChanged
private bool _showWidgetCalendar; private bool _showWidgetCalendar;
private bool _showWidgetBattery; private bool _showWidgetBattery;
private bool _showLauncherBottomQuickActions; private bool _showLauncherBottomQuickActions;
private bool _enableBrowserSessionRestore;
private bool _shortcutHelpUseThemeColor; private bool _shortcutHelpUseThemeColor;
// LLM 공통 설정 // LLM 공통 설정
@@ -948,6 +949,12 @@ public class SettingsViewModel : INotifyPropertyChanged
set { _showLauncherBottomQuickActions = value; OnPropertyChanged(); } set { _showLauncherBottomQuickActions = value; OnPropertyChanged(); }
} }
public bool EnableBrowserSessionRestore
{
get => _enableBrowserSessionRestore;
set { _enableBrowserSessionRestore = value; OnPropertyChanged(); }
}
public bool EnableIconAnimation public bool EnableIconAnimation
{ {
get => _enableIconAnimation; get => _enableIconAnimation;
@@ -1245,6 +1252,7 @@ public class SettingsViewModel : INotifyPropertyChanged
_showWidgetCalendar = s.Launcher.ShowWidgetCalendar; _showWidgetCalendar = s.Launcher.ShowWidgetCalendar;
_showWidgetBattery = s.Launcher.ShowWidgetBattery; _showWidgetBattery = s.Launcher.ShowWidgetBattery;
_showLauncherBottomQuickActions = s.Launcher.ShowLauncherBottomQuickActions; _showLauncherBottomQuickActions = s.Launcher.ShowLauncherBottomQuickActions;
_enableBrowserSessionRestore = s.Launcher.EnableBrowserSessionRestore;
_shortcutHelpUseThemeColor = s.Launcher.ShortcutHelpUseThemeColor; _shortcutHelpUseThemeColor = s.Launcher.ShortcutHelpUseThemeColor;
_enableTextAction = s.Launcher.EnableTextAction; _enableTextAction = s.Launcher.EnableTextAction;
// v1.7.1: 파일 대화상자 통합 기본값을 false로 변경 (브라우저 업로드 오작동 방지) // v1.7.1: 파일 대화상자 통합 기본값을 false로 변경 (브라우저 업로드 오작동 방지)
@@ -1720,6 +1728,7 @@ public class SettingsViewModel : INotifyPropertyChanged
s.Launcher.ShowWidgetCalendar = _showWidgetCalendar; s.Launcher.ShowWidgetCalendar = _showWidgetCalendar;
s.Launcher.ShowWidgetBattery = _showWidgetBattery; s.Launcher.ShowWidgetBattery = _showWidgetBattery;
s.Launcher.ShowLauncherBottomQuickActions = _showLauncherBottomQuickActions; s.Launcher.ShowLauncherBottomQuickActions = _showLauncherBottomQuickActions;
s.Launcher.EnableBrowserSessionRestore = _enableBrowserSessionRestore;
s.Launcher.ShortcutHelpUseThemeColor = _shortcutHelpUseThemeColor; s.Launcher.ShortcutHelpUseThemeColor = _shortcutHelpUseThemeColor;
s.Launcher.EnableTextAction = _enableTextAction; s.Launcher.EnableTextAction = _enableTextAction;
s.Launcher.EnableFileDialogIntegration = _enableFileDialogIntegration; s.Launcher.EnableFileDialogIntegration = _enableFileDialogIntegration;

View File

@@ -3063,6 +3063,19 @@
</Grid> </Grid>
</Border> </Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left">
<TextBlock Style="{StaticResource RowLabel}" Text="브라우저 상태 복원"/>
<TextBlock Style="{StaticResource RowHint}"
Text="워크스페이스 저장 시 브라우저 탭과 현재 URL을 함께 캡처해 복원합니다. 저장 순간 브라우저 탭 포커스가 잠깐 이동할 수 있습니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}"
HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding EnableBrowserSessionRestore, Mode=TwoWay}"/>
</Grid>
</Border>
<!-- ── 기록 기능 ── --> <!-- ── 기록 기능 ── -->
<TextBlock Style="{StaticResource SectionHeader}" Text="기록 기능"/> <TextBlock Style="{StaticResource SectionHeader}" Text="기록 기능"/>