diff --git a/BROWSER_FEATURES_PLAN.md b/BROWSER_FEATURES_PLAN.md
index 03b5264..4864d76 100644
--- a/BROWSER_FEATURES_PLAN.md
+++ b/BROWSER_FEATURES_PLAN.md
@@ -8,7 +8,7 @@
> - 1차(2026-08-13): Chrome/Firefox/Zen 3개 브라우저 단일 조사 → v1 초안
> - **2차(2026-08-15): 5개 전문 조사팀 병렬 심층 조사** — ① Chrome+Edge ② Firefox+Vivaldi ③ Zen+Arc ④ Brave+Opera+Safari+기타(Floorp/Orion/Sidekick/Comet) ⑤ **WebView2 API 검증팀(Microsoft Learn 팩트체크)**. 총 80여 회 웹 검색 + 공식 문서 직접 열람.
>
-> **확장 계획**: 모바일 앱 + PC↔모바일 연동은 **`MOBILE_PLAN.md`** 로 분리 (2026-08-15, 4개 조사팀 심층 조사 기반 — MAUI 10 컴패니언 앱, LAN QR 페어링, Core 엔진 재사용 검증 포함).
+> **확장 계획**: 모바일 앱 + PC↔모바일 연동은 **`MOBILE_PLAN.md`** 로 분리 (2026-08-15, 4개 조사팀 심층 조사 기반 — MAUI 10 컴패니언 앱, LAN QR 페어링, Core 엔진 재사용 검증 포함). 멀티플랫폼 배포(Win/macOS/Linux/모바일)는 **`DEPLOYMENT_PLAN.md`** 로 분리.
---
diff --git a/MOBILE_PLAN.md b/MOBILE_PLAN.md
index 2420894..2366f98 100644
--- a/MOBILE_PLAN.md
+++ b/MOBILE_PLAN.md
@@ -2,7 +2,7 @@
> **목표**: Windows 비디오 다운로더(내장 브라우저)에 **모바일 앱을 만들고 PC↔모바일을 연동**한다. 핵심 질문 — "모바일에서 이 앱과 무엇을 하게 할 것인가?"에 대한 조사 기반 답변.
>
-> **작성일**: 2026-08-15 · **관련 문서**: `BROWSER_FEATURES_PLAN.md` (v2, 데스크톱 브라우저 기능)
+> **작성일**: 2026-08-15 · **관련 문서**: `BROWSER_FEATURES_PLAN.md` (v2, 데스크톱 브라우저 기능) · `DEPLOYMENT_PLAN.md` (Win/macOS/Linux/모바일 배포 체계)
>
> **조사 방법론**: 4개 전문 조사팀 병렬 심층 조사 — ① 모바일 브라우저 UX(Chrome/Safari/Firefox/Samsung/Brave 모바일) ② PC↔모바일 연동 패턴(20여 개 제품: Chrome Send Tab, Handoff, Phone Link, LocalSend, KDE Connect, Jellyfin 등) ③ 모바일 영상 생태계(iOS/Android 제약, 다운로더 앱 생존 모델, 미디어 서버) ④ 기술 스택 검증(**실제 코드베이스 grep 포함**). 총 73회 웹 검색 + 공식 문서 열람.
diff --git a/VideoDownloader.App/Browser/Tabs/TabItem.cs b/VideoDownloader.App/Browser/Tabs/TabItem.cs
index 4f98a45..7bc220b 100644
--- a/VideoDownloader.App/Browser/Tabs/TabItem.cs
+++ b/VideoDownloader.App/Browser/Tabs/TabItem.cs
@@ -14,7 +14,6 @@ public sealed class TabItem : INotifyPropertyChanged
/// CoreWebView2 (초기화 전 null)
public Microsoft.Web.WebView2.Core.CoreWebView2? Core => View?.CoreWebView2;
-
private string _title = "새 탭";
public string Title { get => _title; set => Set(ref _title, value); }
@@ -45,6 +44,11 @@ public sealed class TabItem : INotifyPropertyChanged
/// 닫힌 시점의 인덱스(닫힌 탭 복원용). 열려 있는 동안은 무의미.
internal int ClosedIndex { get; set; } = -1;
+ // ---- 탭별 일시 상태(앱 레이어 전용) ----
+ internal string? PendingHistoryUrl; // SourceChanged → 타이틀 확정 사이의 기록 대기 URL
+ internal string? PendingSocialUrl; // 소셜 카드 제목 갱신 대기 URL
+ internal FindSession? FindSession; // 탭별 페이지 내 검색 세션 (cv 바인딩)
+
public event PropertyChangedEventHandler? PropertyChanged;
private void Set(ref T field, T value, [CallerMemberName] string? name = null)
diff --git a/VideoDownloader.App/MainWindow.xaml b/VideoDownloader.App/MainWindow.xaml
index eaafcb5..d1a1a08 100644
--- a/VideoDownloader.App/MainWindow.xaml
+++ b/VideoDownloader.App/MainWindow.xaml
@@ -153,6 +153,7 @@
+
@@ -209,8 +210,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -224,7 +285,7 @@
-
+
@@ -320,7 +381,7 @@
-
diff --git a/VideoDownloader.App/MainWindow.xaml.cs b/VideoDownloader.App/MainWindow.xaml.cs
index 14daff4..a8e26a4 100644
--- a/VideoDownloader.App/MainWindow.xaml.cs
+++ b/VideoDownloader.App/MainWindow.xaml.cs
@@ -10,10 +10,13 @@ using System.Windows.Interop;
using System.Windows.Media;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.App.Browser;
+using VideoDownloader.App.Browser.Tabs;
using VideoDownloader.App.Core;
using VideoDownloader.App.Detection;
using VideoDownloader.App.ViewModels;
using VideoDownloader.Core.Config;
+using Wv2 = Microsoft.Web.WebView2.Wpf;
+using TabItem = VideoDownloader.App.Browser.Tabs.TabItem;
namespace VideoDownloader.App;
@@ -23,18 +26,22 @@ public partial class MainWindow : Window
private AppConfig _config;
private int _totalRequests;
private string _appLogPath = "";
- private string _currentTitle = "";
+
+ // ---- 탭 아키텍처 (Phase A) ----
+ private CoreWebView2Environment? _env;
+ private TabManager _tabs = null!;
+ private readonly Dictionary _tabInit = new();
+ internal TabManager Tabs => _tabs;
+ internal Wv2.WebView2? ActiveView => _tabs?.ActiveTab?.View;
+ private CoreWebView2? Cv => _tabs?.ActiveTab?.Core;
private readonly ObservableCollection _bookmarks = new();
private readonly ObservableCollection _history = new();
- private string? _pendingHistoryUrl;
- private string? _pendingSocialUrl; // 타이틀 확정 시 소셜 카드 제목 갱신용
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
// T1-4 검색엔진 / T1-7 사이트별 줌 / T1-3 페이지 내 검색 / T1-11 전체화면
private SearchEngineResolver _resolver = null!;
private SiteZoomStore _zoomStore = null!;
- private FindSession? _findSession;
private bool _fullscreen;
private WindowState _stateBeforeFullscreen = WindowState.Normal;
@@ -51,6 +58,8 @@ public partial class MainWindow : Window
private const double MinExpandedWidth = 280;
private const double MaxExpandedWidth = 900;
+ private static string SessionPath => Path.Combine(AppDataDir, "session.json");
+
public MainWindow()
{
InitializeComponent();
@@ -142,44 +151,144 @@ public partial class MainWindow : Window
internal static string ProfileFolder => Path.Combine(AppDataDir, "webview");
+ // ==================== 탭 생명주기 (Phase A) ====================
+
private async Task InitializeBrowserAsync()
{
- CoreWebView2Environment? env = null;
try
{
// 명시적 영속 프로필: 기본(exe 디렉터리) 폴더는 빌드/재배포 시 날아가 로그인이 풀림 → %AppData% 로 고정.
Directory.CreateDirectory(ProfileFolder);
- env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: ProfileFolder);
- var options = env.CreateCoreWebView2ControllerOptions();
- if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
- await Browser.EnsureCoreWebView2Async(env, options);
+ _env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: ProfileFolder);
}
catch (Exception ex) { Log($"WebView2 초기화 실패: {ex.Message}"); return; }
- var cv = Browser.CoreWebView2;
+ _tabs = new TabManager(CreateTabItem, AttachTabView);
+ _tabs.ActiveChanged += OnActiveTabChanged;
+ _tabs.TabRemoved += OnTabRemoved;
+ TabList.ItemsSource = _tabs.Tabs;
UpdatePrivateButton();
+
+ // T1-10 세션 복원: 저장된 탭이 있으면 복원, 아니면 홈 탭
+ var snap = _config.RestoreSession ? TabSessionStore.Load(SessionPath) : null;
+ if (snap != null && snap.Urls.Count > 0)
+ _tabs.RestoreSession(snap);
+ else
+ _tabs.NewTab(_config.HomeUrl);
+ UpdateNavState();
+ }
+
+ private TabItem CreateTabItem() => new();
+
+ /// 탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드)
+ private void AttachTabView(TabItem tab)
+ {
+ var view = new Wv2.WebView2 { DefaultBackgroundColor = System.Drawing.Color.Black };
+ tab.View = view;
+ TabHost.Children.Add(view);
+ view.Visibility = tab.IsActive ? Visibility.Visible : Visibility.Collapsed;
+ _tabInit[tab] = InitTabCoreAsync(tab);
+ }
+
+ /// 탭의 CoreWebView2 초기화 + 탭별 이벤트 배선 + 초기 URL 탐색.
+ private async Task InitTabCoreAsync(TabItem tab)
+ {
+ try
+ {
+ var options = _env!.CreateCoreWebView2ControllerOptions();
+ if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
+ await tab.View!.EnsureCoreWebView2Async(_env, options);
+ }
+ catch (Exception ex)
+ {
+ Log($"탭 초기화 실패: {ex.Message}");
+ return;
+ }
+
+ var cv = tab.Core!;
cv.Settings.AreDevToolsEnabled = true;
- // T1-3 네이티브 Find API 세션 (자체 검색 바)
- _findSession = new FindSession(new WebView2FindApi(cv, env!));
- _findSession.StateChanged += () => Dispatcher.BeginInvoke(new Action(() =>
+ // T1-3 탭별 검색 세션 (네이티브 Find API)
+ tab.FindSession = new FindSession(new WebView2FindApi(cv, _env!));
+ tab.FindSession.StateChanged += () => Dispatcher.BeginInvoke(new Action(() =>
{
- if (_findSession != null) FindStatus.Text = _findSession.StatusText;
+ if (tab.IsActive && tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText;
}));
- // T1-7 사이트별 줌: 네비 완료 시 저장된 배율 적용 (WebView2 는 사이트별 영속 미보장)
+ // 탭별 네비게이션 상태
+ cv.NavigationStarting += (_, _) => Dispatcher.BeginInvoke(new Action(() => tab.IsLoading = true));
cv.NavigationCompleted += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
- try { Browser.ZoomFactor = _zoomStore.Get(cv.Source); } catch { }
+ tab.IsLoading = false;
+ try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7
+ }));
+ cv.HistoryChanged += (_, _) => UpdateNavState();
+
+ // 소셜/주소창/기록: 탭별 상태로 관리
+ cv.SourceChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ var url = cv.Source ?? "";
+ tab.Url = url;
+ if (tab.PendingHistoryUrl != null) RecordHistory(tab.PendingHistoryUrl, tab.PendingHistoryUrl);
+ if (tab.IsActive && !AddressBox.IsKeyboardFocused) AddressBox.Text = url;
+ tab.PendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url;
+ DetectSocial(tab, url);
+ }));
+ cv.DocumentTitleChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ var title = cv.DocumentTitle ?? "";
+ tab.Title = string.IsNullOrWhiteSpace(title) ? (tab.Url.StartsWith("http") ? tab.Url : "새 탭") : title;
+ if (tab.PendingHistoryUrl != null)
+ {
+ RecordHistory(string.IsNullOrWhiteSpace(title) ? tab.PendingHistoryUrl : title, tab.PendingHistoryUrl);
+ tab.PendingHistoryUrl = null;
+ }
+ // 소셜 카드 제목을 페이지 타이틀로 갱신(출력 파일명 포함, 시작 전일 때)
+ if (tab.PendingSocialUrl != null && !string.IsNullOrWhiteSpace(title))
+ {
+ _manager.RenameSocial(new Uri(tab.PendingSocialUrl), title);
+ tab.PendingSocialUrl = null;
+ }
}));
- cv.WebResourceResponseReceived += OnResponseReceived;
- // #4 팝업/새창을 현재 WebView2 로 강제 라우팅
- cv.NewWindowRequested += (_, ne) =>
+ // T1-8 오디오 표시/뮤트
+ try
{
- try { if (ne.Uri != null) { ne.Handled = true; cv.Navigate(ne.Uri); } }
- catch { }
+ cv.IsDocumentPlayingAudioChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ try { tab.IsAudioPlaying = cv.IsDocumentPlayingAudio; } catch { }
+ }));
+ cv.IsMutedChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ try { tab.IsMuted = cv.IsMuted; } catch { }
+ }));
+ }
+ catch { } // 구현체/타이밍 이슈 대비
+
+ // 미디어 감지(탭 무관 전역)
+ cv.WebResourceResponseReceived += OnResponseReceived;
+
+ // Phase A: 팝업/새창 → 새 탭 (deferral 필수 — 탭 생성이 비동기)
+ cv.NewWindowRequested += async (_, ne) =>
+ {
+ try
+ {
+ var deferral = ne.GetDeferral();
+ try
+ {
+ var newTab = _tabs.NewTab(activate: false); // URL 없이 생성 → 팝업 내용이 새 창으로 흘러들어옴
+ await WaitTabReady(newTab);
+ ne.NewWindow = newTab.Core;
+ ne.Handled = true;
+ }
+ finally { deferral.Complete(); }
+ }
+ catch
+ {
+ try { ne.Handled = true; if (ne.Uri != null) cv.Navigate(ne.Uri); } catch { }
+ }
};
+
// #13 페이지 내 다운로드(a[download])를 저장 폴더로 라우팅
cv.DownloadStarting += (_, de) =>
{
@@ -194,46 +303,90 @@ public partial class MainWindow : Window
}
catch { }
};
- cv.HistoryChanged += (_, _) => UpdateNavState();
- cv.SourceChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
- {
- if (_pendingHistoryUrl != null) RecordHistory(_pendingHistoryUrl, _pendingHistoryUrl);
- var url = cv.Source ?? "";
- AddressBox.Text = url;
- _pendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url;
- DetectSocial(url);
- }));
- cv.DocumentTitleChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
- {
- _currentTitle = cv.DocumentTitle ?? "";
- if (_pendingHistoryUrl != null)
- {
- RecordHistory(string.IsNullOrWhiteSpace(_currentTitle) ? _pendingHistoryUrl : _currentTitle, _pendingHistoryUrl);
- _pendingHistoryUrl = null;
- }
- // 소셜 카드 제목을 페이지 타이틀로 갱신(출력 파일명 포함, 시작 전일 때)
- if (_pendingSocialUrl != null && !string.IsNullOrWhiteSpace(_currentTitle))
- {
- _manager.RenameSocial(new Uri(_pendingSocialUrl), _currentTitle);
- _pendingSocialUrl = null;
- }
- }));
- NavHome_Click(this, null!);
+ // 초기 URL (세션 복원/빈 탭 제외)
+ if (!string.IsNullOrEmpty(tab.Url) && (cv.Source == null || cv.Source.Length == 0))
+ {
+ try { cv.Navigate(tab.Url); } catch { }
+ }
+ }
+
+ /// 탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용).
+ private async Task WaitTabReady(TabItem tab)
+ {
+ if (tab.Core != null) return;
+ if (_tabInit.TryGetValue(tab, out var t)) { try { await t; } catch { } return; }
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ while (tab.Core == null && sw.ElapsedMilliseconds < 15000)
+ await Task.Delay(50);
+ }
+
+ private void OnActiveTabChanged(TabItem tab)
+ {
+ foreach (var t in _tabs.Tabs)
+ if (t.View != null) t.View.Visibility = ReferenceEquals(t, tab) ? Visibility.Visible : Visibility.Collapsed;
+ if (!AddressBox.IsKeyboardFocused) AddressBox.Text = tab.Url;
UpdateNavState();
}
+ private void OnTabRemoved(TabItem tab)
+ {
+ if (tab.View != null)
+ {
+ TabHost.Children.Remove(tab.View);
+ tab.View = null; // 재오픈 시 새 뷰로 재생성(ReopenClosedTab → AttachTabView)
+ }
+ _tabInit.Remove(tab);
+ }
+
private void UpdateNavState()
{
- var cv = Browser.CoreWebView2;
- if (cv == null) return;
+ var cv = Cv;
Dispatcher.BeginInvoke(new Action(() =>
{
- NavBack.IsEnabled = cv.CanGoBack;
- NavForward.IsEnabled = cv.CanGoForward;
+ NavBack.IsEnabled = cv?.CanGoBack == true;
+ NavForward.IsEnabled = cv?.CanGoForward == true;
}));
}
+ // ---- 탭 스트립 UI 이벤트 ----
+ private void NewTab_Click(object sender, RoutedEventArgs e) => _tabs.NewTab(_config.HomeUrl);
+
+ private void Tab_Click(object sender, MouseButtonEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.DataContext is TabItem tab)
+ _tabs.Activate(tab);
+ }
+
+ private void Tab_PreviewMouseDown(object sender, MouseButtonEventArgs e)
+ {
+ // 휠 클릭(미들) → 탭 닫기 (브라우저 관례)
+ if (e.ChangedButton == MouseButton.Middle && e.ButtonState == MouseButtonState.Pressed)
+ {
+ if ((sender as FrameworkElement)?.DataContext is TabItem tab)
+ {
+ _tabs.CloseTab(tab);
+ e.Handled = true;
+ }
+ }
+ }
+
+ private void TabClose_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.DataContext is TabItem tab)
+ _tabs.CloseTab(tab);
+ }
+
+ // T1-8 스피커 아이콘 클릭 → 음소거 토글
+ private void TabAudio_Click(object sender, MouseButtonEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.DataContext is TabItem { Core: { } cv } tab)
+ {
+ try { cv.IsMuted = !cv.IsMuted; tab.IsMuted = cv.IsMuted; } catch { }
+ }
+ e.Handled = true;
+ }
+
private void OnResponseReceived(object? sender, CoreWebView2WebResourceResponseReceivedEventArgs e)
{
try
@@ -273,7 +426,7 @@ public partial class MainWindow : Window
}
catch { }
- string referer = Browser.CoreWebView2?.Source ?? "";
+ string referer = (sender as CoreWebView2)?.Source ?? Cv?.Source ?? "";
try { var r = e.Request.Headers.GetHeader("Referer"); if (!string.IsNullOrEmpty(r)) referer = r; } catch { }
var kind = MediaDetector.Classify(uri, ct, len, _config);
@@ -348,7 +501,7 @@ public partial class MainWindow : Window
private async Task> GetCookiesAsync(Uri url)
{
- var cv = Browser.CoreWebView2;
+ var cv = Cv;
if (cv == null) return new();
try { return (await cv.CookieManager.GetCookiesAsync(url.ToString())).ToList(); }
catch { return new(); }
@@ -455,9 +608,9 @@ public partial class MainWindow : Window
}
}
- private void NavBack_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.GoBack();
- private void NavForward_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.GoForward();
- private void NavRefresh_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.Reload();
+ private void NavBack_Click(object sender, RoutedEventArgs e) => Cv?.GoBack();
+ private void NavForward_Click(object sender, RoutedEventArgs e) => Cv?.GoForward();
+ private void NavRefresh_Click(object sender, RoutedEventArgs e) => Cv?.Reload();
private void NavHome_Click(object sender, RoutedEventArgs e) => NavigateTo(_config.HomeUrl);
internal void NavigateTo(string? url)
@@ -465,21 +618,21 @@ public partial class MainWindow : Window
// T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당
var intent = _resolver.Resolve(url);
if (intent == null) return;
- Browser.CoreWebView2?.Navigate(intent.Target);
+ Cv?.Navigate(intent.Target);
}
- // 소셜 영상 페이지(YouTube/Instagram/X/...) 감지 → yt-dlp 카드 등록.
- private void DetectSocial(string? url)
+ // 소셜 영상 페이지(YouTube/Instagram/X/...) 감지 → yt-dlp 카드 등록. (탭별 상태)
+ private void DetectSocial(TabItem tab, string? url)
{
- if (string.IsNullOrEmpty(url)) { _pendingSocialUrl = null; return; }
- if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { _pendingSocialUrl = null; return; }
+ if (string.IsNullOrEmpty(url)) { tab.PendingSocialUrl = null; return; }
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { tab.PendingSocialUrl = null; return; }
if (SocialSiteDetector.TryMatch(uri, out var normalized, out var label))
{
_manager.LogPanel($"★ 소셜 감지 [{label}]: {normalized}");
_manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label));
- _pendingSocialUrl = normalized;
+ tab.PendingSocialUrl = normalized;
}
- else _pendingSocialUrl = null;
+ else tab.PendingSocialUrl = null;
}
// ---- 링크로 받기 (URL 붙여넣기) ----
@@ -506,27 +659,40 @@ public partial class MainWindow : Window
{
_manager.LogPanel($"★ 소셜 감지 [{label}]: {normalized}");
_manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label));
- _pendingSocialUrl = normalized;
LinkPopup.IsOpen = false;
Log("카드에 추가했습니다. 화질을 선택하고 받기를 누르세요");
}
else Log("지원하는 소셜 영상 링크가 아닙니다 (YouTube·Instagram·X·TikTok·Facebook)");
}
- // #7 전역 키보드 단축키 (+T1-3/T1-9/T1-11)
+ // #7 전역 키보드 단축키 (+Phase A 탭/T1-3/T1-9/T1-11)
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
- var cv = Browser.CoreWebView2;
+ var cv = Cv;
var mods = Keyboard.Modifiers;
+ // Phase A 탭 단축키
+ if (mods == ModifierKeys.Control && e.Key == Key.T) { _tabs.NewTab(_config.HomeUrl); e.Handled = true; }
+ else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.T) { _tabs.ReopenClosedTab(); e.Handled = true; }
+ else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.W) { _tabs.CloseTab(_tabs.ActiveTab!); e.Handled = true; }
+ else if (mods == ModifierKeys.Control && e.Key == Key.W) { if (_tabs.ActiveTab != null) _tabs.CloseTab(_tabs.ActiveTab); e.Handled = true; }
+ else if (mods == ModifierKeys.Control && e.Key == Key.Tab) { _tabs.ActivateNext(); e.Handled = true; }
+ else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.Tab) { _tabs.ActivatePrevious(); e.Handled = true; }
+ else if (mods == ModifierKeys.Control && e.Key >= Key.D1 && e.Key <= Key.D9)
+ {
+ var idx = e.Key - Key.D1;
+ if (idx == 8) _tabs.ActivateLast(); else _tabs.ActivateAt(idx); // Ctrl+9 = 마지막 탭
+ e.Handled = true;
+ }
// T1-3 검색 바가 열려 있으면 Esc 는 닫기가 우선
- if (e.Key == Key.Escape && FindBar.Visibility == Visibility.Visible) { CloseFind(); e.Handled = true; }
+ else if (e.Key == Key.Escape && FindBar.Visibility == Visibility.Visible) { CloseFind(); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.F) { OpenFind(); e.Handled = true; }
else if (e.Key == Key.F3)
{
if (FindBar.Visibility == Visibility.Visible)
{
- if (mods == ModifierKeys.Shift) _findSession?.Previous(); else _findSession?.Next();
+ if (mods == ModifierKeys.Shift) _tabs.ActiveTab?.FindSession?.Previous();
+ else _tabs.ActiveTab?.FindSession?.Next();
}
else OpenFind();
e.Handled = true;
@@ -540,66 +706,80 @@ public partial class MainWindow : Window
else if (mods == ModifierKeys.Alt && e.Key == Key.Right) { try { if (cv?.CanGoForward == true) cv.GoForward(); } catch { } e.Handled = true; }
else if (mods == ModifierKeys.Control)
{
- if (e.Key == Key.OemPlus || e.Key == Key.Add) { try { Browser.ZoomFactor = Math.Min(Browser.ZoomFactor + 0.1, 5); PersistZoom(); } catch { } e.Handled = true; }
- else if (e.Key == Key.OemMinus || e.Key == Key.Subtract) { try { Browser.ZoomFactor = Math.Max(Browser.ZoomFactor - 0.1, 0.1); PersistZoom(); } catch { } e.Handled = true; }
- else if (e.Key == Key.D0) { try { Browser.ZoomFactor = 1.0; PersistZoom(); } catch { } e.Handled = true; }
+ if (e.Key == Key.OemPlus || e.Key == Key.Add) { try { ZoomBy(+0.1); } catch { } e.Handled = true; }
+ else if (e.Key == Key.OemMinus || e.Key == Key.Subtract) { try { ZoomBy(-0.1); } catch { } e.Handled = true; }
+ else if (e.Key == Key.D0) { try { ZoomSet(1.0); } catch { } e.Handled = true; }
}
}
- // ---- T1-3 페이지 내 검색 바 ----
+ private void ZoomBy(double delta) => ZoomSet(Math.Clamp((ActiveView?.ZoomFactor ?? 1.0) + delta, 0.1, 5.0));
+
+ private void ZoomSet(double zoom)
+ {
+ if (ActiveView == null) return;
+ ActiveView.ZoomFactor = zoom;
+ PersistZoom();
+ }
+
+ // ---- T1-3 페이지 내 검색 바 (활성 탭 세션) ----
internal void OpenFind()
{
FindBar.Visibility = Visibility.Visible;
FindStatus.Text = "";
FindBox.Focus();
FindBox.SelectAll();
- if (FindBox.Text.Length > 0) _ = _findSession?.StartAsync(FindBox.Text);
+ if (FindBox.Text.Length > 0) _ = _tabs.ActiveTab?.FindSession?.StartAsync(FindBox.Text);
}
internal void CloseFind()
{
FindBar.Visibility = Visibility.Collapsed;
- _findSession?.Close();
+ _tabs.ActiveTab?.FindSession?.Close();
FindStatus.Text = "";
}
private void FindBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (FindBar.Visibility == Visibility.Visible)
- _ = _findSession?.StartAsync(FindBox.Text);
+ _ = _tabs.ActiveTab?.FindSession?.StartAsync(FindBox.Text);
}
private void FindBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
- if (e.Key == Key.Enter) { if (Keyboard.Modifiers == ModifierKeys.Shift) _findSession?.Previous(); else _findSession?.Next(); e.Handled = true; }
+ if (e.Key == Key.Enter)
+ {
+ if (Keyboard.Modifiers == ModifierKeys.Shift) _tabs.ActiveTab?.FindSession?.Previous();
+ else _tabs.ActiveTab?.FindSession?.Next();
+ e.Handled = true;
+ }
else if (e.Key == Key.Escape) { CloseFind(); e.Handled = true; }
}
- private void FindNext_Click(object sender, RoutedEventArgs e) => _findSession?.Next();
- private void FindPrev_Click(object sender, RoutedEventArgs e) => _findSession?.Previous();
+ private void FindNext_Click(object sender, RoutedEventArgs e) => _tabs.ActiveTab?.FindSession?.Next();
+ private void FindPrev_Click(object sender, RoutedEventArgs e) => _tabs.ActiveTab?.FindSession?.Previous();
private void FindClose_Click(object sender, RoutedEventArgs e) => CloseFind();
private void FindOption_Changed(object sender, RoutedEventArgs e)
- => _findSession?.SetOptions(FindCaseBox.IsChecked == true, FindWordBox.IsChecked == true);
+ => _tabs.ActiveTab?.FindSession?.SetOptions(FindCaseBox.IsChecked == true, FindWordBox.IsChecked == true);
// T1-7 줌 변경을 사이트별 저장소에 영속
private void PersistZoom()
{
- var cv = Browser.CoreWebView2;
- if (cv != null) _zoomStore.Set(cv.Source, Browser.ZoomFactor);
+ var cv = Cv;
+ if (cv != null && ActiveView != null) _zoomStore.Set(cv.Source, ActiveView.ZoomFactor);
}
// T1-9 강제 새로고침: 디스크 캐시 비우고 다시 로드
private async void ForceReload()
{
- var cv = Browser.CoreWebView2;
+ var cv = Cv;
if (cv == null) return;
try { await cv.Profile.ClearBrowsingDataAsync(CoreWebView2BrowsingDataKinds.DiskCache); }
catch { }
try { cv.Reload(); } catch { }
}
- // T1-11 전체화면(F11): 툴바/상태바 숨기고 최대화. 재누르면 복원.
+ // T1-11 전체화면(F11): 툴바/탭/상태바 숨기고 최대화. 재누르면 복원.
internal void ToggleFullscreen()
{
_fullscreen = !_fullscreen;
@@ -613,6 +793,7 @@ public partial class MainWindow : Window
WindowState = _stateBeforeFullscreen;
}
ToolbarRow.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
+ TabStripRow.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
StatusBar.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
}
@@ -672,6 +853,8 @@ public partial class MainWindow : Window
_config.RightPanelCollapsed = _panelCollapsed;
if (!_panelCollapsed) _config.RightPanelWidth = ClampPanelWidth(RightCol.Width.Value);
ConfigStore.Save(_config);
+ // T1-10 세션 저장: 탭 URL/활성/핀 보존
+ try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { }
}
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
@@ -707,7 +890,7 @@ public partial class MainWindow : Window
/// 현재 프로필의 브라우저 데이터(쿠키·DOM 저장소·캐시) 삭제. 일반 모드: 영속 프로필 정리.
public async Task ClearBrowsingDataAsync()
{
- var cv = Browser.CoreWebView2;
+ var cv = Cv;
if (cv == null) { Log("브라우저가 아직 준비되지 않았습니다"); return; }
try
{
@@ -727,7 +910,7 @@ public partial class MainWindow : Window
private void AddBookmark_Click(object sender, RoutedEventArgs e)
{
- var cv = Browser.CoreWebView2;
+ var cv = Cv;
var url = cv?.Source ?? "";
if (string.IsNullOrEmpty(url) || url.StartsWith("about:", StringComparison.OrdinalIgnoreCase)) return;
if (_bookmarks.Any(b => b.Url == url)) { Log("이미 즐겨찾기에 있습니다"); return; }
diff --git a/VideoDownloader.Core/Config/AppConfig.cs b/VideoDownloader.Core/Config/AppConfig.cs
index 5fcc707..cdec592 100644
--- a/VideoDownloader.Core/Config/AppConfig.cs
+++ b/VideoDownloader.Core/Config/AppConfig.cs
@@ -70,4 +70,7 @@ public class AppConfig
/// 주소창 기본 검색 엔진(SearchEngines.Presets 의 Name). 검색어 입력 시 이 엔진으로 검색.
public string SearchEngine { get; set; } = "Google";
+
+ /// 종료 시 탭을 저장하고 다음 시작에 복원(T1-10 세션 복원).
+ public bool RestoreSession { get; set; } = true;
}
diff --git a/VideoDownloader.Tests/E2E/MainWindowSmokeE2ETests.cs b/VideoDownloader.Tests/E2E/MainWindowSmokeE2ETests.cs
index aae07ce..324ab17 100644
--- a/VideoDownloader.Tests/E2E/MainWindowSmokeE2ETests.cs
+++ b/VideoDownloader.Tests/E2E/MainWindowSmokeE2ETests.cs
@@ -5,14 +5,14 @@ using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
///
-/// E2E 스모크: 실제 MainWindow 를 기동해 WebView2 초기화·탐색·검색 바·전체화면 전환이
-/// 크래시 없이 동작하는지 검증한다(Phase 0 통합 확인). 네트워크 의존 제거를 위해 about:blank 사용.
+/// E2E (Phase A): 실제 MainWindow 기동 → WebView2 초기화 → about:blank 탐색 →
+/// 다중 탭(생성/전환/닫기/Ctrl+Shift+T 복원) + 검색 바 + 전체화면까지 실 창에서 검증.
///
[Collection("E2E")]
public class MainWindowSmokeE2ETests
{
[Fact]
- public async Task MainWindow_기동_탐색_검색바_전체화면_스모크()
+ public async Task MainWindow_기동_탐색_탭전환_닫기_복원_검색바_전체화면_E2E()
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
@@ -21,38 +21,70 @@ public class MainWindowSmokeE2ETests
var window = new MainWindow();
window.Show();
- // WebView2 초기화 대기 (Loaded → InitializeBrowserAsync)
- var cv = await Wait.Until(
- () => Task.FromResult(window.Browser.CoreWebView2),
- c => c != null, 30000);
+ // 첫 탭 WebView2 초기화 대기
+ var first = await Wait.Until(
+ () => Task.FromResult(window.Tabs.ActiveTab),
+ t => t?.Core != null, 30000);
+ var cv1 = first.Core!;
// about:blank 탐색 (네트워크 불필요)
window.NavigateTo("about:blank");
- await Wait.Until(
- () => Task.FromResult(cv.Source ?? ""),
- s => s.Contains("about:blank"), 15000);
+ await Wait.Until(() => Task.FromResult(cv1.Source ?? ""), s => s.Contains("about:blank"), 15000);
- // T1-3 검색 바 열기/닫기
+ // ── Phase A: 다중 탭 ──
+ var tab2 = window.Tabs.NewTab();
+ var cv2 = await Wait.Until(() => Task.FromResult(tab2.Core), c => c != null, 30000);
+ cv2.NavigateToString("hello-tab2
");
+ await WaitUntilNavCompleted(cv2);
+
+ Assert.NotSame(first, window.Tabs.ActiveTab); // NewTab 이 활성화
+ Assert.Equal(2, window.Tabs.Tabs.Count);
+
+ // 탭 전환: 첫 탭으로
+ window.Tabs.Activate(first);
+ var switched = ReferenceEquals(window.Tabs.ActiveTab, first);
+
+ // 탭 닫기 → 오른쪽 이웃 활성화 → Ctrl+Shift+T 복원
+ window.Tabs.CloseTab(tab2);
+ var countAfterClose = window.Tabs.Tabs.Count;
+ var reopened = window.Tabs.ReopenClosedTab();
+ var countAfterReopen = window.Tabs.Tabs.Count;
+
+ // ── T1-3 검색 바 ──
window.OpenFind();
var findVisible = window.FindBar.Visibility == Visibility.Visible;
window.CloseFind();
var findCollapsed = window.FindBar.Visibility == Visibility.Collapsed;
- // T1-11 전체화면 토글
+ // ── T1-11 전체화면 ──
window.ToggleFullscreen();
- var toolbarHidden = window.ToolbarRow.Visibility == Visibility.Collapsed;
+ var toolbarHidden = window.ToolbarRow.Visibility == Visibility.Collapsed
+ && window.TabStripRow.Visibility == Visibility.Collapsed;
window.ToggleFullscreen();
var toolbarBack = window.ToolbarRow.Visibility == Visibility.Visible;
window.Close();
- return (init: cv != null, nav: true, findVisible, findCollapsed, toolbarHidden, toolbarBack);
+ return (init: true, switched, countAfterClose, reopened: reopened != null, countAfterReopen,
+ findVisible, findCollapsed, toolbarHidden, toolbarBack);
});
Assert.True(ok.init, "WebView2 초기화 실패");
- Assert.True(ok.nav);
+ Assert.True(ok.switched, "탭 전환 실패");
+ Assert.True(ok.countAfterClose == 1, $"탭 닫기 후 개수 오류: {ok.countAfterClose}");
+ Assert.True(ok.reopened, "닫은 탭 복원 실패");
+ Assert.True(ok.countAfterReopen == 2, $"복원 후 개수 오류: {ok.countAfterReopen}");
Assert.True(ok.findVisible, "검색 바 미표시");
Assert.True(ok.findCollapsed, "검색 바 미닫힘");
- Assert.True(ok.toolbarHidden, "전체화면에서 툴바 미숨김");
+ Assert.True(ok.toolbarHidden, "전체화면에서 크롬 미숨김");
Assert.True(ok.toolbarBack, "전체화면 복원 실패");
}
+
+ private static Task WaitUntilNavCompleted(Microsoft.Web.WebView2.Core.CoreWebView2 cv)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ EventHandler? h = null;
+ h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
+ cv.NavigationCompleted += h;
+ return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
+ }
}