refactor(4): TabViewService extracts WebView2 lifecycle/wiring from MainWindow (god-class dissolved) - all 66 tests incl. tab E2E stay GREEN

This commit is contained in:
Yun Chan 2026-08-15 15:04:15 +09:00
parent e8ec1350b3
commit af19dba8f4
2 changed files with 227 additions and 140 deletions

View file

@ -30,7 +30,6 @@ public partial class MainWindow : Window
// ---- 탭 아키텍처 (Phase A) ---- // ---- 탭 아키텍처 (Phase A) ----
private CoreWebView2Environment? _env; private CoreWebView2Environment? _env;
private TabManager _tabs = null!; private TabManager _tabs = null!;
private readonly Dictionary<TabItem, Task> _tabInit = new();
internal TabManager Tabs => _tabs; internal TabManager Tabs => _tabs;
internal Wv2.WebView2? ActiveView => _tabs?.ActiveTab?.View; internal Wv2.WebView2? ActiveView => _tabs?.ActiveTab?.View;
private CoreWebView2? Cv => _tabs?.ActiveTab?.Core; private CoreWebView2? Cv => _tabs?.ActiveTab?.Core;
@ -162,7 +161,21 @@ public partial class MainWindow : Window
} }
catch (Exception ex) { Log($"WebView2 초기화 실패: {ex.Message}"); return; } catch (Exception ex) { Log($"WebView2 초기화 실패: {ex.Message}"); return; }
_tabs = new TabManager(CreateTabItem, AttachTabView); _views = new Services.TabViewService(_env!, _config, TabHost, _zoomStore, Dispatcher)
{
NewTabProvider = () => Task.FromResult(_tabs.NewTab(activate: false)),
};
_views.TabCoreReady += (_, cv) => cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지
_views.DownloadStarting += OnDownloadStarting;
_views.UrlChanged += OnUrlChanged;
_views.TitleChanged += OnTitleChanged;
_views.HistoryChanged += UpdateNavState;
_views.FindStateChanged += tab =>
{
if (tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText;
};
_tabs = new TabManager(() => new TabItem(), _views.AttachView);
_tabs.ActiveChanged += OnActiveTabChanged; _tabs.ActiveChanged += OnActiveTabChanged;
_tabs.TabRemoved += OnTabRemoved; _tabs.TabRemoved += OnTabRemoved;
TabList.ItemsSource = _tabs.Tabs; TabList.ItemsSource = _tabs.Tabs;
@ -177,65 +190,19 @@ public partial class MainWindow : Window
UpdateNavState(); UpdateNavState();
} }
private TabItem CreateTabItem() => new(); /// <summary>탭별 WebView2 생명주기·배선 전담 서비스(재구성 ④).</summary>
private Services.TabViewService? _views;
/// <summary>탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드)</summary> private void OnUrlChanged(TabItem tab, string url)
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);
}
/// <summary>탭의 CoreWebView2 초기화 + 탭별 이벤트 배선 + 초기 URL 탐색.</summary>
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)
tab.FindSession = new FindSession(new WebView2FindApi(cv, _env!));
tab.FindSession.StateChanged += () => Dispatcher.BeginInvoke(new Action(() =>
{
if (tab.IsActive && tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText;
}));
// 탭별 네비게이션 상태
cv.NavigationStarting += (_, _) => Dispatcher.BeginInvoke(new Action(() => tab.IsLoading = true));
cv.NavigationCompleted += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
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.PendingHistoryUrl != null) RecordHistory(tab.PendingHistoryUrl, tab.PendingHistoryUrl);
if (tab.IsActive && !AddressBox.IsKeyboardFocused) AddressBox.Text = url; if (tab.IsActive && !AddressBox.IsKeyboardFocused) AddressBox.Text = url;
tab.PendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url; tab.PendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url;
DetectSocial(tab, url); DetectSocial(tab, url);
})); }
cv.DocumentTitleChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
private void OnTitleChanged(TabItem tab, string title)
{ {
var title = cv.DocumentTitle ?? "";
tab.Title = string.IsNullOrWhiteSpace(title) ? (tab.Url.StartsWith("http") ? tab.Url : "새 탭") : title; tab.Title = string.IsNullOrWhiteSpace(title) ? (tab.Url.StartsWith("http") ? tab.Url : "새 탭") : title;
if (tab.PendingHistoryUrl != null) if (tab.PendingHistoryUrl != null)
{ {
@ -248,48 +215,9 @@ public partial class MainWindow : Window
_manager.RenameSocial(new Uri(tab.PendingSocialUrl), title); _manager.RenameSocial(new Uri(tab.PendingSocialUrl), title);
tab.PendingSocialUrl = null; tab.PendingSocialUrl = null;
} }
}));
// T1-8 오디오 표시/뮤트
try
{
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 { } // 구현체/타이밍 이슈 대비
// 미디어 감지(탭 무관 전역) private void OnDownloadStarting(CoreWebView2 cv, CoreWebView2DownloadStartingEventArgs de)
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) =>
{ {
try try
{ {
@ -301,23 +229,6 @@ public partial class MainWindow : Window
_manager.LogPanel($"📥 페이지 다운로드 → 저장 폴더: {fn}"); _manager.LogPanel($"📥 페이지 다운로드 → 저장 폴더: {fn}");
} }
catch { } catch { }
};
// 초기 URL (세션 복원/빈 탭 제외)
if (!string.IsNullOrEmpty(tab.Url) && (cv.Source == null || cv.Source.Length == 0))
{
try { cv.Navigate(tab.Url); } catch { }
}
}
/// <summary>탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용).</summary>
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) private void OnActiveTabChanged(TabItem tab)
@ -328,15 +239,7 @@ public partial class MainWindow : Window
UpdateNavState(); UpdateNavState();
} }
private void OnTabRemoved(TabItem tab) private void OnTabRemoved(TabItem tab) => _views?.RemoveView(tab);
{
if (tab.View != null)
{
TabHost.Children.Remove(tab.View);
tab.View = null; // 재오픈 시 새 뷰로 재생성(ReopenClosedTab → AttachTabView)
}
_tabInit.Remove(tab);
}
private void UpdateNavState() private void UpdateNavState()
{ {

View file

@ -0,0 +1,184 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.Browser;
using VideoDownloader.Browser.Tabs;
using VideoDownloader.Core.Config;
using Wv2 = Microsoft.Web.WebView2.Wpf;
using TabItem = VideoDownloader.Browser.Tabs.TabItem;
namespace VideoDownloader.App.Services;
/// <summary>
/// 탭별 WebView2 생명주기·이벤트 배선 전담(재구성 ④ — MainWindow 갓-클래스 해체).
/// 일반 브라우저 동작(제목/URL/로딩/오디오/검색세션/줌 적용/초기 탐색/뉴윈도우→탭)은
/// 이 서비스가, 앱 고유 기능(미디어 감지/다운로드 라우팅/소셜/기록/주소창 반영)은
/// 이벤트 훅으로 호스트(MainWindow)가 주입한다.
/// </summary>
public sealed class TabViewService
{
private readonly CoreWebView2Environment _env;
private readonly AppConfig _config;
private readonly Panel _host;
private readonly SiteZoomStore _zoomStore;
private readonly Dispatcher _dispatcher;
private readonly Dictionary<TabItem, Task> _init = new();
/// <summary>뉴윈도우(팝업) 요청 시 새 탭을 만들어 반환하는 공급자(호스트가 TabManager 에 연결).</summary>
public Func<Task<TabItem>>? NewTabProvider { get; set; }
/// <summary>탭 CoreWebView2 초기화 완료(호스트가 감지 등 탭별 구독 부착).</summary>
public event Action<TabItem, CoreWebView2>? TabCoreReady;
/// <summary>페이지 내 a[download] 등 다운로드 시작(호스트가 저장 폴더로 라우팅).</summary>
public event Action<CoreWebView2, CoreWebView2DownloadStartingEventArgs>? DownloadStarting;
/// <summary>URL 확정(SourceChanged). 인자: (탭, url).</summary>
public event Action<TabItem, string>? UrlChanged;
/// <summary>문서 타이틀 확정(DocumentTitleChanged). 인자: (탭, title).</summary>
public event Action<TabItem, string>? TitleChanged;
/// <summary>탭 뒤로/앞으로 가능 상태 변화(네비 버튼 갱신용).</summary>
public event Action? HistoryChanged;
public TabViewService(CoreWebView2Environment env, AppConfig config, Panel host,
SiteZoomStore zoomStore, Dispatcher dispatcher)
{
_env = env;
_config = config;
_host = host;
_zoomStore = zoomStore;
_dispatcher = dispatcher;
}
public TabItem CreateTabItem() => new();
/// <summary>탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드)</summary>
public void AttachView(TabItem tab)
{
var view = new Wv2.WebView2 { DefaultBackgroundColor = System.Drawing.Color.Black };
tab.View = view;
_host.Children.Add(view);
view.Visibility = tab.IsActive ? Visibility.Visible : Visibility.Collapsed;
_init[tab] = InitCoreAsync(tab);
}
/// <summary>탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용).</summary>
public async Task WaitTabReady(TabItem tab)
{
if (tab.Core != null) return;
if (_init.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);
}
/// <summary>탭 제거 시 뷰를 호스트에서 떼고 재오픈 가능 상태로(null) 만든다.</summary>
public void RemoveView(TabItem tab)
{
if (tab.View != null)
{
_host.Children.Remove(tab.View);
tab.View = null; // 재오픈 시 새 뷰로 재생성(ReopenClosedTab → AttachView)
}
_init.Remove(tab);
}
private async Task InitCoreAsync(TabItem tab)
{
try
{
var options = _env.CreateCoreWebView2ControllerOptions();
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
await tab.View!.EnsureCoreWebView2Async(_env, options);
}
catch (Exception)
{
return; // 초기화 실패 탭 — 호스트가 로그 표시는 TitleChanged 로 알림 없이 둔다(기존 동작)
}
var cv = tab.Core!;
cv.Settings.AreDevToolsEnabled = true;
// 탭별 검색 세션 (네이티브 Find API)
tab.FindSession = new FindSession(new WebView2FindApi(cv, _env));
tab.FindSession.StateChanged += () => _dispatcher.BeginInvoke(new Action(() =>
{
if (tab.IsActive && tab.FindSession != null) FindStateChanged?.Invoke(tab);
}));
// 네비게이션 상태
cv.NavigationStarting += (_, _) => _dispatcher.BeginInvoke(new Action(() => tab.IsLoading = true));
cv.NavigationCompleted += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
{
tab.IsLoading = false;
try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7 사이트별 줌
}));
cv.HistoryChanged += (_, _) => HistoryChanged?.Invoke();
cv.SourceChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
{
var url = cv.Source ?? "";
tab.Url = url;
UrlChanged?.Invoke(tab, url);
}));
cv.DocumentTitleChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
{
TitleChanged?.Invoke(tab, cv.DocumentTitle ?? "");
}));
// T1-8 오디오 표시/뮤트
try
{
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 { }
// 뉴윈도우(팝업) → 새 탭 (deferral 필수 — 탭 생성이 비동기)
cv.NewWindowRequested += async (_, ne) =>
{
try
{
var deferral = ne.GetDeferral();
try
{
var newTab = NewTabProvider != null ? await NewTabProvider() : null;
if (newTab != null)
{
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 { }
}
};
// 페이지 내 다운로드 → 호스트 훅
cv.DownloadStarting += (_, de) => DownloadStarting?.Invoke(cv, de);
// 초기 URL (세션 복원)
if (!string.IsNullOrEmpty(tab.Url) && string.IsNullOrEmpty(cv.Source))
{
try { cv.Navigate(tab.Url); } catch { }
}
TabCoreReady?.Invoke(tab, cv);
}
/// <summary>활성 탭 검색 상태 표시 요청(FindStatus 텍스트 갱신 — 호스트가 구독).</summary>
public event Action<TabItem>? FindStateChanged;
}