2214 lines
100 KiB
C#
2214 lines
100 KiB
C#
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'>🌐</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() => "Video Downloader 메인 창";
|
||
}
|
||
|
||
}
|