962 lines
41 KiB
C#
962 lines
41 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.Media;
|
|
using Microsoft.Web.WebView2.Core;
|
|
using VideoDownloader.Browser;
|
|
using VideoDownloader.Browser.Tabs;
|
|
using VideoDownloader.App.Core;
|
|
using VideoDownloader.Core.Detection;
|
|
using VideoDownloader.App.ViewModels;
|
|
using VideoDownloader.Core.Config;
|
|
using Wv2 = Microsoft.Web.WebView2.Wpf;
|
|
using TabItem = VideoDownloader.Browser.Tabs.TabItem;
|
|
|
|
namespace VideoDownloader.App;
|
|
|
|
public partial class MainWindow : Window
|
|
{
|
|
private readonly DownloadManager _manager;
|
|
private AppConfig _config;
|
|
private int _totalRequests;
|
|
private string _appLogPath = "";
|
|
|
|
// ---- 탭 아키텍처 (Phase A) ----
|
|
private CoreWebView2Environment? _env;
|
|
private TabManager _tabs = null!;
|
|
internal TabManager Tabs => _tabs;
|
|
internal Wv2.WebView2? ActiveView => _tabs?.ActiveTab?.View;
|
|
private CoreWebView2? Cv => _tabs?.ActiveTab?.Core;
|
|
|
|
private readonly ObservableCollection<Bookmark> _bookmarks = new();
|
|
private readonly ObservableCollection<HistoryEntry> _history = new();
|
|
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
|
|
|
|
// T1-4 검색엔진 / T1-7 사이트별 줌 / T1-3 페이지 내 검색 / T1-11 전체화면
|
|
private SearchEngineResolver _resolver = null!;
|
|
private SiteZoomStore _zoomStore = null!;
|
|
private bool _fullscreen;
|
|
private WindowState _stateBeforeFullscreen = WindowState.Normal;
|
|
|
|
// #15 위장 미디어(image/png 등) magic byte 검증용. 세션당 상한 두어 폭주 방지.
|
|
private static readonly HttpClient ProbeClient = new(new HttpClientHandler { AllowAutoRedirect = true })
|
|
{ Timeout = TimeSpan.FromSeconds(15) };
|
|
private readonly HashSet<string> _probed = new();
|
|
private int _probeCount;
|
|
|
|
// 범용 우측 패널: 접기/펼치기 + 폭 리사이즈 상태(설정에 저장)
|
|
private bool _panelCollapsed;
|
|
private double _panelExpandedWidth = 420;
|
|
private const double CollapsedPanelWidth = 56;
|
|
private const double MinExpandedWidth = 280;
|
|
private const double MaxExpandedWidth = 900;
|
|
|
|
private static string SessionPath => Path.Combine(AppDataDir, "session.json");
|
|
|
|
public MainWindow()
|
|
{
|
|
InitializeComponent();
|
|
_config = ConfigStore.Load();
|
|
Directory.CreateDirectory(_config.OutputFolder);
|
|
_appLogPath = Path.Combine(_config.OutputFolder, "app.log");
|
|
try { File.WriteAllText(_appLogPath, $"=== 시작 {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==={Environment.NewLine}"); } catch { }
|
|
_manager = new DownloadManager(_config, GetCookiesAsync, Log);
|
|
DataContext = _manager;
|
|
|
|
foreach (var b in BrowserStore.LoadBookmarks()) _bookmarks.Add(b);
|
|
foreach (var h in BrowserStore.LoadHistory()) _history.Add(h);
|
|
BookmarksList.ItemsSource = _bookmarks;
|
|
HistoryList.ItemsSource = _history;
|
|
|
|
_resolver = new SearchEngineResolver(_config.SearchEngine);
|
|
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
|
|
|
|
Loaded += async (_, _) => await InitializeBrowserAsync();
|
|
PreviewKeyDown += OnPreviewKeyDown;
|
|
StateChanged += OnWindowStateChanged;
|
|
AddressBox.LostKeyboardFocus += (_, _) => AddressPopup.IsOpen = false;
|
|
|
|
// 범용 우측 패널: 저장된 접힘/폭 상태 복원
|
|
_panelExpandedWidth = ClampPanelWidth(_config.RightPanelWidth);
|
|
ApplyPanelState(_config.RightPanelCollapsed);
|
|
Closing += OnWindowClosing;
|
|
}
|
|
|
|
// 보더리스: Win11 어두운 타이틀바 + 둥근 모서리(DWMWA_WINDOW_CORNER_PREFERENCE)
|
|
protected override void OnSourceInitialized(EventArgs e)
|
|
{
|
|
base.OnSourceInitialized(e);
|
|
try
|
|
{
|
|
var hwnd = new WindowInteropHelper(this).Handle;
|
|
int dark = App.IsLightTheme ? 0 : 1; // DWMWA_USE_IMMERSIVE_DARK_MODE (라이트 테마면 밝게)
|
|
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
|
|
int round = 2; // DWMWCP_ROUND
|
|
DwmSetWindowAttribute(hwnd, 33, ref round, sizeof(int));
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
private void OnWindowStateChanged(object? sender, EventArgs e)
|
|
{
|
|
// 최대화 시 모서리 각진(Fluent 표준), 복원 시 둥글게. 캡션 아이콘도 전환.
|
|
try
|
|
{
|
|
var hwnd = new WindowInteropHelper(this).Handle;
|
|
int pref = WindowState == WindowState.Maximized ? 0 : 2; // 0=기본(최대화 시 사각)
|
|
DwmSetWindowAttribute(hwnd, 33, ref pref, sizeof(int));
|
|
if (MaxRestoreBtn != null)
|
|
{
|
|
MaxRestoreBtn.Content = WindowState == WindowState.Maximized ? "" : "";
|
|
MaxRestoreBtn.ToolTip = WindowState == WindowState.Maximized ? "복원" : "최대화";
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
// ---- 보더리스: 툴바의 빈 공간을 드래그 (버튼/입력창 위는 제외해 클릭 전달) ----
|
|
private void TitleBar_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
// 더블클릭 → 최대화/복원 (네이티브 타이틀바 동작)
|
|
if (e.ClickCount >= 2) { MaximizeToggle(); return; }
|
|
|
|
// 클릭된 대상(또는 조상)이 버튼/입력창이면 드래그하지 않고 클릭을 그대로 흘려보낸다
|
|
for (var d = e.OriginalSource as DependencyObject; d != null; d = LogicalTreeHelper.GetParent(d) ?? VisualTreeHelper.GetParent(d))
|
|
{
|
|
if (d is ButtonBase || d is TextBox) return;
|
|
}
|
|
if (e.LeftButton == MouseButtonState.Pressed) { try { DragMove(); } catch { } }
|
|
}
|
|
|
|
private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
|
|
private void MaxRestore_Click(object sender, RoutedEventArgs e) => MaximizeToggle();
|
|
private void Close_Click(object sender, RoutedEventArgs e) => Close();
|
|
|
|
private void MaximizeToggle()
|
|
=> WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
|
|
|
|
[DllImport("dwmapi.dll")]
|
|
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
|
|
|
|
/// <summary>앱 데이터 디렉터리(AppPaths.DataDir — VD_DATA_DIR 오버라이드 지원).</summary>
|
|
internal static string AppDataDir => AppPaths.DataDir;
|
|
|
|
internal static string ProfileFolder => Path.Combine(AppDataDir, "webview");
|
|
|
|
// ==================== 탭 생명주기 (Phase A) ====================
|
|
|
|
private async Task InitializeBrowserAsync()
|
|
{
|
|
try
|
|
{
|
|
// 명시적 영속 프로필: 기본(exe 디렉터리) 폴더는 빌드/재배포 시 날아가 로그인이 풀림 → %AppData% 로 고정.
|
|
Directory.CreateDirectory(ProfileFolder);
|
|
_env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: ProfileFolder);
|
|
}
|
|
catch (Exception ex) { Log($"WebView2 초기화 실패: {ex.Message}"); return; }
|
|
|
|
_views = new Services.TabViewService(_env!, _config, TabHost, _zoomStore, Dispatcher)
|
|
{
|
|
NewTabProvider = () => Task.FromResult(_tabs.NewTab(activate: false)),
|
|
};
|
|
_views.TabCoreReady += (_, cv) => cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지
|
|
_views.DownloadStarting += OnDownloadStarting;
|
|
_views.UrlChanged += OnUrlChanged;
|
|
_views.TitleChanged += OnTitleChanged;
|
|
_views.HistoryChanged += UpdateNavState;
|
|
_views.FindStateChanged += tab =>
|
|
{
|
|
if (tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText;
|
|
};
|
|
|
|
_tabs = new TabManager(() => new TabItem(), _views.AttachView);
|
|
_tabs.ActiveChanged += OnActiveTabChanged;
|
|
_tabs.TabRemoved += OnTabRemoved;
|
|
TabList.ItemsSource = _tabs.Tabs;
|
|
UpdatePrivateButton();
|
|
|
|
// T1-10 세션 복원: 저장된 탭이 있으면 복원, 아니면 홈 탭
|
|
var snap = _config.RestoreSession ? TabSessionStore.Load(SessionPath) : null;
|
|
if (snap != null && snap.Urls.Count > 0)
|
|
_tabs.RestoreSession(snap);
|
|
else
|
|
_tabs.NewTab(_config.HomeUrl);
|
|
UpdateNavState();
|
|
}
|
|
|
|
/// <summary>탭별 WebView2 생명주기·배선 전담 서비스(재구성 ④).</summary>
|
|
private Services.TabViewService? _views;
|
|
|
|
private void OnUrlChanged(TabItem tab, string url)
|
|
{
|
|
if (tab.PendingHistoryUrl != null) RecordHistory(tab.PendingHistoryUrl, tab.PendingHistoryUrl);
|
|
if (tab.IsActive && !AddressBox.IsKeyboardFocused) AddressBox.Text = url;
|
|
tab.PendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url;
|
|
DetectSocial(tab, url);
|
|
}
|
|
|
|
private void OnTitleChanged(TabItem tab, string title)
|
|
{
|
|
tab.Title = string.IsNullOrWhiteSpace(title) ? (tab.Url.StartsWith("http") ? tab.Url : "새 탭") : title;
|
|
if (tab.PendingHistoryUrl != null)
|
|
{
|
|
RecordHistory(string.IsNullOrWhiteSpace(title) ? tab.PendingHistoryUrl : title, tab.PendingHistoryUrl);
|
|
tab.PendingHistoryUrl = null;
|
|
}
|
|
// 소셜 카드 제목을 페이지 타이틀로 갱신(출력 파일명 포함, 시작 전일 때)
|
|
if (tab.PendingSocialUrl != null && !string.IsNullOrWhiteSpace(title))
|
|
{
|
|
_manager.RenameSocial(new Uri(tab.PendingSocialUrl), title);
|
|
tab.PendingSocialUrl = null;
|
|
}
|
|
}
|
|
|
|
private void OnDownloadStarting(CoreWebView2 cv, CoreWebView2DownloadStartingEventArgs de)
|
|
{
|
|
try
|
|
{
|
|
var folder = Path.GetFullPath(_config.OutputFolder);
|
|
Directory.CreateDirectory(folder);
|
|
var fn = Path.GetFileName(de.ResultFilePath);
|
|
if (string.IsNullOrWhiteSpace(fn)) fn = $"download_{DateTime.Now:yyyyMMdd_HHmmss}";
|
|
de.ResultFilePath = MakeUniqueDownloadName(folder, fn);
|
|
_manager.LogPanel($"📥 페이지 다운로드 → 저장 폴더: {fn}");
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
private void OnActiveTabChanged(TabItem tab)
|
|
{
|
|
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;
|
|
UpdateNavState();
|
|
}
|
|
|
|
private void OnTabRemoved(TabItem tab) => _views?.RemoveView(tab);
|
|
|
|
private void UpdateNavState()
|
|
{
|
|
var cv = Cv;
|
|
Dispatcher.BeginInvoke(new Action(() =>
|
|
{
|
|
NavBack.IsEnabled = cv?.CanGoBack == true;
|
|
NavForward.IsEnabled = cv?.CanGoForward == true;
|
|
}));
|
|
}
|
|
|
|
// ---- 탭 스트립 UI 이벤트 ----
|
|
private void NewTab_Click(object sender, RoutedEventArgs e) => _tabs.NewTab(_config.HomeUrl);
|
|
|
|
private void Tab_Click(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if ((sender as FrameworkElement)?.DataContext is TabItem tab)
|
|
_tabs.Activate(tab);
|
|
}
|
|
|
|
private void Tab_PreviewMouseDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
// 휠 클릭(미들) → 탭 닫기 (브라우저 관례)
|
|
if (e.ChangedButton == MouseButton.Middle && e.ButtonState == MouseButtonState.Pressed)
|
|
{
|
|
if ((sender as FrameworkElement)?.DataContext is TabItem tab)
|
|
{
|
|
_tabs.CloseTab(tab);
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void TabClose_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if ((sender as FrameworkElement)?.DataContext is TabItem tab)
|
|
_tabs.CloseTab(tab);
|
|
}
|
|
|
|
// T1-8 스피커 아이콘 클릭 → 음소거 토글
|
|
private void TabAudio_Click(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if ((sender as FrameworkElement)?.DataContext is TabItem { Core: { } cv } tab)
|
|
{
|
|
try { cv.IsMuted = !cv.IsMuted; tab.IsMuted = cv.IsMuted; } catch { }
|
|
}
|
|
e.Handled = true;
|
|
}
|
|
|
|
private void OnResponseReceived(object? sender, CoreWebView2WebResourceResponseReceivedEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
var uri = e.Request.Uri ?? "";
|
|
var method = e.Request.Method ?? "GET";
|
|
string ct = "", cl = "";
|
|
int status = 0;
|
|
try { ct = e.Response.Headers.GetHeader("Content-Type") ?? ""; } catch { }
|
|
try { cl = e.Response.Headers.GetHeader("Content-Length") ?? ""; } catch { }
|
|
try { status = e.Response.StatusCode; } catch { }
|
|
|
|
int n = Interlocked.Increment(ref _totalRequests);
|
|
var ctLow = (ct ?? "").ToLowerInvariant();
|
|
// #8 노이즈 content-type(image/font/css/js/beacon)은 requests.log 기록 스킵
|
|
bool loggable = !ctLow.StartsWith("image/") && !ctLow.StartsWith("font/")
|
|
&& !ctLow.Contains("/css") && !ctLow.Contains("javascript")
|
|
&& !ctLow.Contains("beacon") && !ctLow.Contains("ping");
|
|
if (loggable) _manager.AppendAll($"{n}|{status}|{method}|{ct}|{cl}|{uri}");
|
|
|
|
if (status >= 300) return;
|
|
|
|
var l = uri.ToLowerInvariant();
|
|
var c = (ct ?? "").ToLowerInvariant();
|
|
long len = long.TryParse(cl, out var lv) ? lv : -1;
|
|
// 206(Partial Content) Range 응답은 Content-Length 가 청크 크기라 작게 잡힘 →
|
|
// Content-Range 의 전체 크기(/total) 로 보정 (Instagram 등 단일 MP4 Range 스트리밍 감지).
|
|
try
|
|
{
|
|
var cr = e.Response.Headers.GetHeader("Content-Range");
|
|
if (!string.IsNullOrEmpty(cr))
|
|
{
|
|
var slash = cr.LastIndexOf('/');
|
|
if (slash >= 0 && long.TryParse(cr.AsSpan(slash + 1), out var total) && total > len)
|
|
len = total;
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
string referer = (sender as CoreWebView2)?.Source ?? Cv?.Source ?? "";
|
|
try { var r = e.Request.Headers.GetHeader("Referer"); if (!string.IsNullOrEmpty(r)) referer = r; } catch { }
|
|
|
|
var kind = MediaDetector.Classify(uri, ct, len, _config);
|
|
if (kind == MediaKind.Hls)
|
|
{
|
|
_manager.LogPanel($"★ HLS 감지: {uri}");
|
|
_manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Hls, ct));
|
|
}
|
|
else if (kind == MediaKind.Dash)
|
|
{
|
|
_manager.LogPanel($"★ DASH 감지: {uri}");
|
|
_manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Dash, ct));
|
|
}
|
|
else if (kind == MediaKind.Direct)
|
|
{
|
|
// #15(b) chunked video/* (len<0) 는 크기 가드를 건너뛰어 토큰 만료 HTML 오류 페이지가 mp4 로 오탐됨 →
|
|
// magic-byte probe 로 본문이 실제 미디어인지 재검증(정상 chunked MP4 는 ftyp box 로 통과).
|
|
if (c.StartsWith("video/") && len < 0 && Uri.TryCreate(uri, UriKind.Absolute, out var probeUri))
|
|
ProbeMediaAsync(probeUri, referer);
|
|
else
|
|
{
|
|
_manager.LogPanel($"★ 미디어 감지 [{ct}] {FmtLen(len)}: {uri}");
|
|
_manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Direct, ct));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// #15 위장 미디어(image/* 또는 octet-stream, 대용량) → magic byte 검증
|
|
long minBytes = _config.MinSizeMB * 1024L * 1024L;
|
|
bool disguised = (c.StartsWith("image/") || c.Contains("octet-stream")) && (len < 0 || len > minBytes);
|
|
if (disguised && Uri.TryCreate(uri, UriKind.Absolute, out var probeUri))
|
|
ProbeMediaAsync(probeUri, referer);
|
|
else if (MediaDetector.LooksLikeCandidate(l, c))
|
|
_manager.LogPanel($"[후보 {method}] {uri}");
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
private async void ProbeMediaAsync(Uri uri, string referer)
|
|
{
|
|
try
|
|
{
|
|
string key = uri.GetLeftPart(UriPartial.Path);
|
|
lock (_probed)
|
|
{
|
|
if (_probeCount > 40) return; // 세션당 상한
|
|
if (!_probed.Add(key)) return; // 중복 URL 스킵
|
|
_probeCount++;
|
|
}
|
|
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
|
|
req.Headers.Range = new RangeHeaderValue(0, 63);
|
|
req.Headers.UserAgent.ParseAdd(_config.UserAgent);
|
|
if (!string.IsNullOrEmpty(referer)) { try { req.Headers.Referrer = new Uri(referer); } catch { } }
|
|
using var resp = await ProbeClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
|
|
if ((int)resp.StatusCode >= 300) return;
|
|
await using var stream = await resp.Content.ReadAsStreamAsync();
|
|
var head = new byte[64];
|
|
int total = 0, m;
|
|
while (total < 64 && (m = await stream.ReadAsync(head, total, 64 - total)) > 0) total += m;
|
|
var fmt = MediaDetector.ProbeFormat(head.AsSpan(0, total));
|
|
if (fmt != null)
|
|
{
|
|
_manager.LogPanel($"★ 위장 미디어 감지 (→{fmt}): {uri}");
|
|
_manager.OnDetected(new DetectionContext(uri.ToString(), referer, MediaKind.Direct, "video/" + fmt));
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
private static string FmtLen(long len) => len < 0 ? "?" : $"{len / 1048576.0:F1}MB";
|
|
|
|
private async Task<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;
|
|
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) { _tabs.NewTab(_config.HomeUrl); e.Handled = true; }
|
|
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.T) { _tabs.ReopenClosedTab(); e.Handled = true; }
|
|
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.W) { _tabs.CloseTab(_tabs.ActiveTab!); e.Handled = true; }
|
|
else if (mods == ModifierKeys.Control && e.Key == Key.W) { if (_tabs.ActiveTab != null) _tabs.CloseTab(_tabs.ActiveTab); e.Handled = true; }
|
|
else if (mods == ModifierKeys.Control && e.Key == Key.Tab) { _tabs.ActivateNext(); e.Handled = true; }
|
|
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.Tab) { _tabs.ActivatePrevious(); e.Handled = true; }
|
|
else if (mods == ModifierKeys.Control && e.Key >= Key.D1 && e.Key <= Key.D9)
|
|
{
|
|
var idx = e.Key - Key.D1;
|
|
if (idx == 8) _tabs.ActivateLast(); else _tabs.ActivateAt(idx); // Ctrl+9 = 마지막 탭
|
|
e.Handled = true;
|
|
}
|
|
// T1-3 검색 바가 열려 있으면 Esc 는 닫기가 우선
|
|
else if (e.Key == Key.Escape && FindBar.Visibility == Visibility.Visible) { CloseFind(); e.Handled = true; }
|
|
else if (mods == ModifierKeys.Control && e.Key == Key.F) { OpenFind(); e.Handled = true; }
|
|
else if (e.Key == Key.F3)
|
|
{
|
|
if (FindBar.Visibility == Visibility.Visible)
|
|
{
|
|
if (mods == ModifierKeys.Shift) _tabs.ActiveTab?.FindSession?.Previous();
|
|
else _tabs.ActiveTab?.FindSession?.Next();
|
|
}
|
|
else OpenFind();
|
|
e.Handled = true;
|
|
}
|
|
else if (e.Key == Key.F11) { ToggleFullscreen(); e.Handled = true; }
|
|
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.R) { ForceReload(); e.Handled = true; }
|
|
else if (mods == ModifierKeys.Control && e.Key == Key.L) { AddressBox.Focus(); AddressBox.SelectAll(); e.Handled = true; }
|
|
else if (e.Key == Key.F5 || (mods == ModifierKeys.Control && e.Key == Key.R)) { cv?.Reload(); e.Handled = true; }
|
|
else if (e.Key == Key.Escape) { try { cv?.Stop(); } catch { } e.Handled = true; }
|
|
else if (mods == ModifierKeys.Alt && e.Key == Key.Left) { try { if (cv?.CanGoBack == true) cv.GoBack(); } catch { } e.Handled = true; }
|
|
else if (mods == ModifierKeys.Alt && e.Key == Key.Right) { try { if (cv?.CanGoForward == true) cv.GoForward(); } catch { } e.Handled = true; }
|
|
else if (mods == ModifierKeys.Control)
|
|
{
|
|
if (e.Key == Key.OemPlus || e.Key == Key.Add) { try { ZoomBy(+0.1); } catch { } e.Handled = true; }
|
|
else if (e.Key == Key.OemMinus || e.Key == Key.Subtract) { try { ZoomBy(-0.1); } catch { } e.Handled = true; }
|
|
else if (e.Key == Key.D0) { try { ZoomSet(1.0); } catch { } e.Handled = true; }
|
|
}
|
|
}
|
|
|
|
private void ZoomBy(double delta) => ZoomSet(Math.Clamp((ActiveView?.ZoomFactor ?? 1.0) + delta, 0.1, 5.0));
|
|
|
|
private void ZoomSet(double zoom)
|
|
{
|
|
if (ActiveView == null) return;
|
|
ActiveView.ZoomFactor = zoom;
|
|
PersistZoom();
|
|
}
|
|
|
|
// ---- T1-3 페이지 내 검색 바 (활성 탭 세션) ----
|
|
internal void OpenFind()
|
|
{
|
|
FindBar.Visibility = Visibility.Visible;
|
|
FindStatus.Text = "";
|
|
FindBox.Focus();
|
|
FindBox.SelectAll();
|
|
if (FindBox.Text.Length > 0) _ = _tabs.ActiveTab?.FindSession?.StartAsync(FindBox.Text);
|
|
}
|
|
|
|
internal void CloseFind()
|
|
{
|
|
FindBar.Visibility = Visibility.Collapsed;
|
|
_tabs.ActiveTab?.FindSession?.Close();
|
|
FindStatus.Text = "";
|
|
}
|
|
|
|
private void FindBox_TextChanged(object sender, TextChangedEventArgs e)
|
|
{
|
|
if (FindBar.Visibility == Visibility.Visible)
|
|
_ = _tabs.ActiveTab?.FindSession?.StartAsync(FindBox.Text);
|
|
}
|
|
|
|
private void FindBox_PreviewKeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
if (e.Key == Key.Enter)
|
|
{
|
|
if (Keyboard.Modifiers == ModifierKeys.Shift) _tabs.ActiveTab?.FindSession?.Previous();
|
|
else _tabs.ActiveTab?.FindSession?.Next();
|
|
e.Handled = true;
|
|
}
|
|
else if (e.Key == Key.Escape) { CloseFind(); e.Handled = true; }
|
|
}
|
|
|
|
private void FindNext_Click(object sender, RoutedEventArgs e) => _tabs.ActiveTab?.FindSession?.Next();
|
|
private void FindPrev_Click(object sender, RoutedEventArgs e) => _tabs.ActiveTab?.FindSession?.Previous();
|
|
private void FindClose_Click(object sender, RoutedEventArgs e) => CloseFind();
|
|
|
|
private void FindOption_Changed(object sender, RoutedEventArgs e)
|
|
=> _tabs.ActiveTab?.FindSession?.SetOptions(FindCaseBox.IsChecked == true, FindWordBox.IsChecked == true);
|
|
|
|
// T1-7 줌 변경을 사이트별 저장소에 영속
|
|
private void PersistZoom()
|
|
{
|
|
var cv = Cv;
|
|
if (cv != null && ActiveView != null) _zoomStore.Set(cv.Source, ActiveView.ZoomFactor);
|
|
}
|
|
|
|
// T1-9 강제 새로고침: 디스크 캐시 비우고 다시 로드
|
|
private async void ForceReload()
|
|
{
|
|
var cv = Cv;
|
|
if (cv == null) return;
|
|
try { await cv.Profile.ClearBrowsingDataAsync(CoreWebView2BrowsingDataKinds.DiskCache); }
|
|
catch { }
|
|
try { cv.Reload(); } catch { }
|
|
}
|
|
|
|
// T1-11 전체화면(F11): 툴바/탭/상태바 숨기고 최대화. 재누르면 복원.
|
|
internal void ToggleFullscreen()
|
|
{
|
|
_fullscreen = !_fullscreen;
|
|
if (_fullscreen)
|
|
{
|
|
_stateBeforeFullscreen = WindowState;
|
|
WindowState = WindowState.Maximized;
|
|
}
|
|
else
|
|
{
|
|
WindowState = _stateBeforeFullscreen;
|
|
}
|
|
ToolbarRow.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
|
|
TabStripRow.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
|
|
StatusBar.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
|
|
}
|
|
|
|
private static string MakeUniqueDownloadName(string folder, string file)
|
|
{
|
|
var p = Path.Combine(folder, file);
|
|
if (!File.Exists(p)) return p;
|
|
var name = Path.GetFileNameWithoutExtension(file);
|
|
var ext = Path.GetExtension(file);
|
|
int i = 1;
|
|
while (File.Exists(Path.Combine(folder, $"{name} ({i}){ext}"))) i++;
|
|
return Path.Combine(folder, $"{name} ({i}){ext}");
|
|
}
|
|
|
|
// ---- 범용 우측 패널: 접기/펼치기 + 리사이즈 + 상태 저장 ----
|
|
private static double ClampPanelWidth(double w) => Math.Clamp(w, MinExpandedWidth, MaxExpandedWidth);
|
|
|
|
private void ApplyPanelState(bool collapsed)
|
|
{
|
|
_panelCollapsed = collapsed;
|
|
RightCol.Width = new GridLength(collapsed ? CollapsedPanelWidth : _panelExpandedWidth);
|
|
PanelBody.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
|
|
PanelTitle.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
|
|
PanelCountBadge.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
|
|
RightSplitter.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
|
|
// ←(펼치기) / →(접기) chevron. 접힘 시에도 토글 버튼은 헤더에 남아 클릭 가능.
|
|
PanelToggle.Content = collapsed ? "" : "";
|
|
PanelToggle.ToolTip = collapsed ? "패널 펼치기" : "패널 접기";
|
|
}
|
|
|
|
private void PanelToggle_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
// 접기 직전 폭을 기억(다시 펼칠 때 복원)
|
|
if (!_panelCollapsed) _panelExpandedWidth = ClampPanelWidth(RightCol.Width.Value);
|
|
ApplyPanelState(!_panelCollapsed);
|
|
PersistPanel();
|
|
}
|
|
|
|
private void RightSplitter_DragCompleted(object sender, DragCompletedEventArgs e)
|
|
{
|
|
if (_panelCollapsed) return;
|
|
double w = RightCol.Width.Value;
|
|
if (w <= 120) ApplyPanelState(true); // 너무 좁히면 접기 처리
|
|
else _panelExpandedWidth = ClampPanelWidth(w);
|
|
PersistPanel();
|
|
}
|
|
|
|
private void PersistPanel()
|
|
{
|
|
_config.RightPanelCollapsed = _panelCollapsed;
|
|
_config.RightPanelWidth = _panelCollapsed ? _panelExpandedWidth : ClampPanelWidth(RightCol.Width.Value);
|
|
ConfigStore.Save(_config);
|
|
}
|
|
|
|
private void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
|
{
|
|
_config.RightPanelCollapsed = _panelCollapsed;
|
|
if (!_panelCollapsed) _config.RightPanelWidth = ClampPanelWidth(RightCol.Width.Value);
|
|
ConfigStore.Save(_config);
|
|
// T1-10 세션 저장: 탭 URL/활성/핀 보존
|
|
try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { }
|
|
}
|
|
|
|
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
|
|
private void UpdatePrivateButton()
|
|
{
|
|
if (PrivateBtn == null) return;
|
|
try
|
|
{
|
|
PrivateBtn.Foreground = _config.PrivateMode
|
|
? (Brush)FindResource("AccentBrush")
|
|
: (Brush)FindResource("TextSecondary");
|
|
PrivateBtn.ToolTip = _config.PrivateMode
|
|
? "개인정보 보호 모드 켜짐 — 클릭 시 일반 모드로 전환(재시작)"
|
|
: "개인정보 보호 모드(InPrivate)";
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
// 토글 → 확인 → 플래그 저장 → 재시작(테마 전환과 동일 패턴)
|
|
private void TogglePrivate_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
bool next = !_config.PrivateMode;
|
|
var msg = next
|
|
? "개인정보 보호 모드(InPrivate)로 전환합니다.\n이 모드에서는 쿠키·세션·localStorage가 저장되지 않습니다.\n앱이 재시작되며 현재 페이지는 닫힙니다."
|
|
: "일반 모드로 전환합니다.\n앱이 재시작되며 현재 페이지는 닫힙니다.";
|
|
if (MessageBox.Show(msg + "\n\n계속할까요?", "브라우저 모드", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK)
|
|
return;
|
|
_config.PrivateMode = next;
|
|
ConfigStore.Save(_config);
|
|
RestartApp();
|
|
}
|
|
|
|
/// <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());
|
|
Log($"즐겨찾기 추가: {title}");
|
|
}
|
|
|
|
private void RemoveBookmark_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement fe && fe.DataContext is Bookmark b)
|
|
{
|
|
_bookmarks.Remove(b);
|
|
BrowserStore.SaveBookmarks(_bookmarks.ToList());
|
|
}
|
|
}
|
|
|
|
private void OpenBookmark_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement fe && fe.DataContext is Bookmark b)
|
|
{
|
|
BookmarksPopup.IsOpen = false;
|
|
NavigateTo(b.Url);
|
|
}
|
|
}
|
|
|
|
// ---- 히스토리 ----
|
|
private void ToggleHistory(object sender, RoutedEventArgs e)
|
|
{
|
|
BookmarksPopup.IsOpen = false;
|
|
LinkPopup.IsOpen = false;
|
|
HistoryPopup.IsOpen = !HistoryPopup.IsOpen;
|
|
}
|
|
|
|
private void OpenHistory_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement fe && fe.DataContext is HistoryEntry h)
|
|
{
|
|
HistoryPopup.IsOpen = false;
|
|
NavigateTo(h.Url);
|
|
}
|
|
}
|
|
|
|
private void ClearHistory_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
_history.Clear();
|
|
BrowserStore.SaveHistory(new List<HistoryEntry>());
|
|
Log("방문 기록을 삭제했습니다");
|
|
}
|
|
|
|
private void RecordHistory(string title, string url)
|
|
{
|
|
if (string.IsNullOrEmpty(url)) return;
|
|
if (_history.Count > 0 && _history[0].Url == url) return;
|
|
_history.Insert(0, new HistoryEntry(Trunc(title, 80), url, DateTime.Now));
|
|
while (_history.Count > 500) _history.RemoveAt(_history.Count - 1);
|
|
BrowserStore.SaveHistory(_history.ToList());
|
|
}
|
|
|
|
// ---- 카드 액션 ----
|
|
private void CardDownload_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
|
|
_ = _manager.StartDownload(it);
|
|
}
|
|
|
|
// 소셜 카드: yt-dlp -J 로 사용 가능한 화질 조회 → 드롭다운 교체
|
|
private void CardFetchFormats_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
|
|
_ = _manager.FetchFormatsAsync(it);
|
|
}
|
|
|
|
private void CardRetry_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
|
|
_ = _manager.RetryDownload(it);
|
|
}
|
|
|
|
// #1 다운로드 취소
|
|
private void CardCancel_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
|
|
{
|
|
_manager.Cancel(it);
|
|
Log($"취소 요청: {it.Title}");
|
|
}
|
|
}
|
|
|
|
private void CardFolder_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
|
|
OpenInExplorer(it.OutputPath);
|
|
}
|
|
|
|
private void OpenFolder_Click(object sender, RoutedEventArgs e) => OpenInExplorer(null);
|
|
|
|
// #3 explorer 인자 인젝션 방지: 경로 정규화 + 존재 검증 + ArgumentList/UseShellExecute
|
|
private void OpenInExplorer(string? selectFile)
|
|
{
|
|
try
|
|
{
|
|
var folder = Path.GetFullPath(_config.OutputFolder);
|
|
if (!Directory.Exists(folder)) return;
|
|
if (!string.IsNullOrEmpty(selectFile) && File.Exists(selectFile))
|
|
{
|
|
var psi = new System.Diagnostics.ProcessStartInfo("explorer.exe") { UseShellExecute = false };
|
|
psi.ArgumentList.Add($"/select,{selectFile}");
|
|
System.Diagnostics.Process.Start(psi);
|
|
}
|
|
else
|
|
{
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = folder,
|
|
UseShellExecute = true,
|
|
});
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
private void Settings_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
var w = new SettingsWindow(_config, ClearBrowsingDataAsync) { Owner = this };
|
|
if (w.ShowDialog() == true)
|
|
{
|
|
_config = w.Config;
|
|
ConfigStore.Save(_config);
|
|
_resolver = new SearchEngineResolver(_config.SearchEngine); // T1-4 즉시 반영
|
|
_manager.UpdateMaxConcurrent(_config.MaxConcurrentDownloads); // #19 런타임 반영
|
|
if (w.ThemeChanged) RestartApp();
|
|
}
|
|
}
|
|
|
|
// 테마 변경 등은 리소스가 StaticResource 로 물려있어 런타임 전환이 어려움 → 재시작으로 적용.
|
|
private static void RestartApp()
|
|
{
|
|
var exe = Environment.ProcessPath;
|
|
if (!string.IsNullOrEmpty(exe))
|
|
System.Diagnostics.Process.Start(exe);
|
|
Application.Current.Shutdown();
|
|
}
|
|
|
|
private static string Trunc(string s, int n) => string.IsNullOrEmpty(s) ? "" : (s.Length <= n ? s : s[..n] + "…");
|
|
}
|