Phase 0 (T1-3/4/7/9/11): find bar w/ native Find API + search engine + site zoom + force reload + F11, wired into MainWindow, E2E smoke (39 tests)

This commit is contained in:
Yun Chan 2026-08-15 12:14:35 +09:00
parent 470c9267f2
commit 00a822eaeb
11 changed files with 584 additions and 18 deletions

View file

@ -31,6 +31,13 @@ public partial class MainWindow : Window
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;
// #15 위장 미디어(image/png 등) magic byte 검증용. 세션당 상한 두어 폭주 방지.
private static readonly HttpClient ProbeClient = new(new HttpClientHandler { AllowAutoRedirect = true })
{ Timeout = TimeSpan.FromSeconds(15) };
@ -59,6 +66,9 @@ public partial class MainWindow : Window
BookmarksList.ItemsSource = _bookmarks;
HistoryList.ItemsSource = _history;
_resolver = new SearchEngineResolver(_config.SearchEngine);
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
Loaded += async (_, _) => await InitializeBrowserAsync();
PreviewKeyDown += OnPreviewKeyDown;
StateChanged += OnWindowStateChanged;
@ -127,16 +137,19 @@ public partial class MainWindow : Window
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
/// <summary>WebView2 영속 프로필 경로. 쿠키·세션·localStorage·캐시가 여기 보존되어 앱/빌드 재시작에도 유지.</summary>
internal static string ProfileFolder => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "webview");
internal static string AppDataDir => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader");
internal static string ProfileFolder => Path.Combine(AppDataDir, "webview");
private async Task InitializeBrowserAsync()
{
CoreWebView2Environment? env = null;
try
{
// 명시적 영속 프로필: 기본(exe 디렉터리) 폴더는 빌드/재배포 시 날아가 로그인이 풀림 → %AppData% 로 고정.
Directory.CreateDirectory(ProfileFolder);
var env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: 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);
@ -146,6 +159,20 @@ public partial class MainWindow : Window
var cv = Browser.CoreWebView2;
UpdatePrivateButton();
cv.Settings.AreDevToolsEnabled = true;
// T1-3 네이티브 Find API 세션 (자체 검색 바)
_findSession = new FindSession(new WebView2FindApi(cv, env!));
_findSession.StateChanged += () => Dispatcher.BeginInvoke(new Action(() =>
{
if (_findSession != null) FindStatus.Text = _findSession.StatusText;
}));
// T1-7 사이트별 줌: 네비 완료 시 저장된 배율 적용 (WebView2 는 사이트별 영속 미보장)
cv.NavigationCompleted += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
try { Browser.ZoomFactor = _zoomStore.Get(cv.Source); } catch { }
}));
cv.WebResourceResponseReceived += OnResponseReceived;
// #4 팝업/새창을 현재 WebView2 로 강제 라우팅
cv.NewWindowRequested += (_, ne) =>
@ -433,12 +460,12 @@ public partial class MainWindow : Window
private void NavRefresh_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.Reload();
private void NavHome_Click(object sender, RoutedEventArgs e) => NavigateTo(_config.HomeUrl);
private void NavigateTo(string? url)
internal void NavigateTo(string? url)
{
url = url?.Trim();
if (string.IsNullOrEmpty(url)) return;
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) url = "https://" + url;
Browser.CoreWebView2?.Navigate(url);
// T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당
var intent = _resolver.Resolve(url);
if (intent == null) return;
Browser.CoreWebView2?.Navigate(intent.Target);
}
// 소셜 영상 페이지(YouTube/Instagram/X/...) 감지 → yt-dlp 카드 등록.
@ -486,24 +513,109 @@ public partial class MainWindow : Window
else Log("지원하는 소셜 영상 링크가 아닙니다 (YouTube·Instagram·X·TikTok·Facebook)");
}
// #7 전역 키보드 단축키
// #7 전역 키보드 단축키 (+T1-3/T1-9/T1-11)
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
var cv = Browser.CoreWebView2;
var mods = Keyboard.Modifiers;
if (mods == ModifierKeys.Control && e.Key == Key.L) { AddressBox.Focus(); AddressBox.SelectAll(); e.Handled = true; }
// T1-3 검색 바가 열려 있으면 Esc 는 닫기가 우선
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();
}
else OpenFind();
e.Handled = true;
}
else if (e.Key == Key.F11) { ToggleFullscreen(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.R) { ForceReload(); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.L) { AddressBox.Focus(); AddressBox.SelectAll(); e.Handled = true; }
else if (e.Key == Key.F5 || (mods == ModifierKeys.Control && e.Key == Key.R)) { cv?.Reload(); e.Handled = true; }
else if (e.Key == Key.Escape) { try { cv?.Stop(); } catch { } e.Handled = true; }
else if (mods == ModifierKeys.Alt && e.Key == Key.Left) { try { if (cv?.CanGoBack == true) cv.GoBack(); } catch { } e.Handled = true; }
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); } 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); } catch { } e.Handled = true; }
else if (e.Key == Key.D0) { try { Browser.ZoomFactor = 1.0; } catch { } e.Handled = true; }
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; }
}
}
// ---- T1-3 페이지 내 검색 바 ----
internal void OpenFind()
{
FindBar.Visibility = Visibility.Visible;
FindStatus.Text = "";
FindBox.Focus();
FindBox.SelectAll();
if (FindBox.Text.Length > 0) _ = _findSession?.StartAsync(FindBox.Text);
}
internal void CloseFind()
{
FindBar.Visibility = Visibility.Collapsed;
_findSession?.Close();
FindStatus.Text = "";
}
private void FindBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (FindBar.Visibility == Visibility.Visible)
_ = _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; }
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 FindClose_Click(object sender, RoutedEventArgs e) => CloseFind();
private void FindOption_Changed(object sender, RoutedEventArgs e)
=> _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);
}
// T1-9 강제 새로고침: 디스크 캐시 비우고 다시 로드
private async void ForceReload()
{
var cv = Browser.CoreWebView2;
if (cv == null) return;
try { await cv.Profile.ClearBrowsingDataAsync(CoreWebView2BrowsingDataKinds.DiskCache); }
catch { }
try { cv.Reload(); } catch { }
}
// T1-11 전체화면(F11): 툴바/상태바 숨기고 최대화. 재누르면 복원.
internal void ToggleFullscreen()
{
_fullscreen = !_fullscreen;
if (_fullscreen)
{
_stateBeforeFullscreen = WindowState;
WindowState = WindowState.Maximized;
}
else
{
WindowState = _stateBeforeFullscreen;
}
ToolbarRow.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
StatusBar.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
}
private static string MakeUniqueDownloadName(string folder, string file)
{
var p = Path.Combine(folder, file);
@ -746,6 +858,7 @@ public partial class MainWindow : Window
{
_config = w.Config;
ConfigStore.Save(_config);
_resolver = new SearchEngineResolver(_config.SearchEngine); // T1-4 즉시 반영
_manager.UpdateMaxConcurrent(_config.MaxConcurrentDownloads); // #19 런타임 반영
if (w.ThemeChanged) RestartApp();
}