releases/VideoDownloader.App/MainWindow.xaml.cs
Yun Chan 02995f4153 MEDIABROWSER 개명·업데이트 채널 전환·아이콘 tofu 근본 수정 (BACKLOG 13차) — ①개명: installer.iss AppName/출력 MediaBrowser-Setup-*(AppId·exe·데이터 경로 유지로 업그레이드 호환), 워크플로 산출물명(mediabrowser-*.tar.gz, MediaBrowser-<ver>-arm64.apk), 창 타이틀·모바일 표시명·obtainium.json·README. ②UpdateChecker: apiBase 주입(기본=자체 Forgejo /api/v1, GitHub 는 apiBase 로 호환), osx 자산 tar.gz 허용(기존 dmg 만 → mac 영원히 자산 없음 결함 수정), AppConfig 기본 UpdateRepo=Video-Downloader/releases+UpdateApiBase — 테스트 4건. ③아이콘 tofu(사용자 보고 "절대 안고쳐지네") 근본 원인: Shared.xaml 암시적 TextBlock 스타일의 FontFamily=UiFont 강제가 버튼 ContentPresenter 자동 TextBlock 의 IconFont 상속을 덮어 모든 PUA 글리프가 notdef — 폰트 설치/글리프 존재/HW·SW 렌더/RTB/DPI/런타임 8·10/자체포함·공유 전부 배제 후 실창 픽셀 비교로 격리, 콘텐츠 TextBlock 의 실폰트가 Pretendard 체인임을 테스트로 실증. 수정: 암시 스타일 FontFamily 제거(창 루트 상속으로 통일, PairingWindow/PlayerWindow 는 SetResourceReference), PUA+텍스트 혼합 콘텐츠 7곳+코드 1곳 정리, 순수 글리프 2곳 IconFont 명시. 회귀 방어: IconGlyphXamlTests(정적 스캔)·IconContentFontTests(실창 폰트 단정)·IconGlyphRenderTests(픽셀 비교) — 462/462 GREEN, 수정 전후 실창 캡처로 글리프 렌더 확인
2026-08-18 07:13:18 +09:00

2214 lines
100 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.Automation.Peers;
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 Microsoft.Win32;
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 = GroupHistoryByDate(_history); // UC-M20 날짜 묶음 뷰
_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();
// UI 복잡 버전 유즈케이스(§A~§J) 초기화 — MainWindow.Usecases.cs
InitializeUsecases();
// UC-L11/L12/L26 창 위치·크기·최대화·전체화면 복원
RestoreWindowBounds();
Loaded += async (_, _) => await InitializeBrowserAsync();
PreviewKeyDown += OnPreviewKeyDown;
PreviewMouseDown += Window_PreviewMouseDown; // UC-K05 마우스 제스처(XButton)·UC-K08 계열
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 += (tab, cv) =>
{
cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지
WireTabMouseGestures(cv); // UC-K06 휠 클릭 새 탭
};
_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)
{
MaxTabs = _config.MaxTabs, // UC-B11 탭 상한
};
_tabs.ActiveChanged += OnActiveTabChanged;
_tabs.TabRemoved += OnTabRemoved;
_views.TabCoreReady += (tab, cv) =>
{
// UC-K15 새 탭 기본 줌 — 사이트별 저장 줌이 없을 때만 적용(T1-7 과 충돌 방지)
try { if (tab.View != null && _zoomStore.Count == 0 && Math.Abs(_config.DefaultZoomFactor - 1.0) > 0.01) tab.View.ZoomFactor = _config.DefaultZoomFactor; } catch { }
};
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);
// UC-B12 세션 복원을 잠자기로 — 비활성 탭 뷰를 접어 빠른 시작
if (_config.RestoreTabsSuspended && _tabs.ActiveTab != null)
foreach (var t in _tabs.Tabs.Where(t => !ReferenceEquals(t, _tabs.ActiveTab)).ToList())
try { if (t.View != null) t.View.Visibility = Visibility.Collapsed; } catch { }
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}");
UpdateServerStatusIcon(); // §J 서버 상태 아이콘
}
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;
ShieldDomainStats.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;
}
// UC-K14 현재 탭 줌 배율 표시 갱신
if (ZoomIndicator != null && tab.View != null)
ZoomIndicator.Content = $"{Math.Round(tab.View.ZoomFactor * 100)}%";
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)
{
// 휠 클릭(미들) → 탭 닫기 (브라우저 관례). MiddleButton 상태로 판정.
if (e.MiddleButton == 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.F12) { OpenDevTools(); e.Handled = true; } // UC-K12
else if (e.Key == Key.Escape)
{
// UC-K10: 열린 팝업이 있으면 모두 닫고, 없으면 페이지 로딩 중지
if (AnyPopupOpen()) CloseAllPopups();
else 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 double ClampPanelWidth(double w) => Math.Clamp(w, Math.Max(160, _config.RightPanelMinWidth), MaxExpandedWidth); // UC-L20 최소 폭 설정 반영
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)
{
// UC-K11: 다운로드 진행 중 종료 확인
var activeDownloads = _manager.Items.Count(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중" or "대기")
+ _pageDownloads.Items.Count(i => !i.IsFinished);
if (activeDownloads > 0 && !_forceClose)
{
e.Cancel = true;
var r = MessageBox.Show(this,
$"진행 중인 다운로드가 {activeDownloads}건 있습니다.\n그래도 종료하시겠습니까?",
"종료 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (r != MessageBoxResult.Yes) return;
_forceClose = true;
Close();
return;
}
_config.RightPanelCollapsed = _panelCollapsed;
if (!_panelCollapsed) _config.RightPanelWidth = ClampPanelWidth(RightCol.Width.Value);
_config.CompactMode = _compactMode; // UC-L07
if (_splitTab != null)
_config.SplitViewRatio = Math.Clamp(SplitCol.Width.Value / Math.Max(1, SplitCol.Width.Value + TabHost.ActualWidth), 0.1, 0.9); // UC-L10
_config.FullScreen = _fullscreen; // UC-L26
_config.WindowMaximized = WindowState == WindowState.Maximized; // UC-L12
_config.WindowBounds = $"{Left},{Top},{Width},{Height}"; // UC-L11
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
System.Net.NetworkInformation.NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged; // §J
try { Microsoft.Win32.SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; } catch { }
}
private bool _forceClose;
/// <summary>UC-L11/L12/L26: 저장된 창 상태(위치·크기·최대화) 복원.</summary>
private void RestoreWindowBounds()
{
try
{
if (!string.IsNullOrWhiteSpace(_config.WindowBounds))
{
var parts = _config.WindowBounds.Split(',');
if (parts.Length == 4 &&
double.TryParse(parts[0], out var l) && double.TryParse(parts[1], out var t) &&
double.TryParse(parts[2], out var w) && double.TryParse(parts[3], out var h))
{
// 화면 밖 좌표(모니터 분리 후) 방어 — 보이는 가상 화면 범위로 클램프
var bounds = SystemParameters.VirtualScreenLeft;
var boundsTop = SystemParameters.VirtualScreenTop;
var boundsW = SystemParameters.VirtualScreenWidth;
var boundsH = SystemParameters.VirtualScreenHeight;
if (l < bounds || t < boundsTop || l + w > bounds + boundsW || t + h > boundsTop + boundsH)
WindowStartupLocation = WindowStartupLocation.CenterScreen;
else { Left = l; Top = t; Width = Math.Max(600, w); Height = Math.Max(400, h); }
}
}
if (_config.WindowMaximized) WindowState = WindowState.Maximized;
}
catch { }
}
/// <summary>UC-K10 판정: 팝업 중 하나라도 열려 있는가.</summary>
private bool AnyPopupOpen() =>
BookmarksPopup.IsOpen || HistoryPopup.IsOpen || AddressPopup.IsOpen || NotesPopup.IsOpen ||
ShieldPopup.IsOpen || DownloadsPopup.IsOpen || TabSearchPopup.IsOpen || PalettePopup.IsOpen ||
PlaylistPopup.IsOpen || LinkPopup.IsOpen || LibraryPopup.IsOpen || ArchivePopup.IsOpen ||
ShortcutHelpPopup.IsOpen || NotificationCenterPopup.IsOpen || TabVolumePopup.IsOpen;
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
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 HistoryRow h) // UC-M20 묶음 뷰 행
{
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());
HistoryList.ItemsSource = GroupHistoryByDate(_history); // UC-M20 날짜 묶음 뷰 유지
}
// ---- 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 }; // UC-K20
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();
// ---- UI 복잡 버전 유즈케이스 명령(docs/UI_USECASES.md) ----
_palette.Register("모두 일시정지", "pause all stop downloads 전체", PauseAllDownloads); // UC-K09
_palette.Register("모두 재개", "resume all downloads 전체", ResumeAllDownloads);
_palette.Register("라이브러리", "library files 받은 파일", () => LibraryBtn_Click(this, new RoutedEventArgs())); // UC-M01
_palette.Register("영역 캡처", "region capture screenshot 영역", async () => await RegionCapture()); // UC-K18
_palette.Register("프로필 전환", "profile switch session 세션", SwitchProfile); // UC-K13
_palette.Register("즐겨찾기 HTML 내보내기", "bookmark export html", () => ExportBookmarks()); // UC-M19
_palette.Register("단축키 목록", "shortcut help keys", () => ShortcutHelpPopup.IsOpen = true); // UC-K04
_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;
}
private System.Windows.Threading.DispatcherTimer? _edgeRevealTimer;
private bool _edgePending;
protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e)
{
if (_compactMode)
{
var p = e.GetPosition(this);
var wantReveal = p.Y <= 4; // 상단 엣지 4px
// UC-L24 EdgeRevealDelayMs: 엣지에 머무는 동안만 복원(순간 지나침 무시)
if (wantReveal && !_chromeRevealed && !_edgePending)
{
_edgePending = true;
_edgeRevealTimer ??= new System.Windows.Threading.DispatcherTimer();
_edgeRevealTimer.Interval = TimeSpan.FromMilliseconds(Math.Max(0, _config.EdgeRevealDelayMs));
_edgeRevealTimer.Tick -= EdgeRevealTick;
_edgeRevealTimer.Tick += EdgeRevealTick;
_edgeRevealTimer.Stop();
_edgeRevealTimer.Start();
}
else if (!wantReveal)
{
_edgePending = false;
_edgeRevealTimer?.Stop();
if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김
{
_chromeRevealed = false;
ApplyChromeVisibility();
}
}
}
base.OnPreviewMouseMove(e);
}
private void EdgeRevealTick(object? sender, EventArgs e)
{
_edgeRevealTimer?.Stop();
_edgePending = false;
if (!_chromeRevealed)
{
_chromeRevealed = true;
ApplyChromeVisibility();
}
}
// ---- 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;
var total = TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth;
var ratio = _config.SplitViewRatio is > 0.1 and < 0.9 ? _config.SplitViewRatio : 0.5; // UC-L10 복원
SplitCol.Width = new GridLength(Math.Max(280, total * ratio));
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;
// UC-F18: 폰트 하드코딩 대신 앱 폰트 스택 주입(App.ApplyFontSettings 가 관리)
var fontStack = (FontFamily)Application.Current.FindResource("UiFont");
var fontCss = string.Join(",", fontStack.Source.Split(',').Select(f => $"'{f.Trim()}'"));
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 lang='ko'><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:" + fontCss + ",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 탭 컨텍스트 메뉴 + 워크스페이스 ----
// UC-B17: 그룹 색 팔레트를 테마 리소스(Shared.xaml GroupPalette)에서 가져온다.
private string[] GroupColors => (TryFindResource("GroupPalette") as Array)?
.OfType<SolidColorBrush>().Select(b => $"#{b.Color.R:X2}{b.Color.G:X2}{b.Color.B:X2}").ToArray()
?? Array.Empty<string>();
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);
// UC-B23 탭 볼륨
var volume = new MenuItem { Header = "볼륨 조절" };
volume.Click += (_, _) => OpenTabVolume(tab);
menu.Items.Add(volume);
menu.Items.Add(new Separator());
// UC-B01 탭 잠금
var lockItem = new MenuItem { Header = tab.IsLocked ? "잠금 해제" : "탭 잠금(닫기 방지)" };
lockItem.Click += (_, _) => { tab.IsLocked = !tab.IsLocked; Log(tab.IsLocked ? "탭을 잠갔습니다" : "탭 잠금을 해제했습니다"); };
menu.Items.Add(lockItem);
// UC-B02 사용자 지정 탭 색
var color = new MenuItem { Header = "탭 색상" };
var noColor = new MenuItem { Header = "색 없음" };
noColor.Click += (_, _) => tab.CustomColor = null;
color.Items.Add(noColor);
foreach (var c in GroupColors)
{
var mi = new MenuItem { Header = c };
var swatch = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 };
swatch.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(c));
mi.Icon = swatch;
mi.Click += (_, _) => tab.CustomColor = c;
color.Items.Add(mi);
}
menu.Items.Add(color);
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 c = GroupColors[i];
var mi = new MenuItem { Header = $"그룹 {i + 1}" };
var sw = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 };
sw.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(c));
mi.Icon = sw;
mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", c);
group.Items.Add(mi);
}
menu.Items.Add(group);
if (tab.GroupName != null)
{
var rename = new MenuItem { Header = $"그룹 이름 바꾸기 ({tab.GroupName})" }; // UC-B04
rename.Click += (_, _) =>
{
var name = Prompt("그룹 이름 바꾸기", "새 그룹 이름", tab.GroupName!);
if (name != null && _tabs.RenameGroup(tab.GroupName!, name.Trim()))
Log($"그룹 이름 변경: {name.Trim()}");
};
menu.Items.Add(rename);
var collapse = new MenuItem { Header = tab.IsGroupCollapsed ? "그룹 펼치기" : "그룹 접기" }; // UC-B03
collapse.Click += (_, _) => _tabs.ToggleGroupCollapsed(tab.GroupName!);
menu.Items.Add(collapse);
var closeG = new MenuItem { Header = $"그룹 전체 닫기 ({tab.GroupName})" };
closeG.Click += (_, _) =>
{
var n = _tabs.Tabs.Count(t => t.GroupName == tab.GroupName && !t.IsLocked);
// UC-B09 다중 닫기 확인
if (_config.ConfirmCloseMultipleTabs && n > 1 &&
MessageBox.Show(this, $"그룹 탭 {n}개를 닫을까요?", "탭 닫기", MessageBoxButton.OKCancel) != MessageBoxResult.OK)
return;
_tabs.CloseGroup(tab.GroupName!);
};
menu.Items.Add(closeG);
var bmG = new MenuItem { Header = "그룹 즐겨찾기에 저장" }; // UC-B24
bmG.Click += (_, _) => BookmarkGroup(tab.GroupName);
menu.Items.Add(bmG);
}
menu.Items.Add(new Separator());
var close = new MenuItem { Header = "탭 닫기" };
close.Click += (_, _) =>
{
if (!_tabs.CloseTab(tab)) Log("잠긴 탭은 닫을 수 없습니다 (탭 잠금 해제 후 시도)"); // UC-B01
};
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("원격 접근을 껐습니다");
UpdateServerStatusIcon(); // §J 서버 상태 아이콘
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),
EnableMdns = true, // MOBILE_PLAN §3: _videodl._tcp 발견 보조 (실패해도 서버는 뜬다)
});
Log($"원격 접근 켜짐 — 포트 {_remoteServer.Port}");
UpdateServerStatusIcon(); // §J 서버 상태 아이콘
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)
{
if (!ConfirmDuplicateDownload(it)) return; // UC-D25 중복 확인
_ = _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 런타임 반영
ApplyTheme(_config.Theme); // §A 라이브 테마(재시작 없음)
UpdateShortcutHelp(); // §H 단축키 재배치 반영
}
}
/// <summary>테마 변경은 리소스를 DynamicResource 로 물려 런타임 전환이 가능해졌다(UC-T04).
/// 프록시·프로필 등 재시작이 필요한 변경은 설정창의 RestartBanner 가 안내한다.</summary>
internal 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] + "…");
// ==================== UI 유즈케이스 핸들러(소스 계약상 MainWindow.xaml.cs 배치) ====================
private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
if (e.Category != UserPreferenceCategory.General) return;
Dispatcher.BeginInvoke(new Action(() =>
{
// 고대비 켜/끄 → 설정 테마 무시하고 즉시 따라감(UC-A04)
if (SystemParameters.HighContrast && App.CurrentTheme != "highcontrast") ApplyTheme("highcontrast");
else if (!SystemParameters.HighContrast && App.CurrentTheme == "highcontrast") ApplyTheme(_config.Theme);
}));
}
private void CopyStatus_Click(object sender, MouseButtonEventArgs e)
{
try { Clipboard.SetText(StatusText.Text); _toast?.Show("상태 메시지를 복사했습니다", "info"); } catch { }
}
/// <summary>UC-K19 노트 편집 내용 저장.</summary>
private void NotesSave_Click(object sender, RoutedEventArgs e)
{
_notes.Save();
_toast?.Show("노트를 저장했습니다", "success");
}
/// <summary>UC-M17 즐겨찾기 편집(이름·URL).</summary>
private void EditBookmark_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
var title = Prompt("즐겨찾기 편집", "이름", b.Title);
if (title == null) return;
var url = Prompt("즐겨찾기 편집", "URL", b.Url);
if (url == null) return;
var idx = _bookmarks.IndexOf(b);
if (idx >= 0) _bookmarks[idx] = new Bookmark(title.Trim(), url.Trim(), b.Folder);
BrowserStore.SaveBookmarks(_bookmarks.ToList());
RefreshBookmarkBar();
}
/// <summary>UC-M16 즐겨찾기 폴더로 정리 — 항목을 지정 폴더로 이동.</summary>
private void BookmarkFolder_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
var folder = Prompt("폴더 지정", "폴더 이름(비우면 최상위)", b.Folder ?? "");
if (folder == null) return;
var idx = _bookmarks.IndexOf(b);
if (idx >= 0) _bookmarks[idx] = new Bookmark(b.Title, b.Url, string.IsNullOrWhiteSpace(folder) ? null : folder.Trim());
BrowserStore.SaveBookmarks(_bookmarks.ToList());
_toast?.Show($"'{Trunc(b.Title, 24)}' 를 '{(string.IsNullOrWhiteSpace(folder) ? "최상위" : folder.Trim())}' 로 이동", "info");
}
private void LibraryPlay_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is LibraryEntry entry) PlayLibraryEntry(entry);
}
/// <summary>UC-M8 삭제(휴지통 아님 — 확인 후 영구 삭제).</summary>
private void LibraryDelete_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not LibraryEntry entry) return;
if (MessageBox.Show(this, $"'{entry.Name}' 파일을 삭제할까요?", "라이브러리",
MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK) return;
try { File.Delete(entry.Path); } catch (Exception ex) { Log($"삭제 실패: {ex.Message}"); }
RefreshLibrary();
}
/// <summary>UC-M9 포함 폴더 열기(파일 선택 상태).</summary>
private void LibraryOpenFolder_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is LibraryEntry entry) OpenInExplorer(entry.Path);
}
/// <summary>UC-M13 재생목록 파일 내보내기(M3U).</summary>
private void PlaylistExport_Click(object sender, RoutedEventArgs e)
{
var dlg = new SaveFileDialog { Filter = "M3U 재생목록|*.m3u", FileName = "playlist.m3u" };
if (dlg.ShowDialog() != true) return;
var sb = new System.Text.StringBuilder("#EXTM3U\n");
foreach (var t in _playlist.Tracks)
sb.AppendLine($"#EXTINF:-1,{t.Title}\n{t.Url}");
File.WriteAllText(dlg.FileName, sb.ToString());
}
/// <summary>UC-A09: 메인 창 자동화 피어 — 창 이름에 현재 상태 메시지를 노출해 낭독기가 상태를 읽게 한다.</summary>
protected override AutomationPeer OnCreateAutomationPeer()
=> new MainWindowAutomationPeer(this);
private sealed class MainWindowAutomationPeer : WindowAutomationPeer
{
public MainWindowAutomationPeer(Window owner) : base(owner) { }
protected override string GetNameCore() => "MediaBrowser 메인 창";
}
}