releases/VideoDownloader.App/MainWindow.xaml.cs

1920 lines
84 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Bookmark> _bookmarks = new();
private readonly ObservableCollection<HistoryEntry> _history = new();
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
// M0: 원격 접근 서버(모바일 연동)
private Server.ServerHost? _remoteServer;
// Phase D: 미디어 재생목록 (T2-10)
private readonly MediaPlaylist _playlist = new();
private PlayerWindow? _player;
// Phase C: 팔레트/부스트/워크스페이스/레이아웃
private readonly CommandPalette _palette = new();
private BoostStore _boostStore = null!;
private WorkspaceRoutingRules _routingRules = null!; // T3-1
private TabArchive _tabArchive = null!; // T3-2
internal TabArchive Archive => _tabArchive; // E2E 검증용
internal BoostStore BoostStore => _boostStore; // E2E 검증용
internal NotesStore Notes => _notes; // E2E 검증용
private NotesStore _notes = null!; // T3-12
private string? _currentWorkspaceName; // T3-1 라우팅 기준
private bool _compactMode; // T3-13
private bool _chromeRevealed; // T3-13 호버 복원 상태
private List<WorkspaceSnapshot> _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<string> _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"));
_routingRules = new WorkspaceRoutingRules(Path.Combine(AppDataDir, "routing.json")); // T3-1
_tabArchive = new TabArchive(Path.Combine(AppDataDir, "archive.json")); // T3-2
_notes = new NotesStore(Path.Combine(AppDataDir, "notes.json")); // T3-12
try
{
var wsPath = Path.Combine(AppDataDir, "workspaces.json");
if (File.Exists(wsPath))
_workspaces = System.Text.Json.JsonSerializer.Deserialize<List<WorkspaceSnapshot>>(File.ReadAllText(wsPath)) ?? new();
}
catch { }
_playlist.Load(Path.Combine(AppDataDir, "playlist.json"));
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);
/// <summary>앱 데이터 디렉터리(AppPaths.DataDir — VD_DATA_DIR 오버라이드 지원).</summary>
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
// T3-15 주소창 진행 바: 전 탭 네비게이션 집계 → 표시
_navTracker = new NavProgressTracker();
_navTracker.PropertyChanged += (_, e) =>
NavProgress.Visibility = e.PropertyName == nameof(NavProgressTracker.IsNavigating) && _navTracker.IsNavigating
? Visibility.Visible : Visibility.Collapsed;
_views.NavigationStarted += _navTracker.Started;
_views.NavigationCompleted += _navTracker.Completed;
// T3-11 권한 다이얼로그: 예=항상 허용 / 아니요=항상 거절 / 취소=이번만 거절
_views.PermissionPrompt += (cv, e) => Dispatcher.BeginInvoke(new Action(() =>
{
var kind = e.PermissionKind.ToString();
var origin = Services.TabViewService.OriginOf(e.Uri);
var r = System.Windows.MessageBox.Show(this,
$"{origin} 사이트가 {KoreanPermission(kind)} 권한을 요청합니다.\n허용하시겠습니까?\n\n예: 항상 허용 · 아니요: 항상 거절 · 취소: 이번만 거절",
"권한 요청", MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
e.State = r == MessageBoxResult.Yes ? Microsoft.Web.WebView2.Core.CoreWebView2PermissionState.Allow
: Microsoft.Web.WebView2.Core.CoreWebView2PermissionState.Deny;
e.SavesInProfile = r != MessageBoxResult.Cancel; // 항상 선택만 프로필 영속
e.Handled = true;
if (r != MessageBoxResult.Cancel)
_views.Permissions.Remember(kind, origin,
r == MessageBoxResult.Yes ? PermissionVerdict.Allow : PermissionVerdict.Deny);
}));
// T3-14 실드 배지 갱신
_views.TrackerBlocked += UpdateShield;
_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;
PlaylistList.ItemsSource = _playlist.Tracks;
PlaylistEmptyHint.Visibility = _playlist.Tracks.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
_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();
// M0: 설정돼 있으면 원격 접근 서버 자동 시작
if (_config.RemoteAccessEnabled && !string.IsNullOrEmpty(_config.RemoteAccessToken))
{
try
{
_remoteServer = await Server.ServerHost.StartAsync(new Server.ServerOptions
{
Port = 0,
Token = _config.RemoteAccessToken!,
LibraryFolder = Path.GetFullPath(_config.OutputFolder),
Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher),
});
Log($"원격 접근 서버 포트 {_remoteServer.Port}");
}
catch { }
}
// 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();
}
/// <summary>탭별 WebView2 생명주기·배선 전담 서비스(재구성 ④).</summary>
private Services.TabViewService? _views;
/// <summary>T3-15 주소창 진행 바 상태(전 탭 네비게이션 집계).</summary>
private NavProgressTracker? _navTracker;
/// <summary>T3-15: 파라미터 제거 후 클립보드 복사(주소창 우클릭).</summary>
private void CopyCleanUrl_Click(object sender, RoutedEventArgs e)
{
var clean = UrlCleaner.StripQuery(AddressBox.Text);
if (string.IsNullOrEmpty(clean)) return;
try { Clipboard.SetText(clean); Log($"파라미터 제거 URL 복사: {clean}"); } catch { }
}
/// <summary>T3-14: 실드 배지 갱신.</summary>
private void UpdateShield()
{
if (_views == null) return;
var n = _views.TrackerCounter.Total;
ShieldBadgeText.Text = n > 0 ? n.ToString() : "";
ShieldBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
ShieldBtn.ToolTip = n > 0 ? $"추적 요청 {n}건 차단됨" : "추적 방지";
}
/// <summary>T3-14: 실드 클릭 → 차단 도메인 목록 팝업.</summary>
private void Shield_Click(object sender, RoutedEventArgs e)
{
if (_views == null) return;
var byDomain = _views.TrackerCounter.ByDomain;
ShieldList.ItemsSource = byDomain.Count == 0
? new[] { "차단된 추적 요청이 없습니다" }
: byDomain.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Value,3} × {kv.Key}").ToList();
ShieldPopup.IsOpen = true;
}
/// <summary>T3-11: 권한 종류 한글 표기.</summary>
private static string KoreanPermission(string kind) => kind switch
{
"Microphone" => "마이크",
"Camera" => "카메라",
"Geolocation" => "위치",
"Notifications" => "알림",
"ClipboardRead" => "클립보드 읽기",
"Autoplay" => "자동 재생",
"FileReadWrite" => "파일 읽기/쓰기",
"PersistentStorage" => "영구 저장소",
_ => kind,
};
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<PageDownloadItem> 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();
_tabArchive.Add(tab.Title, tab.Url); // T3-2 닫힘 → 아카이브(영속, 500 상한)
_views?.RemoveView(tab);
}
private void UpdateNavState()
{
Dispatcher.BeginInvoke(new Action(() =>
{
try
{
var cv = Cv; // dispose 이후 이벤트 경로 방어
NavBack.IsEnabled = cv?.CanGoBack == true;
NavForward.IsEnabled = cv?.CanGoForward == true;
}
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { }
}));
}
// ---- 탭 스트립 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}");
AddToPlaylist(uri, GuessTitle(uri), MediaKind.Direct.ToString(), referer);
_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<List<CoreWebView2Cookie>> 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;
// T3-1: 도메인 라우팅 규칙 — 대상 워크스페이스가 다르면 자동 전환 후 이동(Arc ATC 패턴)
var routeTo = _routingRules.Match(intent.Target);
if (routeTo != null && routeTo != _currentWorkspaceName &&
_workspaces.FirstOrDefault(w => w.Name == routeTo) is { } wsTarget)
{
SwitchWorkspace(wsTarget);
_currentWorkspaceName = routeTo;
Log($"라우팅 규칙 → 워크스페이스 '{routeTo}' 전환");
}
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.Control | ModifierKeys.Shift) && e.Key == Key.M) { ToggleCompactMode(); e.Handled = true; } // T3-13
else if (mods == (ModifierKeys.Control | ModifierKeys.Alt) && e.Key == Key.N) { ToggleNotes(); e.Handled = true; } // T3-12
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 { }
try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } // T2-10
try { if (_remoteServer != null) _ = _remoteServer.DisposeAsync(); } catch { } // M0
}
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
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();
}
/// <summary>현재 프로필의 브라우저 데이터(쿠키·DOM 저장소·캐시) 삭제. 일반 모드: 영속 프로필 정리.</summary>
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<HistoryEntry>());
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.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; });
_palette.Register("재생목록 재생", "playlist play start", OpenPlayer);
_palette.Register("기기 연결 (QR 페어링)", "remote pair qr mobile phone", ShowPairing);
// ---- T3 잔여(데스크톱) ----
_palette.Register("이 사이트 흑백", "grayscale filter page action 흑백", () => ApplyPageFilter(PageFilter.Grayscale)); // T3-6
_palette.Register("이 사이트 세피아", "sepia filter page action 세피아", () => ApplyPageFilter(PageFilter.Sepia));
_palette.Register("페이지 액션 끄기", "filter off reset", () => ApplyPageFilter(PageFilter.None));
_palette.Register("노트 패널", "notes note 메모", ToggleNotes); // T3-12
_palette.Register("노트: 현재 페이지 추가", "note add", () => NotesAddPage_Click(this, new RoutedEventArgs()));
_palette.Register("컴팩트 모드 토글", "compact chrome hide ui", ToggleCompactMode); // T3-13
_palette.RegisterDynamic(() => _workspaces // T3-1: 사이트→워크스페이스 규칙
.Select(w => new PaletteCommand($"라우팅 규칙: 이 사이트 → '{w.Name}'", "routing rule workspace",
() => AddRoutingRuleForCurrentSite(w.Name))).ToList());
_palette.RegisterDynamic(() => _tabArchive.Search("") // T3-2: 아카이브 복구
.Take(8)
.Select(a => new PaletteCommand($"아카이브에서 열기: {a.Title}", $"archive reopen {a.Url}",
() => { var t = _tabs.NewTab(); Cv2Navigate(t, a.Url); })).ToList());
_palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess());
_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 ? "이 사이트 강제 다크 켜짐" : "이 사이트 강제 다크 꺼짐");
}
// ---- T3-6 페이지 액션(흑백/세피아 — Boost 통합) ----
internal void ApplyPageFilter(PageFilter filter)
{
var url = Cv?.Source;
if (string.IsNullOrEmpty(url)) { Log("페이지가 없습니다"); return; }
var cur = _boostStore.Get(url);
_boostStore.Set(url, cur.ForceDark, cur.CustomCss, filter);
_views?.ApplyBoostNow(_tabs.ActiveTab!);
Log(filter switch
{
PageFilter.Grayscale => "이 사이트 흑백 적용",
PageFilter.Sepia => "이 사이트 세피아 적용",
_ => "페이지 액션 해제",
});
}
// ---- T3-1 도메인 라우팅 규칙 ----
private void AddRoutingRuleForCurrentSite(string workspaceName)
{
var url = Cv?.Source;
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var u))
{ Log("페이지가 없습니다"); return; }
var host = u.Host;
if (host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) host = host[4..];
_routingRules.Add(host, workspaceName);
Log($"라우팅 규칙 추가: {host} → '{workspaceName}' (다음 이동부터 자동 전환)");
}
// ---- T3-2 아카이브 복구 보조 ----
private static void Cv2Navigate(TabItem tab, string url)
{
try { tab.View?.CoreWebView2?.Navigate(url); } catch { }
}
// ---- T3-12 노트 패널 ----
internal void ToggleNotes()
{
RefreshNotesList();
NotesPopup.IsOpen = true;
}
private void RefreshNotesList()
{
NotesList.ItemsSource = _notes.List()
.Select(n => $"[{n.CreatedAt:MM-dd HH:mm}] {n.Title} — {n.Body}")
.ToList();
NotesList.DisplayMemberPath = null;
}
internal void NotesAddPage_Click(object sender, RoutedEventArgs e)
{
var url = Cv?.Source ?? "";
var title = _tabs.ActiveTab?.Title ?? url;
_notes.Add(title, url);
Log("노트에 페이지 추가됨");
RefreshNotesList();
}
private void NotesDelete_Click(object sender, RoutedEventArgs e)
{
var idx = NotesList.SelectedIndex;
var list = _notes.List();
if (idx >= 0 && idx < list.Count)
{
_notes.Delete(list[idx].Id);
RefreshNotesList();
}
}
// ---- T3-13 컴팩트 모드(크롬 숨김 + 상단 엣지 호버 복원) ----
internal void ToggleCompactMode()
{
_compactMode = !_compactMode;
_chromeRevealed = false;
ApplyChromeVisibility();
Log(_compactMode ? "컴팩트 모드: 크롤 숨김 (상단 가장자리에 마우스를 올리면 복원)" : "컴팩트 모드 해제");
}
private void ApplyChromeVisibility()
{
var chromeRows = new[] { 0, 1, 2 }; // 툴바/북마크바/탭스트립
foreach (var i in chromeRows)
MainGrid.RowDefinitions[i].Height = (_compactMode && !_chromeRevealed) ? new GridLength(0) : GridLength.Auto;
if (!_compactMode) _chromeRevealed = false;
}
protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e)
{
if (_compactMode)
{
var p = e.GetPosition(this);
var wantReveal = p.Y <= 4; // 상단 엣지 4px
if (wantReveal != _chromeRevealed)
{
_chromeRevealed = wantReveal;
ApplyChromeVisibility();
}
else if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김
{
_chromeRevealed = false;
ApplyChromeVisibility();
}
}
base.OnPreviewMouseMove(e);
}
// ---- 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<string>(html) ?? "";
var wrapped = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#1b1c24;color:#d4d4dc;font-family:Consolas,monospace;font-size:12.5px;white-space:pre-wrap;padding:20px}</style></head><body>"
+ System.Net.WebUtility.HtmlEncode(decoded) + "</body></html>";
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 =>
$"<a href='{System.Net.WebUtility.HtmlEncode(b.Url)}' style='display:block;text-decoration:none;background:#262736;color:#e6e6ec;border-radius:12px;padding:18px 12px;text-align:center;font-size:12.5px;min-width:110px' onmouseover='this.style.background=#2f3042' onmouseout='this.style.background=#262736'><div style='font-size:26px;margin-bottom:8px'>&#127760;</div><div style='overflow:hidden;text-overflow:ellipsis;white-space:nowrap'>{System.Net.WebUtility.HtmlEncode(b.Title)}</div></a>").ToList();
var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI Variable Text','Segoe UI','Malgun Gothic',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#9aa0b4;font-size:12.5px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
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);
_currentWorkspaceName = ws.Name; // T3-1 라우팅 기준
Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)");
}
// ---- M0 원격 접근(모바일 연동) ----
private async Task ToggleRemoteAccess()
{
if (_remoteServer != null)
{
await _remoteServer.DisposeAsync();
_remoteServer = null;
_config.RemoteAccessEnabled = false;
ConfigStore.Save(_config);
Log("원격 접근을 껐습니다");
return;
}
var token = _config.RemoteAccessToken;
if (string.IsNullOrEmpty(token))
{
token = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(24))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
_config.RemoteAccessToken = token;
}
_config.RemoteAccessEnabled = true;
ConfigStore.Save(_config);
try
{
_remoteServer = await Server.ServerHost.StartAsync(new Server.ServerOptions
{
Port = 0,
Token = token,
LibraryFolder = Path.GetFullPath(_config.OutputFolder),
Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher),
});
Log($"원격 접근 켜짐 — 포트 {_remoteServer.Port}");
var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this };
pair.Show();
}
catch (Exception ex)
{
_remoteServer = null;
Log($"원격 접근 시작 실패(방화벽 확인): {ex.Message}");
}
}
private async void ShowPairing()
{
if (_remoteServer == null) { await ToggleRemoteAccess(); return; }
var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this };
pair.Show();
}
// ---- T2-10 미디어 재생목록 ----
private void AddToPlaylist(string url, string title, string kind, string? referer)
{
_playlist.Add(new MediaTrack(url, Trunc(title, 70), kind, DateTime.UtcNow, Referer: referer));
try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { }
PlaylistEmptyHint.Visibility = _playlist.Tracks.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
private static string GuessTitle(string url)
{
try
{
var name = Path.GetFileName(new Uri(url).AbsolutePath);
return string.IsNullOrWhiteSpace(name) ? url : Uri.UnescapeDataString(name);
}
catch { return url; }
}
private void TogglePlaylistPanel(object sender, RoutedEventArgs e)
{
PlaylistPopup.IsOpen = !PlaylistPopup.IsOpen;
}
private void PlaylistPlay_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is MediaTrack t)
{
_playlist.CurrentIndex = _playlist.Tracks.IndexOf(t);
OpenPlayer();
}
}
private void PlaylistRemove_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is MediaTrack t) _playlist.Remove(t);
}
private void PlaylistClear_Click(object sender, RoutedEventArgs e) => _playlist.Clear();
private void PlaylistPlayAll_Click(object sender, RoutedEventArgs e) => OpenPlayer();
private void OpenPlayer()
{
if (_player == null || _player.IsLoaded == false)
{
_player = new PlayerWindow(_env!, _config, _playlist) { Owner = this };
_player.Closed += (_, _) => _player = null;
_player.Show();
}
else { _player.Activate(); _player.PlayCurrent(); }
}
// ---- 카드 액션 ----
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] + "…");
}