워크스페이스 브라우저 상태 복원 경로를 추가하고 설정 및 검증을 정리한다
변경 목적: ~ 워크스페이스 복원 시 창 배치뿐 아니라 브라우저 탭/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:
@@ -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 환경에 최적화된 키보드 중심 생산성 도구입니다.
|
||||||
|
|||||||
@@ -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`를 우선하도록 안내합니다.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
628
src/AxCopilot/Core/BrowserWorkspaceStateHelper.cs
Normal file
628
src/AxCopilot/Core/BrowserWorkspaceStateHelper.cs
Normal 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);
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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="기록 기능"/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user