using System.IO; using System.Windows; using System.Windows.Media; using System.Windows.Threading; using VideoDownloader.App.Core; using VideoDownloader.App.Services; namespace VideoDownloader.App; public partial class App : Application { private static string CrashLogPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "crash.log"); // #14 글로벌 예외 핸들러: 잡히지 않은 예외를 crash.log 에 기록 + UC-N18 크래시 리포트 다이얼로그 표시. protected override void OnStartup(StartupEventArgs e) { DispatcherUnhandledException += (_, de) => { LogCrash(de.Exception); CrashReportDialog.Show(de.Exception); de.Handled = true; }; AppDomain.CurrentDomain.UnhandledException += (_, ue) => LogCrash(ue.ExceptionObject as Exception); TaskScheduler.UnobservedTaskException += (_, ue) => { LogCrash(ue.Exception); ue.SetObserved(); }; base.OnStartup(e); var config = ConfigStore.Load(); // UC-A04: OS 고대비 모드가 켜져 있고 별도 선택이 없으면 고대비 테마로 시작 if (SystemParameters.HighContrast && config.Theme is "dark" or "light" or "system") config.Theme = "highcontrast"; ApplyTheme(config.Theme, config.CustomThemePath); ApplyAccent(config.AccentColor); ApplyFontSettings(); ApplyReduceMotion(config.ReduceMotion); new MainWindow().Show(); } /// 테마 색(dark/light/system/highcontrast/사용자파일) + Shared(스타일) 리소스사전을 App.Resources 에 주입. /// 주의: 반드시 색(brush) 사전을 스타일 사전보다 먼저 로드해야 Shared 의 템플릿이 /// {DynamicResource GlassBg} 등을 해석할 수 있다. 인덱스 0 자리를 교체해 라이브 전환이 동작한다(UC-T04). public static void ApplyTheme(string theme, string? customThemePath = null) { var (effective, customFile) = Services.ThemeResolver.Resolve(theme, customThemePath); var dicts = Current.Resources.MergedDictionaries; dicts.Clear(); // 어셈블리 한정 pack URI — 테스트 호스트(타 어셈블리 Application)에서도 App 의 테마를 찾는다. var themeSrc = effective switch { "light" => "pack://application:,,,/VideoDownloader;component/Themes/Light.xaml", "highcontrast" => "pack://application:,,,/VideoDownloader;component/Themes/HighContrast.xaml", "custom" when customFile != null => customFile, // UC-T12 사용자 테마 파일(절대경로) _ => "pack://application:,,,/VideoDownloader;component/Themes/Dark.xaml", }; dicts.Add(new ResourceDictionary { Source = new Uri(themeSrc, UriKind.Absolute) }); dicts.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/VideoDownloader;component/Themes/Shared.xaml", UriKind.Absolute) }); Current.Resources["EffectiveTheme"] = effective; } /// 현재 적용된 테마(dark/light/highcontrast/custom) — DWM 타이틀바·판정용. public static string CurrentTheme => Current.Resources["EffectiveTheme"] as string ?? "dark"; /// 현재 적용된 테마가 밝은 계열인지(DWM 타이틀바 색 연동용). public static bool IsLightTheme => CurrentTheme is "light" or "custom"; /// UC-T08 사용자 강조색 적용 — 현재 테마 사전의 AccentBrush/Gradient/Fade/AccentText 를 /// 파생색으로 교체한다. DynamicResource 로 물린 모든 컨트롤(버튼·진행바)에 즉시 반영된다. public static void ApplyAccent(string? hex) { if (string.IsNullOrWhiteSpace(hex) || !TryParseColor(hex, out var color)) return; var dict = Current.Resources.MergedDictionaries.FirstOrDefault(d => d.Contains("AccentBrush")); if (dict == null) return; var base_ = Color.FromArgb(0xFF, color.R, color.G, color.B); dict["AccentBrush"] = new SolidColorBrush(base_); dict["AccentTextBrush"] = new SolidColorBrush(Lighten(base_, 0.12)); var grad = new LinearGradientBrush { StartPoint = new Point(0, 0), EndPoint = new Point(1, 1), GradientStops = { new GradientStop(base_, 0), new GradientStop(Lighten(base_, 0.18), 1), }, }; dict["AccentGradient"] = grad; var fade = new LinearGradientBrush { StartPoint = new Point(0, 0), EndPoint = new Point(0, 1), GradientStops = { new GradientStop(Color.FromArgb(0x66, base_.R, base_.G, base_.B), 0), new GradientStop(Color.FromArgb(0x00, base_.R, base_.G, base_.B), 1), }, }; dict["AccentFadeBrush"] = fade; } /// UC-F16 폰트 설정 즉시 적용 — AppConfig 의 폰트/크기/확대 배율으로 UiFont·FontSize.Base 토큰 교체. public static void ApplyFontSettings() { var c = ConfigStore.Load(); var stack = string.IsNullOrWhiteSpace(c.UiFontFamily) ? "Pretendard, Segoe UI Variable Text, Segoe UI" : c.UiFontFamily.Trim(); var fallback = string.IsNullOrWhiteSpace(c.UiFontKoreanFallback) ? "Malgun Gothic" : c.UiFontKoreanFallback.Trim(); Current.Resources["UiFont"] = new FontFamily($"{stack}, {fallback}"); var scale = Math.Clamp(c.UiFontScale <= 0 ? 1.0 : c.UiFontScale, 0.5, 2.0); // UC-A13 최대 200% var size = Math.Clamp(c.UiFontSize * scale, 9, 40); Current.Resources["FontSize.Base"] = size; Current.Resources["FontSize.Small"] = size - 1.5; Current.Resources["FontSize.Large"] = size + 3; Current.Resources["FontSize.XLarge"] = size + 9; } /// UC-A16/L21 애니메이션 축소 — Duration 토큰을 0으로 만들어 전환을 즉시 완결시킨다. public static void ApplyReduceMotion(bool reduce) { Current.Resources["ReduceMotion"] = reduce; Current.Resources["Duration.Fast"] = new System.Windows.Duration(TimeSpan.Zero); Current.Resources["Duration.Normal"] = new System.Windows.Duration(reduce ? TimeSpan.Zero : TimeSpan.FromSeconds(0.3)); if (!reduce) Current.Resources["Duration.Fast"] = new System.Windows.Duration(TimeSpan.FromSeconds(0.15)); } private static bool TryParseColor(string hex, out Color color) { hex = hex.Trim().TrimStart('#'); if (hex.Length == 6) { try { color = Color.FromRgb( Convert.ToByte(hex[..2], 16), Convert.ToByte(hex[2..4], 16), Convert.ToByte(hex[4..6], 16)); return true; } catch { } } color = Colors.Transparent; return false; } private static Color Lighten(Color c, double amount) => Color.FromRgb( (byte)Math.Min(255, c.R + (255 - c.R) * amount), (byte)Math.Min(255, c.G + (255 - c.G) * amount), (byte)Math.Min(255, c.B + (255 - c.B) * amount)); private static void LogCrash(Exception? ex) { if (ex == null) return; try { var dir = Path.GetDirectoryName(CrashLogPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); File.AppendAllText(CrashLogPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {ex}{Environment.NewLine}{new string('-', 60)}{Environment.NewLine}"); } catch { } } }