using System.Collections.ObjectModel; using System.Net.Http.Headers; using System.Runtime.InteropServices; using System.Threading; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Interop; using System.Windows.Media; using Microsoft.Web.WebView2.Core; using VideoDownloader.Browser; using VideoDownloader.App.Browser; using VideoDownloader.App.Browser.Features; using VideoDownloader.Browser.Tabs; using VideoDownloader.App.Core; using VideoDownloader.Core.Detection; using VideoDownloader.App.ViewModels; using VideoDownloader.Core.Config; using Wv2 = Microsoft.Web.WebView2.Wpf; using TabItem = VideoDownloader.Browser.Tabs.TabItem; namespace VideoDownloader.App; public partial class MainWindow : Window { private readonly DownloadManager _manager; private AppConfig _config; private int _totalRequests; private string _appLogPath = ""; // ---- 탭 아키텍처 (Phase A) ---- private CoreWebView2Environment? _env; private TabManager _tabs = null!; 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 bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지) // Phase C: 팔레트/부스트/워크스페이스/레이아웃 private readonly CommandPalette _palette = new(); private BoostStore _boostStore = null!; private List _workspaces = new(); private bool _verticalTabs; private TabItem? _splitTab; private System.Speech.Synthesis.SpeechSynthesizer? _speech; // Phase B: 다운로드 매니저(T1-5) / 탭 절전(T2-2) private readonly PageDownloadTracker _pageDownloads = new(); private System.Windows.Threading.DispatcherTimer? _speedTimer; private System.Windows.Threading.DispatcherTimer? _suspendTimer; private TabSuspendService? _suspendService; private readonly System.Diagnostics.Stopwatch _speedWatch = new(); // T1-4 검색엔진 / T1-7 사이트별 줌 / T1-3 페이지 내 검색 / T1-11 전체화면 private SearchEngineResolver _resolver = null!; private SiteZoomStore _zoomStore = null!; 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) }; private readonly HashSet _probed = new(); private int _probeCount; // 범용 우측 패널: 접기/펼치기 + 폭 리사이즈 상태(설정에 저장) private bool _panelCollapsed; private double _panelExpandedWidth = 420; private const double CollapsedPanelWidth = 56; private const double MinExpandedWidth = 280; private const double MaxExpandedWidth = 900; private static string SessionPath => Path.Combine(AppDataDir, "session.json"); public MainWindow() { InitializeComponent(); _config = ConfigStore.Load(); Directory.CreateDirectory(_config.OutputFolder); _appLogPath = Path.Combine(_config.OutputFolder, "app.log"); try { File.WriteAllText(_appLogPath, $"=== 시작 {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==={Environment.NewLine}"); } catch { } _manager = new DownloadManager(_config, GetCookiesAsync, Log); DataContext = _manager; foreach (var b in BrowserStore.LoadBookmarks()) _bookmarks.Add(b); foreach (var h in BrowserStore.LoadHistory()) _history.Add(h); BookmarksList.ItemsSource = _bookmarks; HistoryList.ItemsSource = _history; _resolver = new SearchEngineResolver(_config.SearchEngine); _zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json")); _boostStore = new BoostStore(Path.Combine(AppDataDir, "boost.json")); try { var wsPath = Path.Combine(AppDataDir, "workspaces.json"); if (File.Exists(wsPath)) _workspaces = System.Text.Json.JsonSerializer.Deserialize>(File.ReadAllText(wsPath)) ?? new(); } catch { } BuildPalette(); Loaded += async (_, _) => await InitializeBrowserAsync(); PreviewKeyDown += OnPreviewKeyDown; StateChanged += OnWindowStateChanged; AddressBox.LostKeyboardFocus += (_, _) => AddressPopup.IsOpen = false; // 범용 우측 패널: 저장된 접힘/폭 상태 복원 _panelExpandedWidth = ClampPanelWidth(_config.RightPanelWidth); ApplyPanelState(_config.RightPanelCollapsed); Closing += OnWindowClosing; } // 보더리스: Win11 어두운 타이틀바 + 둥근 모서리(DWMWA_WINDOW_CORNER_PREFERENCE) protected override void OnSourceInitialized(EventArgs e) { base.OnSourceInitialized(e); try { var hwnd = new WindowInteropHelper(this).Handle; int dark = App.IsLightTheme ? 0 : 1; // DWMWA_USE_IMMERSIVE_DARK_MODE (라이트 테마면 밝게) DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int)); int round = 2; // DWMWCP_ROUND DwmSetWindowAttribute(hwnd, 33, ref round, sizeof(int)); } catch { } } private void OnWindowStateChanged(object? sender, EventArgs e) { // 최대화 시 모서리 각진(Fluent 표준), 복원 시 둥글게. 캡션 아이콘도 전환. try { var hwnd = new WindowInteropHelper(this).Handle; int pref = WindowState == WindowState.Maximized ? 0 : 2; // 0=기본(최대화 시 사각) DwmSetWindowAttribute(hwnd, 33, ref pref, sizeof(int)); if (MaxRestoreBtn != null) { MaxRestoreBtn.Content = WindowState == WindowState.Maximized ? "" : ""; MaxRestoreBtn.ToolTip = WindowState == WindowState.Maximized ? "복원" : "최대화"; } } catch { } } // ---- 보더리스: 툴바의 빈 공간을 드래그 (버튼/입력창 위는 제외해 클릭 전달) ---- private void TitleBar_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { // 더블클릭 → 최대화/복원 (네이티브 타이틀바 동작) if (e.ClickCount >= 2) { MaximizeToggle(); return; } // 클릭된 대상(또는 조상)이 버튼/입력창이면 드래그하지 않고 클릭을 그대로 흘려보낸다 for (var d = e.OriginalSource as DependencyObject; d != null; d = LogicalTreeHelper.GetParent(d) ?? VisualTreeHelper.GetParent(d)) { if (d is ButtonBase || d is TextBox) return; } if (e.LeftButton == MouseButtonState.Pressed) { try { DragMove(); } catch { } } } private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; private void MaxRestore_Click(object sender, RoutedEventArgs e) => MaximizeToggle(); private void Close_Click(object sender, RoutedEventArgs e) => Close(); private void MaximizeToggle() => WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; [DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute); /// 앱 데이터 디렉터리(AppPaths.DataDir — VD_DATA_DIR 오버라이드 지원). internal static string AppDataDir => AppPaths.DataDir; internal static string ProfileFolder => Path.Combine(AppDataDir, "webview"); // ==================== 탭 생명주기 (Phase A) ==================== private async Task InitializeBrowserAsync() { try { // 명시적 영속 프로필: 기본(exe 디렉터리) 폴더는 빌드/재배포 시 날아가 로그인이 풀림 → %AppData% 로 고정. Directory.CreateDirectory(ProfileFolder); _env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: ProfileFolder); } catch (Exception ex) { Log($"WebView2 초기화 실패: {ex.Message}"); return; } _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; }; _views.ContextMenuBuild += OnContextMenuBuild; _views.BoostScriptProvider = url => BoostCssBuilder.BuildScript(url, _boostStore.Get(url)); // T2-7 _views.GlanceRequested += (_, link) => Dispatcher.BeginInvoke(new Action(() => OpenGlance(link))); // T2-6 _views.SpeakRequested += (_, text) => Dispatcher.BeginInvoke(new Action(() => SpeakText(text))); // T3-9 _views.ContextMenuBuild += OnContextMenuBuild; _views.BoostScriptProvider = url => BoostCssBuilder.BuildScript(url, _boostStore.Get(url)); // T2-7 _views.GlanceRequested += (_, link) => Dispatcher.BeginInvoke(new Action(() => OpenGlance(link))); // T2-6 _views.SpeakRequested += (_, text) => Dispatcher.BeginInvoke(new Action(() => SpeakText(text))); // T3-9 _tabs = new TabManager(() => new TabItem(), _views.AttachView); _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(); // T1-5 다운로드 속도 갱신 타이머 DownloadsList.ItemsSource = _pageDownloads.Items; _speedTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _speedTimer.Tick += (_, _) => { foreach (var item in _pageDownloads.Items) item.UpdateSpeed(item.Op.BytesReceived, TimeSpan.FromSeconds(1)); DownloadsEmptyHint.Visibility = _pageDownloads.Items.Count == 0 ? Visibility.Visible : Visibility.Collapsed; }; _speedTimer.Start(); // T2-2 탭 절전: 1분마다 유휴(10분+) 비활성 탭 서스펜드 _suspendService = new TabSuspendService(TimeSpan.FromMinutes(10), tab => { var cv = tab.Core; if (cv == null || cv.IsSuspended) return false; if (tab.View?.Visibility != Visibility.Collapsed) return false; // 숨겨진 탭만 서스펜드 가능 _ = cv.TrySuspendAsync(); return true; }, tab => tab.Core != null); _suspendTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMinutes(1) }; _suspendTimer.Tick += (_, _) => { if (_tabs.ActiveTab != null) _suspendService.SuspendIdleTabs(_tabs.Tabs, _tabs.ActiveTab, DateTime.UtcNow); }; _suspendTimer.Start(); } /// 탭별 WebView2 생명주기·배선 전담 서비스(재구성 ④). private Services.TabViewService? _views; private void OnUrlChanged(TabItem tab, string 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); } private void OnTitleChanged(TabItem tab, string title) { 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; } } private void OnDownloadStarting(CoreWebView2 cv, CoreWebView2DownloadStartingEventArgs de) { try { var folder = Path.GetFullPath(_config.OutputFolder); Directory.CreateDirectory(folder); var fn = Path.GetFileName(de.ResultFilePath); if (string.IsNullOrWhiteSpace(fn)) fn = $"download_{DateTime.Now:yyyyMMdd_HHmmss}"; de.ResultFilePath = MakeUniqueDownloadName(folder, fn); _manager.LogPanel($"📥 페이지 다운로드 → 저장 폴더: {fn}"); var item = _pageDownloads.Add(new DownloadOperationAdapter(de.DownloadOperation), fn); // T1-5 item.UpdateSpeed(0, TimeSpan.FromSeconds(1)); DownloadsEmptyHint.Visibility = Visibility.Collapsed; } catch { } } // ---- T1-5 다운로드 플라이아웃 ---- private void ToggleDownloads(object sender, RoutedEventArgs e) { DownloadsPopup.IsOpen = !DownloadsPopup.IsOpen; } private void DownloadsClear_Click(object sender, RoutedEventArgs e) => _pageDownloads.ClearCompleted(); private void DlPause_Click(object sender, RoutedEventArgs e) => IfDownloadItem(sender, i => _pageDownloads.Pause(i)); private void DlResume_Click(object sender, RoutedEventArgs e) => IfDownloadItem(sender, i => _pageDownloads.Resume(i)); private void DlCancel_Click(object sender, RoutedEventArgs e) => IfDownloadItem(sender, i => _pageDownloads.Cancel(i)); private static void IfDownloadItem(object sender, Action act) { if ((sender as FrameworkElement)?.DataContext is PageDownloadItem item) act(item); } private void OnActiveTabChanged(TabItem tab) { if (_splitTab != null && !ReferenceEquals(tab, _splitTab)) ExitSplit(); // v1: 전환 시 분할 해제 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; if (tab.IsSuspended) // T2-2: 절전 탭 활성화 시 재개 { try { tab.Core?.Resume(); } catch { } tab.IsSuspended = false; } UpdateNavState(); } private void OnTabRemoved(TabItem tab) { if (ReferenceEquals(_splitTab, tab)) ExitSplit(); _views?.RemoveView(tab); } private void UpdateNavState() { var cv = Cv; Dispatcher.BeginInvoke(new Action(() => { NavBack.IsEnabled = cv?.CanGoBack == true; NavForward.IsEnabled = cv?.CanGoForward == true; })); } // ---- 탭 스트립 UI 이벤트 ---- private void NewTab_Click(object sender, RoutedEventArgs e) => NewTabWithDial(); 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 { var uri = e.Request.Uri ?? ""; var method = e.Request.Method ?? "GET"; string ct = "", cl = ""; int status = 0; try { ct = e.Response.Headers.GetHeader("Content-Type") ?? ""; } catch { } try { cl = e.Response.Headers.GetHeader("Content-Length") ?? ""; } catch { } try { status = e.Response.StatusCode; } catch { } int n = Interlocked.Increment(ref _totalRequests); var ctLow = (ct ?? "").ToLowerInvariant(); // #8 노이즈 content-type(image/font/css/js/beacon)은 requests.log 기록 스킵 bool loggable = !ctLow.StartsWith("image/") && !ctLow.StartsWith("font/") && !ctLow.Contains("/css") && !ctLow.Contains("javascript") && !ctLow.Contains("beacon") && !ctLow.Contains("ping"); if (loggable) _manager.AppendAll($"{n}|{status}|{method}|{ct}|{cl}|{uri}"); if (status >= 300) return; var l = uri.ToLowerInvariant(); var c = (ct ?? "").ToLowerInvariant(); long len = long.TryParse(cl, out var lv) ? lv : -1; // 206(Partial Content) Range 응답은 Content-Length 가 청크 크기라 작게 잡힘 → // Content-Range 의 전체 크기(/total) 로 보정 (Instagram 등 단일 MP4 Range 스트리밍 감지). try { var cr = e.Response.Headers.GetHeader("Content-Range"); if (!string.IsNullOrEmpty(cr)) { var slash = cr.LastIndexOf('/'); if (slash >= 0 && long.TryParse(cr.AsSpan(slash + 1), out var total) && total > len) len = total; } } catch { } 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); if (kind == MediaKind.Hls) { _manager.LogPanel($"★ HLS 감지: {uri}"); _manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Hls, ct)); } else if (kind == MediaKind.Dash) { _manager.LogPanel($"★ DASH 감지: {uri}"); _manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Dash, ct)); } else if (kind == MediaKind.Direct) { // #15(b) chunked video/* (len<0) 는 크기 가드를 건너뛰어 토큰 만료 HTML 오류 페이지가 mp4 로 오탐됨 → // magic-byte probe 로 본문이 실제 미디어인지 재검증(정상 chunked MP4 는 ftyp box 로 통과). if (c.StartsWith("video/") && len < 0 && Uri.TryCreate(uri, UriKind.Absolute, out var probeUri)) ProbeMediaAsync(probeUri, referer); else { _manager.LogPanel($"★ 미디어 감지 [{ct}] {FmtLen(len)}: {uri}"); _manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Direct, ct)); } } else { // #15 위장 미디어(image/* 또는 octet-stream, 대용량) → magic byte 검증 long minBytes = _config.MinSizeMB * 1024L * 1024L; bool disguised = (c.StartsWith("image/") || c.Contains("octet-stream")) && (len < 0 || len > minBytes); if (disguised && Uri.TryCreate(uri, UriKind.Absolute, out var probeUri)) ProbeMediaAsync(probeUri, referer); else if (MediaDetector.LooksLikeCandidate(l, c)) _manager.LogPanel($"[후보 {method}] {uri}"); } } catch { } } private async void ProbeMediaAsync(Uri uri, string referer) { try { string key = uri.GetLeftPart(UriPartial.Path); lock (_probed) { if (_probeCount > 40) return; // 세션당 상한 if (!_probed.Add(key)) return; // 중복 URL 스킵 _probeCount++; } using var req = new HttpRequestMessage(HttpMethod.Get, uri); req.Headers.Range = new RangeHeaderValue(0, 63); req.Headers.UserAgent.ParseAdd(_config.UserAgent); if (!string.IsNullOrEmpty(referer)) { try { req.Headers.Referrer = new Uri(referer); } catch { } } using var resp = await ProbeClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); if ((int)resp.StatusCode >= 300) return; await using var stream = await resp.Content.ReadAsStreamAsync(); var head = new byte[64]; int total = 0, m; while (total < 64 && (m = await stream.ReadAsync(head, total, 64 - total)) > 0) total += m; var fmt = MediaDetector.ProbeFormat(head.AsSpan(0, total)); if (fmt != null) { _manager.LogPanel($"★ 위장 미디어 감지 (→{fmt}): {uri}"); _manager.OnDetected(new DetectionContext(uri.ToString(), referer, MediaKind.Direct, "video/" + fmt)); } } catch { } } private static string FmtLen(long len) => len < 0 ? "?" : $"{len / 1048576.0:F1}MB"; private async Task> GetCookiesAsync(Uri url) { var cv = Cv; if (cv == null) return new(); try { return (await cv.CookieManager.GetCookiesAsync(url.ToString())).ToList(); } catch { return new(); } } private void Log(string s) { var line = $"[{DateTime.Now:HH:mm:ss}] {s}"; try { File.AppendAllText(_appLogPath, line + Environment.NewLine); } catch { } Dispatcher.BeginInvoke(new Action(() => { StatusText.Text = s; })); } // ---- 네비게이션 ---- private void Go_Click(object sender, RoutedEventArgs e) => NavigateTo(AddressBox.Text); // ---- 주소창 자동완성/제안 ---- private void AddressBox_TextChanged(object sender, TextChangedEventArgs e) { if (_suggesting) return; UpdateSuggestions(AddressBox.Text); TryInlineSuggest(AddressBox.Text); } private void UpdateSuggestions(string text) { text = text?.Trim() ?? ""; if (text.Length == 0 || !AddressBox.IsKeyboardFocused) { AddressPopup.IsOpen = false; return; } var low = text.ToLowerInvariant(); var matches = _history .Where(h => h.Url.ToLowerInvariant().Contains(low) || (h.Title ?? "").ToLowerInvariant().Contains(low)) .Take(8) .ToList(); AddressSuggestions.ItemsSource = matches; AddressSuggestions.SelectedIndex = matches.Count > 0 ? 0 : -1; AddressPopup.IsOpen = matches.Count > 0; } // 인라인 자동완성: 방문한 URL 중 입력이 접두사면 나머지를 선택영역으로 채움 (Tab/→ 로 확정) private void TryInlineSuggest(string text) { if (text.Length == 0 || AddressBox.CaretIndex != text.Length) return; var low = text.ToLowerInvariant(); var match = _history.FirstOrDefault(h => h.Url.Length > text.Length && h.Url.ToLowerInvariant().StartsWith(low)); if (match == null) return; _suggesting = true; AddressBox.Text = match.Url; AddressBox.Select(text.Length, match.Url.Length - text.Length); _suggesting = false; } private void AddressBox_PreviewKeyDown(object sender, KeyEventArgs e) { var k = e.Key; // ↑/↓ 제안 목록 탐색 if (AddressPopup.IsOpen && AddressSuggestions.HasItems && (k == Key.Down || k == Key.Up)) { int n = AddressSuggestions.Items.Count; int delta = k == Key.Down ? 1 : -1; int i = Math.Clamp(AddressSuggestions.SelectedIndex + delta, 0, n - 1); AddressSuggestions.SelectedIndex = i; AddressSuggestions.ScrollIntoView(AddressSuggestions.Items[i]); e.Handled = true; return; } // Tab / → 로 인라인 제안 확정 (선택영역을 끝으로 붕괴) if ((k == Key.Tab || k == Key.Right) && AddressBox.SelectionLength > 0 && AddressBox.CaretIndex + AddressBox.SelectionLength == AddressBox.Text.Length) { AddressBox.CaretIndex = AddressBox.Text.Length; AddressBox.Select(AddressBox.Text.Length, 0); e.Handled = true; return; } if (k == Key.Escape) { AddressPopup.IsOpen = false; e.Handled = true; return; } if (k == Key.Enter) { AddressPopup.IsOpen = false; if (AddressSuggestions.SelectedItem is HistoryEntry sel) NavigateTo(sel.Url); else NavigateTo(AddressBox.Text); Keyboard.ClearFocus(); e.Handled = true; } } // 제안 항목 클릭 → 이동 private void AddressSuggestion_Click(object sender, MouseButtonEventArgs e) { if (AddressSuggestions.SelectedItem is HistoryEntry sel) { AddressPopup.IsOpen = false; NavigateTo(sel.Url); } } 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) { // T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당 var intent = _resolver.Resolve(url); if (intent == null) return; Cv?.Navigate(intent.Target); } // 소셜 영상 페이지(YouTube/Instagram/X/...) 감지 → yt-dlp 카드 등록. (탭별 상태) private void DetectSocial(TabItem tab, string? url) { 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)); tab.PendingSocialUrl = normalized; } else tab.PendingSocialUrl = null; } // ---- 링크로 받기 (URL 붙여넣기) ---- private void ToggleLink(object sender, RoutedEventArgs e) { BookmarksPopup.IsOpen = false; HistoryPopup.IsOpen = false; LinkPopup.IsOpen = !LinkPopup.IsOpen; if (LinkPopup.IsOpen) { LinkBox.Clear(); Dispatcher.BeginInvoke(new Action(() => LinkBox.Focus())); } } private void LinkBox_PreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Enter) { LinkDownload_Click(sender, e); e.Handled = true; } } private void LinkDownload_Click(object sender, RoutedEventArgs e) { var raw = (LinkBox.Text ?? "").Trim(); if (string.IsNullOrEmpty(raw)) return; if (!raw.StartsWith("http", StringComparison.OrdinalIgnoreCase)) raw = "https://" + raw; if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri)) { Log("올바른 링크가 아닙니다"); 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)); LinkPopup.IsOpen = false; Log("카드에 추가했습니다. 화질을 선택하고 받기를 누르세요"); } else Log("지원하는 소셜 영상 링크가 아닙니다 (YouTube·Instagram·X·TikTok·Facebook)"); } // #7 전역 키보드 단축키 (+Phase A 탭/T1-3/T1-9/T1-11) private void OnPreviewKeyDown(object sender, KeyEventArgs e) { var cv = Cv; var mods = Keyboard.Modifiers; // Phase A 탭 단축키 if (mods == ModifierKeys.Control && e.Key == Key.T) { NewTabWithDial(); e.Handled = true; } else if (mods == ModifierKeys.Control && e.Key == Key.J) { DownloadsPopup.IsOpen = !DownloadsPopup.IsOpen; e.Handled = true; } else if (mods == ModifierKeys.Control && e.Key == Key.K) { TogglePalette(); e.Handled = true; } else if (e.Key == Key.F9) { _ = ToggleReader(); e.Handled = true; } else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.D) { ToggleBoostDark(); e.Handled = true; } else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.S) { _ = TakeScreenshot(); e.Handled = true; } else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.N) { OpenInPrivateTab(); e.Handled = true; } else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.V) { ToggleVerticalTabs(); e.Handled = true; } else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.H) { ToggleSplit(); e.Handled = true; } else if (mods == ModifierKeys.Control && e.Key == Key.U) { ViewSource(); e.Handled = true; } else if (mods == ModifierKeys.Control && e.Key == Key.P) { try { Cv?.ShowPrintUI(); } catch { } e.Handled = true; } else if (e.Key == Key.OemQuestion && Keyboard.FocusedElement is not TextBox) { OpenFind(); e.Handled = true; } else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.A) { ToggleTabSearch(this, e); e.Handled = true; } else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.B) { ToggleBookmarkBar(); 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 는 닫기가 우선 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) _tabs.ActiveTab?.FindSession?.Previous(); else _tabs.ActiveTab?.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 { 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; } } } 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) _ = _tabs.ActiveTab?.FindSession?.StartAsync(FindBox.Text); } internal void CloseFind() { FindBar.Visibility = Visibility.Collapsed; _tabs.ActiveTab?.FindSession?.Close(); FindStatus.Text = ""; } private void FindBox_TextChanged(object sender, TextChangedEventArgs e) { if (FindBar.Visibility == Visibility.Visible) _ = _tabs.ActiveTab?.FindSession?.StartAsync(FindBox.Text); } private void FindBox_PreviewKeyDown(object sender, KeyEventArgs e) { 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) => _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) => _tabs.ActiveTab?.FindSession?.SetOptions(FindCaseBox.IsChecked == true, FindWordBox.IsChecked == true); // T1-7 줌 변경을 사이트별 저장소에 영속 private void PersistZoom() { var cv = Cv; if (cv != null && ActiveView != null) _zoomStore.Set(cv.Source, ActiveView.ZoomFactor); } // T1-9 강제 새로고침: 디스크 캐시 비우고 다시 로드 private async void ForceReload() { var cv = Cv; 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; TabStripRow.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); if (!File.Exists(p)) return p; var name = Path.GetFileNameWithoutExtension(file); var ext = Path.GetExtension(file); int i = 1; while (File.Exists(Path.Combine(folder, $"{name} ({i}){ext}"))) i++; return Path.Combine(folder, $"{name} ({i}){ext}"); } // ---- 범용 우측 패널: 접기/펼치기 + 리사이즈 + 상태 저장 ---- private static double ClampPanelWidth(double w) => Math.Clamp(w, MinExpandedWidth, MaxExpandedWidth); private void ApplyPanelState(bool collapsed) { _panelCollapsed = collapsed; RightCol.Width = new GridLength(collapsed ? CollapsedPanelWidth : _panelExpandedWidth); PanelBody.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible; PanelTitle.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible; PanelCountBadge.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible; RightSplitter.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible; // ←(펼치기) / →(접기) chevron. 접힘 시에도 토글 버튼은 헤더에 남아 클릭 가능. PanelToggle.Content = collapsed ? "" : ""; PanelToggle.ToolTip = collapsed ? "패널 펼치기" : "패널 접기"; } private void PanelToggle_Click(object sender, RoutedEventArgs e) { // 접기 직전 폭을 기억(다시 펼칠 때 복원) if (!_panelCollapsed) _panelExpandedWidth = ClampPanelWidth(RightCol.Width.Value); ApplyPanelState(!_panelCollapsed); PersistPanel(); } private void RightSplitter_DragCompleted(object sender, DragCompletedEventArgs e) { if (_panelCollapsed) return; double w = RightCol.Width.Value; if (w <= 120) ApplyPanelState(true); // 너무 좁히면 접기 처리 else _panelExpandedWidth = ClampPanelWidth(w); PersistPanel(); } private void PersistPanel() { _config.RightPanelCollapsed = _panelCollapsed; _config.RightPanelWidth = _panelCollapsed ? _panelExpandedWidth : ClampPanelWidth(RightCol.Width.Value); ConfigStore.Save(_config); } private void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e) { _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 { } } // ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ---- private void UpdatePrivateButton() { if (PrivateBtn == null) return; try { PrivateBtn.Foreground = _config.PrivateMode ? (Brush)FindResource("AccentBrush") : (Brush)FindResource("TextSecondary"); PrivateBtn.ToolTip = _config.PrivateMode ? "개인정보 보호 모드 켜짐 — 클릭 시 일반 모드로 전환(재시작)" : "개인정보 보호 모드(InPrivate)"; } catch { } } // 토글 → 확인 → 플래그 저장 → 재시작(테마 전환과 동일 패턴) private void TogglePrivate_Click(object sender, RoutedEventArgs e) { bool next = !_config.PrivateMode; var msg = next ? "개인정보 보호 모드(InPrivate)로 전환합니다.\n이 모드에서는 쿠키·세션·localStorage가 저장되지 않습니다.\n앱이 재시작되며 현재 페이지는 닫힙니다." : "일반 모드로 전환합니다.\n앱이 재시작되며 현재 페이지는 닫힙니다."; if (MessageBox.Show(msg + "\n\n계속할까요?", "브라우저 모드", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK) return; _config.PrivateMode = next; ConfigStore.Save(_config); RestartApp(); } /// 현재 프로필의 브라우저 데이터(쿠키·DOM 저장소·캐시) 삭제. 일반 모드: 영속 프로필 정리. public async Task ClearBrowsingDataAsync() { var cv = Cv; if (cv == null) { Log("브라우저가 아직 준비되지 않았습니다"); return; } try { await cv.Profile.ClearBrowsingDataAsync(); Log("브라우저 데이터(쿠키·localStorage·캐시)를 삭제했습니다. 페이지를 새로고침하세요."); } catch (Exception ex) { Log($"데이터 삭제 실패: {ex.Message}"); } } // ---- 즐겨찾기 ---- private void ToggleBookmarks(object sender, RoutedEventArgs e) { HistoryPopup.IsOpen = false; LinkPopup.IsOpen = false; BookmarksPopup.IsOpen = !BookmarksPopup.IsOpen; } private void AddBookmark_Click(object sender, RoutedEventArgs e) { 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; } var title = Trunc(string.IsNullOrWhiteSpace(cv!.DocumentTitle) ? url : cv.DocumentTitle, 80); _bookmarks.Insert(0, new Bookmark(title, url)); BrowserStore.SaveBookmarks(_bookmarks.ToList()); if (BookmarkBar.Visibility == Visibility.Visible) RefreshBookmarkBar(); Log($"즐겨찾기 추가: {title}"); } private void RemoveBookmark_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is Bookmark b) { _bookmarks.Remove(b); BrowserStore.SaveBookmarks(_bookmarks.ToList()); } } private void OpenBookmark_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is Bookmark b) { BookmarksPopup.IsOpen = false; NavigateTo(b.Url); } } // ---- 히스토리 ---- private void ToggleHistory(object sender, RoutedEventArgs e) { BookmarksPopup.IsOpen = false; LinkPopup.IsOpen = false; HistoryPopup.IsOpen = !HistoryPopup.IsOpen; } private void OpenHistory_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is HistoryEntry h) { HistoryPopup.IsOpen = false; NavigateTo(h.Url); } } private void ClearHistory_Click(object sender, RoutedEventArgs e) { _history.Clear(); BrowserStore.SaveHistory(new List()); Log("방문 기록을 삭제했습니다"); } private void RecordHistory(string title, string url) { if (string.IsNullOrEmpty(url)) return; if (_history.Count > 0 && _history[0].Url == url) return; _history.Insert(0, new HistoryEntry(Trunc(title, 80), url, DateTime.Now)); while (_history.Count > 500) _history.RemoveAt(_history.Count - 1); BrowserStore.SaveHistory(_history.ToList()); } // ---- T2-8 탭 검색 (Ctrl+Shift+A) ---- private void ToggleTabSearch(object sender, RoutedEventArgs e) { var open = !TabSearchPopup.IsOpen; TabSearchPopup.IsOpen = open; if (open) { TabSearchBox.Clear(); TabSearchBox.Focus(); } } private void TabSearchBox_TextChanged(object sender, TextChangedEventArgs e) { var results = TabSearchFilter.Search(_tabs.Tabs, _tabs.ClosedStack, TabSearchBox.Text); TabSearchResults.ItemsSource = results; TabSearchResults.SelectedIndex = results.Count > 0 ? 0 : -1; } private void TabSearchBox_PreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Escape) { TabSearchPopup.IsOpen = false; e.Handled = true; } else if (e.Key == Key.Down && TabSearchResults.HasItems) { TabSearchResults.SelectedIndex = Math.Min(TabSearchResults.SelectedIndex + 1, TabSearchResults.Items.Count - 1); e.Handled = true; } else if (e.Key == Key.Up && TabSearchResults.HasItems) { TabSearchResults.SelectedIndex = Math.Max(TabSearchResults.SelectedIndex - 1, 0); e.Handled = true; } else if (e.Key == Key.Enter) { if (TabSearchResults.SelectedItem is TabSearchResult sel) ActivateSearchResult(sel); e.Handled = true; } } private void TabSearchResult_Click(object sender, MouseButtonEventArgs e) { if (TabSearchResults.SelectedItem is TabSearchResult sel) ActivateSearchResult(sel); } private void ActivateSearchResult(TabSearchResult sel) { TabSearchPopup.IsOpen = false; if (sel.IsClosed) { var reopened = _tabs.ReopenClosedTab(); if (reopened == null) _tabs.NewTab(sel.Url); } else _tabs.Activate(sel.Tab); } // ---- T1-6 북마크바 (Ctrl+Shift+B) ---- private void ToggleBookmarkBar() { BookmarkBar.Visibility = BookmarkBar.Visibility == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible; if (BookmarkBar.Visibility == Visibility.Visible) RefreshBookmarkBar(); } private void RefreshBookmarkBar() => BookmarkBarList.ItemsSource = _bookmarks.Take(30).ToList(); private void BookmarkBar_Click(object sender, RoutedEventArgs e) { if ((sender as FrameworkElement)?.DataContext is Bookmark b) NavigateTo(b.Url); } // ---- T2-12 컨텍스트 메뉴 ---- private void OnContextMenuBuild(CoreWebView2 cv, CoreWebView2ContextMenuRequestedEventArgs ce) { try { var menu = new ContextMenu(); var target = ce.ContextMenuTarget; var link = target?.LinkUri; if (!string.IsNullOrEmpty(link)) { var download = new MenuItem { Header = "이 링크의 영상 받기", FontWeight = FontWeights.Medium }; download.Click += (_, _) => QueueLinkForDownload(link); menu.Items.Add(download); var copy = new MenuItem { Header = "링크 주소 복사" }; copy.Click += (_, _) => { try { Clipboard.SetText(link); } catch { } }; menu.Items.Add(copy); menu.Items.Add(new Separator()); } foreach (var builtin in ce.MenuItems) { if (builtin.Kind == CoreWebView2ContextMenuItemKind.Separator) { if (menu.Items.Count > 0 && menu.Items[^1] is not Separator) menu.Items.Add(new Separator()); continue; } if (builtin.Kind != CoreWebView2ContextMenuItemKind.Command) continue; var mi = new MenuItem { Header = builtin.Label, IsEnabled = builtin.IsEnabled }; var commandId = builtin.CommandId; mi.Click += (_, _) => { try { ce.SelectedCommandId = commandId; ce.Handled = true; } catch { } }; menu.Items.Add(mi); } while (menu.Items.Count > 0 && menu.Items[^1] is Separator) menu.Items.RemoveAt(menu.Items.Count - 1); var view = ActiveView; if (view != null) { var pt = view.PointToScreen(new Point(ce.Location.X, ce.Location.Y)); menu.PlacementTarget = view; menu.Placement = System.Windows.Controls.Primitives.PlacementMode.AbsolutePoint; menu.HorizontalOffset = pt.X; menu.VerticalOffset = pt.Y; } menu.IsOpen = true; } catch { } } private void QueueLinkForDownload(string? link) { if (string.IsNullOrEmpty(link)) return; if (!Uri.TryCreate(link, UriKind.Absolute, out var uri)) return; var referer = Cv?.Source ?? link; if (SocialSiteDetector.TryMatch(uri, out var normalized, out var label)) { _manager.LogPanel($"컨텍스트 메뉴 소셜 감지 [{label}]: {normalized}"); _manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label)); return; } var kind = MediaDetector.Classify(link, "", -1, _config) ?? MediaKind.Direct; _manager.LogPanel($"컨텍스트 메뉴 감지 [{kind}]: {link}"); _manager.OnDetected(new DetectionContext(link, referer, kind, "")); } // ==================== Phase C 기능 ==================== // ---- T2-1 명령 팔레트 ---- private void BuildPalette() { _palette.Register("새 탭", "tab new", () => NewTabWithDial()); _palette.Register("새 InPrivate 탭", "private incognito 사생활", OpenInPrivateTab); _palette.Register("탭 닫기", "close", () => { if (_tabs.ActiveTab != null) _tabs.CloseTab(_tabs.ActiveTab); }); _palette.Register("닫은 탭 복원", "reopen restore", () => _tabs.ReopenClosedTab()); _palette.Register("탭 검색", "tab search find", () => ToggleTabSearch(this, new RoutedEventArgs())); _palette.Register("다운로드", "downloads page", () => DownloadsPopup.IsOpen = true); _palette.Register("북마크바 토글", "bookmark bar", ToggleBookmarkBar); _palette.Register("전체화면", "fullscreen f11", ToggleFullscreen); _palette.Register("리더 모드", "reader reading 읽기", async () => await ToggleReader()); _palette.Register("강제 다크 모드 토글", "dark boost theme", ToggleBoostDark); _palette.Register("세로 탭 토글", "vertical layout", ToggleVerticalTabs); _palette.Register("분할 화면 토글", "split zen tile", ToggleSplit); _palette.Register("그림 속 그림 (PiP)", "pip picture video", TogglePip); _palette.Register("스크린샷", "screenshot capture", async () => await TakeScreenshot()); _palette.Register("소스 보기", "source html", ViewSource); _palette.Register("인쇄", "print pdf", () => { try { Cv?.ShowPrintUI(); } catch { } }); _palette.Register("저장 폴더 열기", "folder open output", () => OpenInExplorer(null)); _palette.Register("이 페이지 즐겨찾기", "bookmark add star", () => AddBookmark_Click(this, new RoutedEventArgs())); _palette.RegisterSeparator(); _palette.RegisterDynamic(() => _tabs.Tabs .Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t))) .ToList()); _palette.RegisterDynamic(() => _workspaces .Select(w => new PaletteCommand($"워크스페이스 전환: {w.Name}", "workspace switch", () => SwitchWorkspace(w))) .ToList()); } private void TogglePalette() { var open = !PalettePopup.IsOpen; PalettePopup.IsOpen = open; if (open) { PaletteBox.Clear(); PaletteBox.Focus(); } } private void PaletteBox_TextChanged(object sender, TextChangedEventArgs e) { var results = _palette.Search(PaletteBox.Text); PaletteResults.ItemsSource = results; PaletteResults.SelectedIndex = results.Count > 0 ? 0 : -1; } private void PaletteBox_PreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Escape) { PalettePopup.IsOpen = false; e.Handled = true; } else if (e.Key == Key.Down && PaletteResults.HasItems) { PaletteResults.SelectedIndex = Math.Min(PaletteResults.SelectedIndex + 1, PaletteResults.Items.Count - 1); e.Handled = true; } else if (e.Key == Key.Up && PaletteResults.HasItems) { PaletteResults.SelectedIndex = Math.Max(PaletteResults.SelectedIndex - 1, 0); e.Handled = true; } else if (e.Key == Key.Enter) { if (PaletteResults.SelectedItem is PaletteCommand cmd) { PalettePopup.IsOpen = false; cmd.Execute(); } e.Handled = true; } } private void PaletteResult_Click(object sender, MouseButtonEventArgs e) { if (PaletteResults.SelectedItem is PaletteCommand cmd) { PalettePopup.IsOpen = false; cmd.Execute(); } } // ---- T2-4 리더 모드 ---- private async Task ToggleReader() { var tab = _tabs.ActiveTab; if (tab == null) return; var result = await ReaderMode.ToggleAsync(tab, App.IsLightTheme); if (result == null) Log("이 페이지에서는 리더 모드를 사용할 수 없습니다"); } // ---- T2-7 부스트(강제 다크) ---- private void ToggleBoostDark() { var url = Cv?.Source; if (string.IsNullOrEmpty(url)) { Log("페이지가 없습니다"); return; } var cur = _boostStore.Get(url); _boostStore.Set(url, !cur.ForceDark, cur.CustomCss); _views?.ApplyBoostNow(_tabs.ActiveTab!); Log(!cur.ForceDark ? "이 사이트 강제 다크 켜짐" : "이 사이트 강제 다크 꺼짐"); } // ---- T2-9 스크린샷 ---- private async Task TakeScreenshot() { var cv = Cv; if (cv == null) return; try { using var ms = new MemoryStream(); await cv.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png, ms); var path = MakeUniqueDownloadName(Path.GetFullPath(_config.OutputFolder), $"screenshot_{DateTime.Now:yyyyMMdd_HHmmss}.png"); await File.WriteAllBytesAsync(path, ms.ToArray()); Log($"스크린샷 저장: {Path.GetFileName(path)}"); } catch (Exception ex) { Log($"스크린샷 실패: {ex.Message}"); } } // ---- T2-5 세로 탭 ---- private void ToggleVerticalTabs() { _verticalTabs = !_verticalTabs; if (_verticalTabs) { TabStripRow.Visibility = Visibility.Collapsed; SideStripCol.Width = new GridLength(210); var sv = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, Padding = new Thickness(4, 8, 4, 8) }; sv.Content = TabList; TabSideHost.Child = sv; SetTabListOrientation(Orientation.Vertical); } else { SetTabListOrientation(Orientation.Horizontal); TabSideHost.Child = null; // 원래 스트립의 DockPanel 로 복귀 (NewTabBtn 옆 ScrollViewer) var sv = new ScrollViewer { HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden, VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, VerticalAlignment = VerticalAlignment.Bottom }; sv.Content = TabList; // 기존 DockPanel: 스트립의 유일한 DockPanel var dock = TabStripRow.Child as DockPanel; dock?.Children.Add(sv); SideStripCol.Width = new GridLength(0); TabStripRow.Visibility = Visibility.Visible; } if (_fullscreen) TabStripRow.Visibility = Visibility.Collapsed; // 전체화면 유지 } private void SetTabListOrientation(Orientation o) { var factory = new FrameworkElementFactory(typeof(StackPanel)); factory.SetValue(StackPanel.OrientationProperty, o); TabList.ItemsPanel = new ItemsPanelTemplate(factory); } // ---- T2-3 분할 화면 (활성 + 다음 탭 나란히) ---- private void ToggleSplit() { if (_splitTab != null) { ExitSplit(); return; } var active = _tabs.ActiveTab; if (active == null) return; var next = _tabs.Tabs.FirstOrDefault(t => !ReferenceEquals(t, active)) ?? _tabs.NewTab(activate: false); if (next.View?.Parent is Grid host) host.Children.Remove(next.View); _splitTab = next; SplitRightHost.Child = next.View; next.View!.Visibility = Visibility.Visible; SplitCol.Width = new GridLength(Math.Max(280, (TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth) / 2)); SplitRightHost.Visibility = Visibility.Visible; SplitSplitter.Visibility = Visibility.Visible; Log($"분할 화면: {Trunc(next.Title, 30)}"); } private void ExitSplit() { if (_splitTab?.View != null) { SplitRightHost.Child = null; TabHost.Children.Add(_splitTab.View); _splitTab.View.Visibility = ReferenceEquals(_splitTab, _tabs.ActiveTab) ? Visibility.Visible : Visibility.Collapsed; } _splitTab = null; SplitCol.Width = new GridLength(0); SplitRightHost.Visibility = Visibility.Collapsed; SplitSplitter.Visibility = Visibility.Collapsed; } // ---- T2-6 Glance (Alt+클릭 링크 미리보기) ---- private void OpenGlance(string url) { try { var glance = new GlanceWindow(_env!, _config, url) { Owner = this, ShowInTaskbar = false, WindowStyle = WindowStyle.None, AllowsTransparency = false, Background = System.Windows.Media.Brushes.Black }; glance.ExpandRequested += g => { g.Close(); _tabs.NewTab(g.Url); // 탭으로 승격 }; glance.Show(); } catch (Exception ex) { Log($"미리보기 실패: {ex.Message}"); } } // ---- T3-9 낭독(TTS) ---- private void SpeakText(string text) { try { _speech?.Dispose(); _speech = new System.Speech.Synthesis.SpeechSynthesizer(); if (text.Length > 4000) text = text[..4000]; _speech.SpeakAsync(text); } catch { } } // ---- T3-3 InPrivate 탭 ---- private void OpenInPrivateTab() { _views!.NextTabIsPrivate = true; var t = _tabs.NewTab(); t.Title = "InPrivate 탭"; Log("InPrivate 탭 열림 — 이 탭은 디스크에 저장되지 않습니다"); } // ---- T3-10 소스 보기 ---- private async void ViewSource() { var cv = Cv; if (cv == null) return; try { var html = await cv.ExecuteScriptAsync("document.documentElement.outerHTML"); var decoded = System.Text.Json.JsonSerializer.Deserialize(html) ?? ""; var wrapped = "" + System.Net.WebUtility.HtmlEncode(decoded) + ""; cv.NavigateToString(wrapped); } catch { } } // ---- T3-7 PiP ---- private void TogglePip() { var cv = Cv; if (cv == null) return; _ = cv.ExecuteScriptAsync(""" (function(){ try { var v = Array.from(document.querySelectorAll('video')).find(x => !x.paused && !x.disabled) || document.querySelector('video'); if (!v) { return 'NO_VIDEO'; } if (document.pictureInPictureElement) { document.exitPictureInPicture(); return 'EXITED'; } v.disablePictureInPicture = false; v.requestPictureInPicture(); return 'PIP'; } catch(e){ return 'ERR:'+e.message; } })(); """); Log("그림 속 그림 전환 (재생 중인 영상이 없으면 첫 번째 영상)"); } // ---- T3-6 새 탭 스피드 다이얼 ---- private void NewTabWithDial() { var tab = _tabs.NewTab(); _ = ShowDialAsync(tab); } private async Task ShowDialAsync(TabItem tab) { await WaitTabReadyLocal(tab); var cv = tab.Core; if (cv == null) return; var tiles = _bookmarks.Take(18).Select(b => $"
🌐
{System.Net.WebUtility.HtmlEncode(b.Title)}
").ToList(); var dial = "

새 탭

" + string.Join("", tiles) + $"
주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시
"; cv.NavigateToString(dial); } private async Task WaitTabReadyLocal(TabItem tab) { var sw = System.Diagnostics.Stopwatch.StartNew(); while (tab.Core == null && sw.ElapsedMilliseconds < 15000) await Task.Delay(50); } // ---- T2-11/T3-1 탭 컨텍스트 메뉴 + 워크스페이스 ---- private static readonly string[] GroupColors = { "#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#e91e8c" }; private void Tab_ContextMenu(object sender, MouseButtonEventArgs e) { if ((sender as FrameworkElement)?.DataContext is not TabItem tab) return; e.Handled = true; var menu = new ContextMenu(); var pin = new MenuItem { Header = tab.IsPinned ? "고정 해제" : "탭 고정" }; pin.Click += (_, _) => _tabs.SetPinned(tab, !tab.IsPinned); menu.Items.Add(pin); var dup = new MenuItem { Header = "탭 복제" }; dup.Click += (_, _) => _tabs.NewTab(tab.Url); menu.Items.Add(dup); var mute = new MenuItem { Header = tab.Core is { IsMuted: true } ? "음소거 해제" : "사이트 음소거" }; mute.Click += (_, _) => { try { if (tab.Core != null) { tab.Core.IsMuted = !(tab.Core?.IsMuted == true); tab.IsMuted = tab.Core.IsMuted; } } catch { } }; menu.Items.Add(mute); menu.Items.Add(new Separator()); var group = new MenuItem { Header = "그룹" }; var noGroup = new MenuItem { Header = "그룹 없음" }; noGroup.Click += (_, _) => _tabs.SetGroup(tab, null, null); group.Items.Add(noGroup); for (var i = 0; i < GroupColors.Length; i++) { var color = GroupColors[i]; var mi = new MenuItem { Header = $"그룹 {i + 1}" }; var sw = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 }; sw.Fill = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(color)); mi.Icon = sw; mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", color); group.Items.Add(mi); } menu.Items.Add(group); if (tab.GroupName != null) { var closeG = new MenuItem { Header = $"그룹 전체 닫기 ({tab.GroupName})" }; closeG.Click += (_, _) => _tabs.CloseGroup(tab.GroupName!); menu.Items.Add(closeG); } menu.Items.Add(new Separator()); var close = new MenuItem { Header = "탭 닫기" }; close.Click += (_, _) => _tabs.CloseTab(tab); menu.Items.Add(close); menu.PlacementTarget = sender as UIElement; menu.IsOpen = true; } private void SaveWorkspace(string name) { _workspaces.RemoveAll(w => w.Name == name); _workspaces.Insert(0, _tabs.CaptureWorkspace(name)); if (_workspaces.Count > 6) _workspaces.RemoveAt(_workspaces.Count - 1); try { Directory.CreateDirectory(AppDataDir); File.WriteAllText(Path.Combine(AppDataDir, "workspaces.json"), System.Text.Json.JsonSerializer.Serialize(_workspaces)); } catch { } Log($"워크스페이스 '{name}' 저장 ({_tabs.Tabs.Count} 탭)"); } private void SwitchWorkspace(WorkspaceSnapshot ws) { ExitSplit(); _tabs.SwitchWorkspace(ws); Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)"); } // ---- 카드 액션 ---- private void CardDownload_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) _ = _manager.StartDownload(it); } // 소셜 카드: yt-dlp -J 로 사용 가능한 화질 조회 → 드롭다운 교체 private void CardFetchFormats_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) _ = _manager.FetchFormatsAsync(it); } private void CardRetry_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) _ = _manager.RetryDownload(it); } // #1 다운로드 취소 private void CardCancel_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) { _manager.Cancel(it); Log($"취소 요청: {it.Title}"); } } private void CardFolder_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) OpenInExplorer(it.OutputPath); } private void OpenFolder_Click(object sender, RoutedEventArgs e) => OpenInExplorer(null); // #3 explorer 인자 인젝션 방지: 경로 정규화 + 존재 검증 + ArgumentList/UseShellExecute private void OpenInExplorer(string? selectFile) { try { var folder = Path.GetFullPath(_config.OutputFolder); if (!Directory.Exists(folder)) return; if (!string.IsNullOrEmpty(selectFile) && File.Exists(selectFile)) { var psi = new System.Diagnostics.ProcessStartInfo("explorer.exe") { UseShellExecute = false }; psi.ArgumentList.Add($"/select,{selectFile}"); System.Diagnostics.Process.Start(psi); } else { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = folder, UseShellExecute = true, }); } } catch { } } private void Settings_Click(object sender, RoutedEventArgs e) { var w = new SettingsWindow(_config, ClearBrowsingDataAsync) { Owner = this }; if (w.ShowDialog() == true) { _config = w.Config; ConfigStore.Save(_config); _resolver = new SearchEngineResolver(_config.SearchEngine); // T1-4 즉시 반영 _manager.UpdateMaxConcurrent(_config.MaxConcurrentDownloads); // #19 런타임 반영 if (w.ThemeChanged) RestartApp(); } } // 테마 변경 등은 리소스가 StaticResource 로 물려있어 런타임 전환이 어려움 → 재시작으로 적용. private static void RestartApp() { var exe = Environment.ProcessPath; if (!string.IsNullOrEmpty(exe)) System.Diagnostics.Process.Start(exe); Application.Current.Shutdown(); } private static string Trunc(string s, int n) => string.IsNullOrEmpty(s) ? "" : (s.Length <= n ? s : s[..n] + "…"); }