feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization

This commit is contained in:
Yun Chan 2026-09-14 00:39:44 +09:00
parent a88aafa1e5
commit 0e568f1f0a
975 changed files with 130593 additions and 16783 deletions

11
src/Paca.App/App.xaml Normal file
View file

@ -0,0 +1,11 @@
<Application x:Class="Paca.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Paca.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries/>
<local:InverseCountToVisibilityConverter x:Key="InverseCountToVisibility"/>
</ResourceDictionary>
</Application.Resources>
</Application>

203
src/Paca.App/App.xaml.cs Normal file
View file

@ -0,0 +1,203 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Threading;
using Paca.App.Core;
using Paca.App.Services;
namespace Paca.App;
public partial class App : Application
{
private static string CrashLogPath => Path.Combine(Paca.Core.Platform.CorePaths.DataDir, "crash.log");
// #14 글로벌 예외 핸들러: 잡히지 않은 예외를 crash.log 에 기록 + UC-N18 크래시 리포트 다이얼로그 표시.
protected override void OnStartup(StartupEventArgs e)
{
var startupLog = Path.Combine(Paca.Core.Platform.CorePaths.DataDir, "startup.log");
try { Directory.CreateDirectory(Paca.Core.Platform.CorePaths.DataDir); File.WriteAllText(startupLog, $"[Startup 1] OnStartup entry {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}\n"); } catch { }
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 startupMode = DesktopStartupModeParser.Parse(e.Args);
if (startupMode == DesktopStartupMode.GatewayDaemonSilent)
{
ShutdownMode = ShutdownMode.OnExplicitShutdown;
try
{
var daemon = new Paca.Server.Gateway.PacaGatewayDaemon(new Paca.Server.Gateway.GatewayStartupConfig());
_ = daemon.StartAsync();
File.AppendAllText(startupLog, "[Startup] Started in GatewayDaemonSilent mode.\n");
}
catch (Exception ex)
{
LogCrash(ex);
Shutdown();
}
return;
}
try { File.AppendAllText(startupLog, "[Startup 2] ConfigStore.Load()\n"); } catch { }
var config = ConfigStore.Load();
// UC-A04: OS 고대비 모드가 켜져 있고 별도 선택이 없으면 고대비 테마로 시작
if (SystemParameters.HighContrast && config.Theme is "dark" or "light" or "system")
config.Theme = "highcontrast";
try { File.AppendAllText(startupLog, "[Startup 3] ApplyTheme\n"); } catch { }
ApplyTheme(config.Theme, config.CustomThemePath);
try { File.AppendAllText(startupLog, "[Startup 4] ApplyAccent\n"); } catch { }
ApplyAccent(config.AccentColor);
try { File.AppendAllText(startupLog, "[Startup 5] ApplyFontSettings\n"); } catch { }
ApplyFontSettings();
try { File.AppendAllText(startupLog, "[Startup 6] ApplyReduceMotion\n"); } catch { }
ApplyReduceMotion(config.ReduceMotion);
try { File.AppendAllText(startupLog, "[Startup 7] new MainWindow()\n"); } catch { }
var mw = new MainWindow();
try { File.AppendAllText(startupLog, "[Startup 8] mw.Show()\n"); } catch { }
mw.Show();
try { File.AppendAllText(startupLog, "[Startup 9] mw.Show() done, Activate()\n"); } catch { }
mw.Activate();
if (DesktopStartupModeParser.TryExtractDeepLink(e.Args, out var deepLinkAction) &&
deepLinkAction is Paca.Mobile.Core.PacaDownloadDeepLinkAction downloadAction)
{
try { File.AppendAllText(startupLog, $"[Startup 10] DeepLink download: {downloadAction.TargetUrl}\n"); } catch { }
mw.HandleDeepLinkDownload(downloadAction.TargetUrl);
}
}
/// <summary>테마 색(dark/light/system/highcontrast/사용자파일) + Shared(스타일) 리소스사전을 App.Resources 에 주입.
/// 주의: 반드시 색(brush) 사전을 스타일 사전보다 먼저 로드해야 Shared 의 템플릿이
/// {DynamicResource GlassBg} 등을 해석할 수 있다. 인덱스 0 자리를 교체해 라이브 전환이 동작한다(UC-T04).</summary>
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:,,,/Paca;component/Themes/Light.xaml",
"highcontrast" => "pack://application:,,,/Paca;component/Themes/HighContrast.xaml",
"custom" when customFile != null => customFile, // UC-T12 사용자 테마 파일(절대경로)
_ => "pack://application:,,,/Paca;component/Themes/Dark.xaml",
};
dicts.Add(new ResourceDictionary { Source = new Uri(themeSrc, UriKind.Absolute) });
dicts.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/Paca;component/Themes/Shared.xaml", UriKind.Absolute) });
Current.Resources["EffectiveTheme"] = effective;
if (effective == "light")
{
Current.Resources["ScrollBarThumbBrush"] = new SolidColorBrush(Color.FromArgb(0x1E, 0, 0, 0));
Current.Resources["ScrollBarThumbHoverBrush"] = new SolidColorBrush(Color.FromArgb(0x50, 0, 0, 0));
}
else if (effective == "dark")
{
Current.Resources["ScrollBarThumbBrush"] = new SolidColorBrush(Color.FromArgb(0x26, 0xFF, 0xFF, 0xFF));
Current.Resources["ScrollBarThumbHoverBrush"] = new SolidColorBrush(Color.FromArgb(0x66, 0xFF, 0xFF, 0xFF));
}
}
/// <summary>현재 적용된 테마(dark/light/highcontrast/custom) — DWM 타이틀바·판정용.</summary>
public static string CurrentTheme => Current.Resources["EffectiveTheme"] as string ?? "dark";
/// <summary>현재 적용된 테마가 밝은 계열인지(DWM 타이틀바 색 연동용).</summary>
public static bool IsLightTheme => CurrentTheme is "light" or "custom";
/// <summary>UC-T08 사용자 강조색 적용 — 현재 테마 사전의 AccentBrush/Gradient/Fade/AccentText 를
/// 파생색으로 교체한다. DynamicResource 로 물린 모든 컨트롤(버튼·진행바)에 즉시 반영된다.</summary>
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;
}
/// <summary>UC-F16 폰트 설정 즉시 적용 — AppConfig 의 폰트/크기/확대 배율으로 UiFont·FontSize.Base 토큰 교체.</summary>
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;
}
/// <summary>UC-A16/L21 애니메이션 축소 — Duration 토큰을 0으로 만들어 전환을 즉시 완결시킨다.</summary>
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 startupLog = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "startup.log");
File.AppendAllText(startupLog, $"[CRASH] {ex.GetType().FullName}: {ex.Message}\n{ex.StackTrace}\n");
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 { }
}
}

7
src/Paca.App/AppPaths.cs Normal file
View file

@ -0,0 +1,7 @@
namespace Paca.App;
/// <summary>앱 데이터 디렉터리(CorePaths 위임) — VD_DATA_DIR 오버라이드 지원.</summary>
public static class AppPaths
{
public static string DataDir => Paca.Core.Platform.CorePaths.DataDir;
}

View file

@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

3
src/Paca.App/Assets/dash.all.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
src/Paca.App/Assets/hls.min.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
<Window x:Class="Paca.App.BookmarkManagerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="즐겨찾기 관리" Height="520" Width="640"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
UseLayoutRounding="True"
KeyboardNavigation.TabNavigation="Cycle"
AutomationProperties.Name="즐겨찾기 관리 창">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- UC-M18 즐겨찾기 검색 (일체형 단일 테두리) -->
<Border Grid.Row="0" Style="{StaticResource SearchInputContainer}" Height="34" Margin="0,0,0,12" Padding="8,0">
<DockPanel VerticalAlignment="Center">
<TextBlock Text="&#xE721;" Style="{StaticResource FluentIcon}" FontSize="13" Foreground="{DynamicResource TextMuted}" Margin="2,0,8,0" VerticalAlignment="Center"/>
<TextBox x:Name="SearchBox" Style="{StaticResource AddressTextBox}" TextChanged="SearchBox_TextChanged"
Tag="이름 또는 URL 검색..." ToolTip="이름 또는 URL로 즐겨찾기 검색"/>
</DockPanel>
</Border>
<ListView x:Name="List" Grid.Row="1" Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource TextPrimary}"
SelectionMode="Extended" KeyDown="List_KeyDown"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0,0,0,6"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Border x:Name="bd" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource GlassBorder}" BorderThickness="1" CornerRadius="8" Padding="12,10" SnapsToDevicePixels="True">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
<Setter TargetName="bd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate>
<DockPanel>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource CardActionButton}" Content="편집" Click="Edit_Click" Margin="4,0,0,0"
ToolTip="이름 및 URL 편집" AutomationProperties.Name="{Binding Title, StringFormat='{}{0} 즐겨찾기 편집'}"/>
<Button Style="{StaticResource CardActionButton}" Content="삭제" Click="Delete_Click" Margin="4,0,0,0"
ToolTip="즐겨찾기 삭제" AutomationProperties.Name="{Binding Title, StringFormat='{}{0} 즐겨찾기 삭제'}"/>
</StackPanel>
<StackPanel VerticalAlignment="Center" Margin="0,0,12,0">
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}"/>
<TextBlock Text="{Binding Url}" Foreground="{DynamicResource TextMuted}" FontSize="11" TextTrimming="CharacterEllipsis" Margin="0,3,0,0"/>
</StackPanel>
</DockPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<!-- UC-M19 Netscape 북마크 HTML 내보내기/가져오기 -->
<Button Style="{StaticResource GhostButton}" Content="HTML 내보내기" Click="Export_Click" Margin="0,0,8,0" AutomationProperties.Name="HTML로 북마크 내보내기"/>
<Button Style="{StaticResource GhostButton}" Content="HTML 가져오기" Click="Import_Click" Margin="0,0,8,0" AutomationProperties.Name="HTML에서 북마크 가져오기"/>
<Button Style="{StaticResource PrimaryButton}" Content="닫기" Width="90" Click="Close_Click" IsCancel="True" AutomationProperties.Name="즐겨찾기 창 닫기"/>
</StackPanel>
</Grid>
</Window>

View file

@ -0,0 +1,156 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Paca.Browser;
namespace Paca.App;
/// <summary>UC-K16 즐겨찾기 관리 창 — 검색·편집(M17)·삭제·HTML 내보내기/가져오기(M19).</summary>
public partial class BookmarkManagerWindow : Window
{
private readonly ObservableCollection<Bookmark> _source;
private readonly Action _persist;
public BookmarkManagerWindow(ObservableCollection<Bookmark> bookmarks, Action persist)
{
InitializeComponent();
_source = bookmarks;
_persist = persist;
List.ItemsSource = _source;
}
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var q = SearchBox.Text?.Trim() ?? "";
var view = System.Windows.Data.CollectionViewSource.GetDefaultView(_source);
view.Filter = q.Length == 0
? null
: o => o is Bookmark b &&
(b.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
b.Url.Contains(q, StringComparison.OrdinalIgnoreCase));
}
private void Edit_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
var title = InputBox("즐겨찾기 편집", "이름", b.Title);
if (title == null) return;
var url = InputBox("즐겨찾기 편집", "URL", b.Url);
if (url == null) return;
var idx = _source.IndexOf(b);
if (idx >= 0) _source[idx] = new Bookmark(title.Trim(), url.Trim());
_persist();
}
private void Delete_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
_source.Remove(b);
_persist();
}
private void List_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Delete && List.SelectedItem is Bookmark b)
{
_source.Remove(b);
_persist();
e.Handled = true;
}
}
/// <summary>UC-M19 브라우저 호환 Netscape 북마크 HTML 로 내보내기.</summary>
private void Export_Click(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.SaveFileDialog { Filter = "북마크 HTML|*.html", FileName = "bookmarks.html" };
if (dlg.ShowDialog() != true) return;
var sb = new System.Text.StringBuilder();
sb.AppendLine("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
sb.AppendLine("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">");
sb.AppendLine("<TITLE>Bookmarks</TITLE><H1>Bookmarks</H1><DL><p>");
foreach (var b in _source)
sb.AppendLine($" <DT><A HREF=\"{System.Net.WebUtility.HtmlEncode(b.Url)}\">{System.Net.WebUtility.HtmlEncode(b.Title)}</A>");
sb.AppendLine("</DL><p>");
File.WriteAllText(dlg.FileName, sb.ToString());
}
/// <summary>Netscape HTML 에서 HREF/제목 쌍을 추출해 추가 가져오기.</summary>
private void Import_Click(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog { Filter = "북마크 HTML|*.html;*.htm" };
if (dlg.ShowDialog() != true) return;
try
{
var html = File.ReadAllText(dlg.FileName);
var added = 0;
foreach (var (url, title) in ParseNetscapeBookmarks(html))
{
if (_source.Any(b => b.Url.Equals(url, StringComparison.OrdinalIgnoreCase))) continue;
_source.Add(new Bookmark(title, url));
added++;
}
_persist();
MessageBox.Show(this, $"{added}개 항목을 가져왔습니다", "가져오기");
}
catch (Exception ex)
{
MessageBox.Show(this, $"가져오기 실패: {ex.Message}", "가져오기", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
internal static IEnumerable<(string Url, string Title)> ParseNetscapeBookmarks(string html)
{
var matches = System.Text.RegularExpressions.Regex.Matches(html,
@"<A\s[^>]*HREF=""(?<url>[^""]+)""[^>]*>(?<title>.*?)</A>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in matches)
{
var url = System.Net.WebUtility.HtmlDecode(m.Groups["url"].Value);
var title = System.Text.RegularExpressions.Regex.Replace(m.Groups["title"].Value, "<[^>]+>", "");
yield return (url, System.Net.WebUtility.HtmlDecode(title).Trim());
}
}
private string? InputBox(string title, string label, string initial)
{
var box = new TextBox { Text = initial, Style = (Style)FindResource("DarkTextBox"), Margin = new Thickness(0, 6, 0, 12) };
var win = new Window
{
Title = title,
Width = 440,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Owner = this,
Background = (System.Windows.Media.Brush)FindResource("WindowBg"),
};
var cancel = new Button { Content = "취소", Style = (Style)FindResource("GhostButton"), Width = 80, Margin = new Thickness(0, 0, 8, 0) };
var ok = new Button { Content = "확인", Style = (Style)FindResource("PrimaryButton"), Width = 80, IsDefault = true };
cancel.Click += (_, _) => win.DialogResult = false;
ok.Click += (_, _) => win.DialogResult = true;
win.Content = new StackPanel { Margin = new Thickness(16) }.Also(p =>
{
p.Children.Add(new TextBlock { Text = label, Foreground = (System.Windows.Media.Brush)FindResource("TextSecondary") });
p.Children.Add(box);
p.Children.Add(new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancel, ok },
});
});
return win.ShowDialog() == true ? box.Text : null;
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
}
/// <summary>람다식 객체 초기화 헬퍼(BookmarkManagerWindow 입력 다이얼로그용).</summary>
internal static class UiExtensions
{
public static T Also<T>(this T self, Action<T> init)
{
init(self);
return self;
}
}

View file

@ -0,0 +1,149 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Text.Json;
namespace Paca.App.Browser.Features;
/// <summary>팔레트 명령(T2-1, Arc Command Bar / Vivaldi Quick Commands 패턴).</summary>
public sealed record PaletteCommand(string Title, string Keywords, Action Execute, string? Category = null);
/// <summary>명령 등록·검색·실행. 동적 소스(탭/기록/북마크)는 검색 시점에 평가.</summary>
public sealed class CommandPalette
{
private readonly List<PaletteCommand> _static = new();
private readonly List<Func<IEnumerable<PaletteCommand>>> _dynamic = new();
private readonly List<PaletteCommand> _separators = new(); // 빈질의 표시용 구분자(Title="\u2015")
public IReadOnlyList<PaletteCommand> Register(string title, string keywords, Action execute, string? category = null)
{
var cmd = new PaletteCommand(title, keywords, execute, category);
_static.Add(cmd);
return new[] { cmd };
}
public void RegisterDynamic(Func<List<PaletteCommand>> source) => _dynamic.Add(source);
public void RegisterSeparator() => _separators.Add(new PaletteCommand("\u2015", "", () => { }));
/// <summary>검색: 정적+동적 통합, 시작-일치 우선, 대소문자 무시. 빈 질의는 정적 전체(등록순).</summary>
public List<PaletteCommand> Search(string? query)
{
query = query?.Trim();
if (string.IsNullOrEmpty(query))
return _static.ToList();
var dynamicCmds = new List<PaletteCommand>();
foreach (var src in _dynamic)
{
try
{
var list = src();
if (list != null) dynamicCmds.AddRange(list);
}
catch { }
}
var pool = _static.Concat(dynamicCmds).ToList();
var low = query.ToLowerInvariant();
return pool
.Select(c => new { Cmd = c, Hay = (c.Title + " " + c.Keywords).ToLowerInvariant() })
.Where(x => x.Hay.Contains(low))
.OrderByDescending(x => x.Hay.StartsWith(low) ? 1 : 0)
.ThenBy(_ => 0) // 안정 정렬 유지(등록순)
.Select(x => x.Cmd)
.Take(12)
.ToList();
}
public void Execute(PaletteCommand command) => command.Execute();
}
/// <summary>페이지 액션 필터(T3-6, Vivaldi Page Actions — Boost 통합).</summary>
public enum PageFilter { None, Grayscale, Sepia }
/// <summary>사이트별 부스트 설정(T2-7, Zen/Arc Boosts 패턴) + 페이지 액션 필터(T3-6).</summary>
public sealed record BoostSettings(bool ForceDark, string CustomCss, PageFilter Filter = PageFilter.None);
/// <summary>도메인별 부스트 영속 저장.</summary>
public sealed class BoostStore
{
private readonly string _path;
private Dictionary<string, BoostSettings> _map = new(StringComparer.OrdinalIgnoreCase);
public BoostStore(string path)
{
_path = path;
try
{
if (File.Exists(path))
{
var loaded = JsonSerializer.Deserialize<Dictionary<string, BoostSettings>>(File.ReadAllText(path));
if (loaded != null) _map = loaded;
}
}
catch { _map = new(); }
}
public BoostSettings Get(string? url)
{
var host = Normalize(url);
return host != null && _map.TryGetValue(host, out var v) ? v : new BoostSettings(false, "");
}
public void Set(string? url, bool forceDark, string css, PageFilter filter = PageFilter.None)
{
var host = Normalize(url);
if (host == null) return;
_map[host] = new BoostSettings(forceDark, css, filter);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var tmp = _path + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(_map));
File.Move(tmp, _path, overwrite: true);
}
catch { }
}
private static string? Normalize(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
var raw = url.Trim();
Uri? uri = null;
if (Uri.TryCreate(raw, UriKind.Absolute, out var parsed) && !string.IsNullOrEmpty(parsed.Host)) uri = parsed;
else Uri.TryCreate("https://" + raw, UriKind.Absolute, out uri); // 스킴 없는 호스트 허용
if (uri == null) return null;
var host = uri.Host;
return host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host;
}
}
/// <summary>부스트 → 문서 생성 시점 주입 JS 생성(강제 다크: 반전 필터 + 미디어 보호).</summary>
public static class BoostCssBuilder
{
public static string BuildScript(string? url, BoostSettings settings)
{
if (!settings.ForceDark && settings.Filter == PageFilter.None && string.IsNullOrWhiteSpace(settings.CustomCss)) return "";
var css = "";
if (settings.ForceDark)
{
css += """
html { filter: invert(1) hue-rotate(180deg); background: #fff; }
img, video, picture, canvas, svg, iframe, embed, object { filter: invert(1) hue-rotate(180deg); }
""";
}
else if (settings.Filter == PageFilter.Grayscale) // T3-6 페이지 액션
{
css += "html { filter: grayscale(1); }";
}
else if (settings.Filter == PageFilter.Sepia)
{
css += "html { filter: sepia(0.7); }";
}
if (!string.IsNullOrWhiteSpace(settings.CustomCss)) css += settings.CustomCss + "\n";
var js = $$"""
(function(){ try { var s = document.createElement('style'); s.id = 'vd-boost'; s.textContent = {{JsonSerializer.Serialize(css)}}; (document.head || document.documentElement).appendChild(s); } catch(e){} })();
""";
return js;
}
}

View file

@ -0,0 +1,63 @@
using System.Windows.Data;
using Microsoft.Web.WebView2.Core;
namespace Paca.App.Browser.Features;
/// <summary>CoreWebView2DownloadOperation → IDownloadOperation 어댑터.</summary>
public sealed class DownloadOperationAdapter : IDownloadOperation
{
private readonly CoreWebView2DownloadOperation _op;
public DownloadOperationAdapter(CoreWebView2DownloadOperation op) => _op = op;
public string Uri => _op.Uri ?? "";
public string FileName => Path.GetFileName(_op.ResultFilePath ?? "");
public long BytesReceived => (long)_op.BytesReceived;
public long TotalBytesToReceive => (long)(_op.TotalBytesToReceive ?? 0);
public string State => _op.State.ToString();
public void Pause() => _op.Pause();
public void Resume() => _op.Resume();
public void Cancel() => _op.Cancel();
public event Action? Changed
{
add { _op.BytesReceivedChanged += (_, _) => value?.Invoke(); _op.StateChanged += (_, _) => value?.Invoke(); }
remove { }
}
}
/// <summary>탭 검색 결과의 IsClosed → "닫힘" 라벨 변환.</summary>
public sealed class BoolToClosedConverter : IValueConverter
{
public static readonly BoolToClosedConverter Instance = new();
public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
=> value is true ? "닫힘 · 클릭해서 복원" : "";
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
=> throw new NotSupportedException();
}
/// <summary>그룹 색상 HEX → Color / 표시 여부 변환 (탭 그룹 바).</summary>
public sealed class StringToBrushConverter : System.Windows.Data.IValueConverter
{
public static readonly StringToBrushConverter Conv = new();
public static readonly StringToBrushConverter VisibleIf = new();
public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
{
var hex = value as string;
if (ReferenceEquals(this, VisibleIf))
return string.IsNullOrWhiteSpace(hex) ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible;
try
{
var c = System.Windows.Media.ColorConverter.ConvertFromString(hex ?? "#5b8def");
return c is System.Windows.Media.Color col ? col : System.Windows.Media.Colors.Gray;
}
catch { return System.Windows.Media.Colors.Gray; }
}
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
=> throw new NotSupportedException();
}

View file

@ -0,0 +1,120 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Paca.App.Browser.Features;
/// <summary>재생목록 트랙. Kind: Direct/Hls/Dash/Social.</summary>
public sealed record MediaTrack(
string Url,
string Title,
string Kind,
DateTime AddedAt,
bool Played = false,
string? Referer = null,
string? LocalFile = null);
/// <summary>
/// T2-10 미디어 재생목록(Brave Playlist 패턴): 브라우저에서 감지된 미디어를
/// 쌓아두고 앱 내에서 연속 재생. JSON 영속(playlist.json).
/// </summary>
public sealed class MediaPlaylist
{
public ObservableCollection<MediaTrack> Tracks { get; } = new();
public int CurrentIndex { get; set; } = -1;
[JsonIgnore]
public MediaTrack? Current => CurrentIndex >= 0 && CurrentIndex < Tracks.Count ? Tracks[CurrentIndex] : null;
public event Action? Changed;
/// <summary>추가(중복 URL 무시). 새 항목이 첫 트랙이면 자동 선택.</summary>
public void Add(MediaTrack track)
{
if (Tracks.Any(t => t.Url == track.Url)) return;
Tracks.Add(track);
if (CurrentIndex < 0) CurrentIndex = Tracks.Count - 1;
Changed?.Invoke();
}
public void AddTrack(MediaTrack track) => Add(track);
public void Remove(MediaTrack track)
{
var idx = Tracks.IndexOf(track);
if (idx < 0) return;
Tracks.RemoveAt(idx);
if (CurrentIndex >= Tracks.Count) CurrentIndex = Tracks.Count - 1;
Changed?.Invoke();
}
public void Clear()
{
Tracks.Clear();
CurrentIndex = -1;
Changed?.Invoke();
}
/// <summary>다음 트랙 인덱스(순환). 반환 후 CurrentIndex 갱신.</summary>
public int Next()
{
if (Tracks.Count == 0) return -1;
CurrentIndex = (CurrentIndex + 1) % Tracks.Count;
return CurrentIndex;
}
/// <summary>이전 트랙 인덱스(순환).</summary>
public int Previous()
{
if (Tracks.Count == 0) return -1;
CurrentIndex = (CurrentIndex - 1 + Tracks.Count) % Tracks.Count;
return CurrentIndex;
}
public void MarkPlayed(MediaTrack track)
{
var idx = Tracks.IndexOf(track);
if (idx < 0) return;
Tracks[idx] = track with { Played = true };
Changed?.Invoke();
}
public void Save(string path)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var dto = new { tracks = Tracks.ToList(), currentIndex = CurrentIndex };
var tmp = path + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(dto));
File.Move(tmp, path, overwrite: true);
}
catch { }
}
public void Load(string path)
{
try
{
if (!File.Exists(path)) return;
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var root = doc.RootElement;
Tracks.Clear();
if (root.TryGetProperty("tracks", out var arr))
foreach (var t in arr.EnumerateArray())
{
var track = t.Deserialize<MediaTrack>();
if (track != null) Tracks.Add(track);
}
CurrentIndex = root.TryGetProperty("currentIndex", out var ci) ? ci.GetInt32() : -1;
if (CurrentIndex >= Tracks.Count) CurrentIndex = Tracks.Count - 1;
}
catch
{
Tracks.Clear();
CurrentIndex = -1;
}
}
}

View file

@ -0,0 +1,252 @@
using System;
using System.Text.Encodings.Web;
namespace Paca.App.Browser.Features;
public static partial class NewTabPageBuilder
{
/// <summary>
/// 사생활 보호(InPrivate) 전용 New Tab Page HTML을 생성한다.
/// 일반 프로필의 방문 기록, 바로가기, 최근 다운로드 일체를 배제하고,
/// 사생활 보호 정책(저장되지 않는 항목 vs 외부 관찰자에게 보이는 항목)과 독립 검색창을 제공한다.
/// </summary>
public static string BuildInPrivateNtp(
bool isDark = true,
string? accentColor = null,
string? fontStack = null)
{
var font = string.IsNullOrWhiteSpace(fontStack) ? "Segoe UI, -apple-system, Malgun Gothic, sans-serif" : fontStack;
var accent = string.IsNullOrWhiteSpace(accentColor) ? "#8B5CF6" : accentColor;
return $$"""
<!DOCTYPE html>
<html lang="ko" class="{{(isDark ? "dark" : "light")}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>새 InPrivate 탭</title>
<style>
:root {
--font-family: {{font}};
--accent-color: {{accent}};
--bg-color: #0f172a;
--card-bg: rgba(30, 41, 59, 0.7);
--card-border: rgba(148, 163, 184, 0.15);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--search-bg: #1e293b;
--search-border: #334155;
--search-focus-border: {{accent}};
--search-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
}
html.light {
--bg-color: #f8fafc;
--card-bg: rgba(255, 255, 255, 0.9);
--card-border: rgba(203, 213, 225, 0.6);
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #64748b;
--search-bg: #ffffff;
--search-border: #cbd5e1;
--search-focus-border: {{accent}};
--search-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-family);
background: var(--bg-color);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
overflow-x: hidden;
}
.container {
width: 100%;
max-width: 680px;
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: rgba(139, 92, 246, 0.15);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 9999px;
color: var(--accent-color);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
}
.hero-icon {
font-size: 16px;
line-height: 1;
}
.hero-title {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.02em;
text-align: center;
margin-top: -8px;
}
.hero-desc {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
max-width: 520px;
line-height: 1.6;
}
.search-bar {
width: 100%;
display: flex;
align-items: center;
background: var(--search-bg);
border: 1px solid var(--search-border);
border-radius: 28px;
padding: 8px 18px;
box-shadow: var(--search-shadow);
transition: all 0.2s ease;
}
.search-bar:focus-within {
border-color: var(--search-focus-border);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.25);
}
.search-icon {
color: var(--text-muted);
margin-right: 12px;
font-size: 16px;
user-select: none;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-family: inherit;
font-size: 15px;
padding: 6px 0;
}
.search-input::placeholder {
color: var(--text-muted);
}
.info-grid {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 4px;
}
@media (max-width: 580px) {
.info-grid { grid-template-columns: 1fr; }
}
.info-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 20px;
backdrop-filter: blur(12px);
}
.card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
}
.card-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-list li {
font-size: 12.5px;
color: var(--text-secondary);
line-height: 1.5;
display: flex;
align-items: flex-start;
gap: 8px;
}
.card-list li::before {
content: "•";
color: var(--accent-color);
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="hero-badge">
<svg class="hero-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<span>InPrivate 브라우징</span>
</div>
<h1 class="hero-title">비공개 브라우징 모드</h1>
<p class="hero-desc">
PACA는 이 창 또는 탭이 닫히면 브라우징 데이터를 저장하지 않습니다.<br>
방문 기록, 쿠키, 사이트 데이터, 양식에 입력된 정보가 저장되지 않습니다.
</p>
<form class="search-bar" onsubmit="handleSearch(event)">
<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input type="text" id="searchInput" class="search-input" placeholder="웹 검색 또는 URL 입력..." autofocus autocomplete="off" spellcheck="false">
</form>
<div class="info-grid">
<div class="info-card">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>
<span>저장되지 않는 정보</span>
</div>
<ul class="card-list">
<li>방문 기록 (Browsing history)</li>
<li>쿠키 및 사이트 데이터</li>
<li>양식에 입력된 정보 및 계정 비밀번호</li>
<li>캐시 및 임시 파일 (종료 시 파기)</li>
</ul>
</div>
<div class="info-card">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
<span>외부에 표시될 수 있는 정보</span>
</div>
<ul class="card-list">
<li>방문하는 웹사이트</li>
<li>네트워크 관리자 (학교, 회사)</li>
<li>인터넷 서비스 제공업체 (ISP)</li>
<li>사용자가 직접 다운로드한 파일 (PC 보관)</li>
</ul>
</div>
</div>
</div>
<script>
function handleSearch(e) {
e.preventDefault();
var q = document.getElementById('searchInput').value.trim();
if (!q) return;
if (/^(https?:\/\/|file:\/\/|paca:\/\/)/i.test(q)) {
window.location.href = q;
} else if (q.indexOf('.') > 0 && q.indexOf(' ') < 0) {
window.location.href = 'https://' + q;
} else {
window.location.href = 'https://www.google.com/search?q=' + encodeURIComponent(q);
}
}
</script>
</body>
</html>
""";
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,159 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Paca.App.Browser.Features;
/// <summary>CoreWebView2DownloadOperation 의 최소 추상화(단위 테스트 fake 교체 가능).</summary>
public interface IDownloadOperation
{
string Uri { get; }
string FileName { get; }
long BytesReceived { get; }
long TotalBytesToReceive { get; }
/// <summary>InProgress / Interrupted / Completed / Cancelled</summary>
string State { get; }
void Pause();
void Resume();
void Cancel();
/// <summary>BytesReceived/State 변경 통지(구현은 호스트 스레드에서 발화).</summary>
event Action? Changed;
}
/// <summary>다운로드 매니저 UI 항목(T1-5) — 진행률/속도/상태를 INPC 로 노출.</summary>
public sealed class PageDownloadItem : INotifyPropertyChanged
{
public IDownloadOperation Op { get; }
public string FileName { get; }
public string Uri { get; }
public long TotalBytes { get; }
private long _received;
private long _bps;
private string _state;
public PageDownloadItem(IDownloadOperation op, string fileName)
{
Op = op;
FileName = fileName;
Uri = op.Uri;
TotalBytes = op.TotalBytesToReceive;
_received = op.BytesReceived;
_state = op.State;
op.Changed += () =>
{
_received = op.BytesReceived;
_state = op.State;
OnPropertyChangedAll();
};
}
public double Percent => TotalBytes > 0 ? Math.Clamp(_received * 100.0 / TotalBytes, 0, 100) : 0;
public long BytesPerSecond => _bps;
public string State => _state;
public bool IsFinished => _state is "Completed" or "Interrupted" or "Cancelled";
public bool CanControl => _state == "InProgress";
public string SpeedText => _bps switch
{
<= 0 => "—",
< 1024 => $"{(double)_bps:F1} B/s",
< 1024 * 1024 => $"{_bps / 1024.0:F1} KB/s",
_ => $"{_bps / 1024.0 / 1024.0:F1} MB/s",
};
public string SizeProgressText
{
get
{
if (TotalBytes > 0)
return $"{FormatBytes(_received)} / {FormatBytes(TotalBytes)}";
return FormatBytes(_received);
}
}
public string EtaText
{
get
{
if (_state == "Completed") return "완료됨";
if (_state == "Cancelled") return "취소됨";
if (_state == "Interrupted") return "중단됨";
if (_state == "Paused") return "일시정지됨";
if (_bps <= 0 || TotalBytes <= 0 || _received >= TotalBytes) return "—";
var remainingBytes = TotalBytes - _received;
var remainingSec = remainingBytes / _bps;
if (remainingSec < 60) return $"{Math.Max(1, remainingSec)}초 남음";
if (remainingSec < 3600) return $"{remainingSec / 60}분 남음";
return $"{remainingSec / 3600}시간 {(remainingSec % 3600) / 60}분 남음";
}
}
public static string FormatBytes(long bytes)
{
if (bytes < 1024) return $"{bytes} B";
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
if (bytes < 1024L * 1024 * 1024) return $"{bytes / 1024.0 / 1024.0:F1} MB";
return $"{bytes / 1024.0 / 1024.0 / 1024.0:F1} GB";
}
private long? _lastSpeedReceived;
/// <summary>속도 갱신: (현재까지 수신 바이트, 직전 호출로부터 경과).</summary>
public void UpdateSpeed(long receivedNow, TimeSpan elapsed)
{
_received = receivedNow;
if (elapsed.TotalSeconds > 0)
{
var delta = Math.Max(0, receivedNow - (_lastSpeedReceived ?? 0));
_bps = (long)(delta / elapsed.TotalSeconds);
}
_lastSpeedReceived = receivedNow;
OnPropertyChangedAll();
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChangedAll()
{
foreach (var n in new[] { nameof(Percent), nameof(BytesPerSecond), nameof(SpeedText), nameof(SizeProgressText), nameof(EtaText), nameof(State), nameof(IsFinished), nameof(CanControl) })
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
}
/// <summary>페이지 다운로드 목록(T1-5) — 최신순, 일시정지/재개/취소, 완료 정리.</summary>
public sealed class PageDownloadTracker
{
public ObservableCollection<PageDownloadItem> Items { get; } = new();
public PageDownloadItem Add(IDownloadOperation op, string fileName)
{
var item = new PageDownloadItem(op, fileName);
Items.Insert(0, item);
return item;
}
public void Pause(PageDownloadItem item) => item.Op.Pause();
public void Resume(PageDownloadItem item) => item.Op.Resume();
public void Cancel(PageDownloadItem item) => item.Op.Cancel();
/// <summary>UC-D28 페이지 파일 다운로드 전체 일시정지.</summary>
public void PauseAll()
{
foreach (var item in Items.Where(i => i.CanControl).ToList())
try { item.Op.Pause(); } catch { }
}
/// <summary>UC-D28 짝 맞춤: 일시정지 항목 전체 재개(Interrupted 포함).</summary>
public void ResumeAll()
{
foreach (var item in Items.Where(i => !i.IsFinished).ToList())
try { item.Op.Resume(); } catch { }
}
public void ClearCompleted()
{
foreach (var done in Items.Where(i => i.IsFinished).ToList())
Items.Remove(done);
}
}

View file

@ -0,0 +1,103 @@
using System.IO;
using System.Reflection;
using Microsoft.Web.WebView2.Core;
using Paca.Browser.Tabs;
namespace Paca.App.Browser.Features;
/// <summary>
/// 리더 모드(T2-4, Firefox Reader View 패턴): Mozilla Readability.js 로 본문을 추출해
/// 스타일된 정적 HTML 로 교체. 원본 URL 은 탭에 보존해 토글 복귀 지원.
/// </summary>
public sealed class ReaderMode
{
private static string? _bundle;
/// <summary>번들된 Readability.js 소스(어셈블리 리소스, 1회 로드).</summary>
public static string Bundle =>
_bundle ??= new StreamReader(
Assembly.GetExecutingAssembly().GetManifestResourceStream(
"Paca.App.Assets.readability.js")!).ReadToEnd();
/// <summary>리더 토글. 반환: 리더로 진입했으면 true, 원본 복귀면 false. 추출 실패 시 null.</summary>
public static async Task<bool?> ToggleAsync(TabItem tab, bool lightTheme)
{
var cv = tab.Core;
if (cv == null) return null;
if (tab.IsReader)
{
// 원본 복귀
tab.IsReader = false;
var original = tab.ReaderOriginalUrl;
if (!string.IsNullOrEmpty(original))
{
cv.Navigate(original);
return false;
}
return false;
}
// 본문 추출
var extractJs =
Bundle + "\n" +
"""
(function(){ try {
var docClone = document.cloneNode(true);
var article = new Readability(docClone).parse();
return JSON.stringify(article || null);
} catch(e){ return null; } })();
""";
string? json = null;
try { json = await cv.ExecuteScriptAsync(extractJs); }
catch { }
if (string.IsNullOrEmpty(json) || json == "null" || json == "\"null\"") return null;
// ExecuteScriptAsync 는 결과를 JSON 문자열로 한 겹 감싼다 — 언랩 후 파싱
var wrapped = System.Text.Json.JsonSerializer.Deserialize<string>(json);
if (string.IsNullOrEmpty(wrapped) || wrapped == "null") return null;
var article = System.Text.Json.JsonDocument.Parse(wrapped).RootElement;
if (!article.TryGetProperty("content", out var contentEl) ||
string.IsNullOrWhiteSpace(contentEl.GetString())) return null;
var title = article.TryGetProperty("title", out var t) ? t.GetString() : "";
var excerpt = article.TryGetProperty("excerpt", out var e) ? e.GetString() : "";
var byline = article.TryGetProperty("byline", out var b) ? b.GetString() : "";
// 진입 정보 저장 (복귀용)
tab.ReaderOriginalUrl = cv.Source;
tab.IsReader = true;
tab.Title = string.IsNullOrWhiteSpace(title) ? tab.Title : title + " (리더)";
cv.NavigateToString(BuildReaderHtml(title ?? "", byline ?? "", excerpt ?? "", contentEl.GetString() ?? "", lightTheme, AppFontStack));
return true;
}
/// <summary>호스트가 주입하는 앱 폰트 스택(UC-F17 — 폰트 하드코딩 제거). 기본값은 WPF 기본 스택.</summary>
public static string AppFontStack { get; set; } = "Segoe UI, sans-serif";
/// <summary>리더 HTML(Firefox 스타일 — 폰트/여백/테마). trusted-types 회피를 위해 iframe srcdoc 형태 대신 직접 문서.</summary>
public static string BuildReaderHtml(string title, string byline, string excerpt, string contentHtml, bool lightTheme, string? fontStack = null)
{
var bg = lightTheme ? "#faf8f5" : "#1b1c24";
var fg = lightTheme ? "#1b1c24" : "#e6e6ec";
var muted = lightTheme ? "#666" : "#9a9ab0";
var font = string.IsNullOrWhiteSpace(fontStack) ? AppFontStack : fontStack;
var linkColor = lightTheme ? "#2563eb" : "#60a5fa";
var css = $@"
body {{ background: {bg}; color: {fg}; font-family: '{font.Replace("'", "")}', sans-serif; max-width: 720px; margin: 0 auto; padding: 40px 24px 80px; line-height: 1.75; }}
h1 {{ font-size: 1.7em; line-height: 1.3; margin-bottom: 6px; }}
.byline {{ color: {muted}; font-size: 0.9em; margin-bottom: 4px; }}
.excerpt {{ color: {muted}; font-style: italic; margin: 14px 0 26px; padding-left: 12px; border-left: 3px solid {muted}; }}
img, video {{ max-width: 100%; height: auto; }}
a {{ color: {linkColor}; }}
pre {{ overflow-x: auto; background: {(lightTheme ? "#f0eee9" : "#262736")}; padding: 12px; border-radius: 8px; }}
blockquote {{ border-left: 3px solid {muted}; margin-left: 0; padding-left: 14px; color: {muted}; }}
";
var titleEsc = System.Net.WebUtility.HtmlEncode(title);
var bylineEsc = System.Net.WebUtility.HtmlEncode(byline);
var excerptEsc = System.Net.WebUtility.HtmlEncode(excerpt);
// UC-A17: lang="ko" 선언으로 화면 낭독기가 한국어 음성으로 읽는다.
return $"""<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{titleEsc}</title><style>{css}</style></head><body><h1>{titleEsc}</h1>{(bylineEsc.Length > 0 ? $"<div class=\"byline\">{bylineEsc}</div>" : "")}{(excerptEsc.Length > 0 ? $"<p class=\"excerpt\">{excerptEsc}</p>" : "")}{contentHtml}</body></html>""";
}
}

View file

@ -0,0 +1,51 @@
using Paca.Browser.Tabs;
namespace Paca.App.Browser.Features;
/// <summary>탭 검색 결과(T2-8, Chrome Ctrl+Shift+A 패턴).</summary>
public sealed record TabSearchResult(TabItem Tab, string Title, string Url, bool IsClosed);
/// <summary>열린(+닫힌) 탭을 제목/URL 부분일치로 검색. 대소문자 무시.</summary>
public static class TabSearchFilter
{
public static List<TabSearchResult> Search(IEnumerable<TabItem> openTabs, string? query)
=> Search(openTabs, Array.Empty<TabItem>(), query);
/// <summary>Chrome 파리티: 검색어가 비어 있으면 모든 열린 탭(+최근 닫힌 탭)을 즉시 반환하여 탭 빠른 탐색 지원.</summary>
public static List<TabSearchResult> GetAllOrSearch(IEnumerable<TabItem> openTabs, IEnumerable<TabItem> closedTabs, string? query, int maxClosed = 10)
{
query = query?.Trim();
if (string.IsNullOrEmpty(query))
{
var all = new List<TabSearchResult>();
foreach (var t in openTabs)
all.Add(new TabSearchResult(t, t.Title, t.Url, IsClosed: false));
int count = 0;
foreach (var t in closedTabs)
{
if (count++ >= maxClosed) break;
all.Add(new TabSearchResult(t, t.Title, t.Url, IsClosed: true));
}
return all;
}
return Search(openTabs, closedTabs, query);
}
public static List<TabSearchResult> Search(IEnumerable<TabItem> openTabs, IEnumerable<TabItem> closedTabs, string? query)
{
query = query?.Trim();
if (string.IsNullOrEmpty(query)) return new();
var low = query.ToLowerInvariant();
var results = new List<TabSearchResult>();
foreach (var t in openTabs)
if (Matches(t)) results.Add(new TabSearchResult(t, t.Title, t.Url, IsClosed: false));
foreach (var t in closedTabs)
if (Matches(t)) results.Add(new TabSearchResult(t, t.Title, t.Url, IsClosed: true));
return results;
bool Matches(TabItem t) =>
(t.Title ?? "").ToLowerInvariant().Contains(low)
|| (t.Url ?? "").ToLowerInvariant().Contains(low);
}
}

View file

@ -0,0 +1,41 @@
using Paca.Browser.Tabs;
namespace Paca.App.Browser.Features;
/// <summary>
/// 탭 절전(T2-2, Edge Sleeping Tabs 패턴): 유휴 비활성 탭을 TrySuspendAsync 로
/// 서스펜드해 메모리를 회수한다. 실제 서스펜드 수행은 주입(suspendAction) —
/// WebView2 TrySuspendAsync 는 뷰가 숨겨진(비활성) 탭에서만 동작.
/// </summary>
public sealed class TabSuspendService
{
private readonly TimeSpan _idleTimeout;
private readonly Func<TabItem, bool> _suspendAction;
private readonly Func<TabItem, bool> _canSuspend;
public TabSuspendService(TimeSpan idleTimeout, Func<TabItem, bool> suspendAction, Func<TabItem, bool> canSuspend)
{
_idleTimeout = idleTimeout;
_suspendAction = suspendAction;
_canSuspend = canSuspend;
}
/// <summary>유휴 비활성 탭 서스펜드. 반환: 서스펜드된 탭 수.</summary>
public int SuspendIdleTabs(IEnumerable<TabItem> tabs, TabItem active, DateTime now)
{
var count = 0;
foreach (var tab in tabs)
{
if (ReferenceEquals(tab, active)) continue;
if (tab.IsSuspended) continue;
if (!_canSuspend(tab)) continue;
if (now - tab.LastActivated < _idleTimeout) continue;
try
{
if (_suspendAction(tab)) { tab.IsSuspended = true; count++; }
}
catch { /* 서스펜드 실패는 건너뜀 */ }
}
return count;
}
}

View file

@ -0,0 +1,125 @@
using Microsoft.Web.WebView2.Core;
using Paca.Browser.Extensions;
namespace Paca.App.Browser.Features;
/// <summary>
/// WebView2 Profile과 연동하여 실제 브라우저 프로세스에 크롬 확장 프로그램을 주입/제어하는 호스트.
/// </summary>
public sealed class WebView2ExtensionHost : IBrowserExtensionHost
{
private readonly Func<CoreWebView2Profile?> _profileProvider;
private readonly Dictionary<string, CoreWebView2BrowserExtension> _loadedExtensions = new(StringComparer.OrdinalIgnoreCase);
public WebView2ExtensionHost(Func<CoreWebView2Profile?> profileProvider)
{
_profileProvider = profileProvider ?? throw new ArgumentNullException(nameof(profileProvider));
}
public async Task<string?> AddExtensionAsync(string folderPath, string extensionId)
{
var profile = _profileProvider();
if (profile == null) return null;
try
{
var ext = await profile.AddBrowserExtensionAsync(folderPath);
if (ext != null)
{
_loadedExtensions[extensionId] = ext;
_loadedExtensions[ext.Id] = ext;
return ext.Id;
}
}
catch (Exception)
{
// 이미 등록되어 있거나 로드 실패 시 목록에서 재검색
await RefreshLoadedMapAsync(profile);
if (_loadedExtensions.TryGetValue(extensionId, out var existing)) return existing.Id;
var match = _loadedExtensions.Values.FirstOrDefault(x => string.Equals(x.Id, extensionId, StringComparison.OrdinalIgnoreCase));
if (match != null) return match.Id;
}
return null;
}
public async Task<bool> RemoveExtensionAsync(string extensionId)
{
var profile = _profileProvider();
if (profile == null) return false;
var ext = await FindExtensionAsync(profile, extensionId);
if (ext != null)
{
try
{
await ext.RemoveAsync();
_loadedExtensions.Remove(extensionId);
_loadedExtensions.Remove(ext.Id);
return true;
}
catch
{
return false;
}
}
return false;
}
public async Task<bool> SetExtensionEnabledAsync(string extensionId, bool enabled)
{
var profile = _profileProvider();
if (profile == null) return false;
var ext = await FindExtensionAsync(profile, extensionId);
if (ext != null)
{
try
{
await ext.EnableAsync(enabled);
return true;
}
catch
{
return false;
}
}
return false;
}
private async Task<CoreWebView2BrowserExtension?> FindExtensionAsync(CoreWebView2Profile profile, string extensionId)
{
if (_loadedExtensions.TryGetValue(extensionId, out var existing))
return existing;
await RefreshLoadedMapAsync(profile);
if (_loadedExtensions.TryGetValue(extensionId, out var found))
return found;
return _loadedExtensions.Values.FirstOrDefault(x => string.Equals(x.Id, extensionId, StringComparison.OrdinalIgnoreCase));
}
private async Task RefreshLoadedMapAsync(CoreWebView2Profile profile)
{
try
{
var list = await profile.GetBrowserExtensionsAsync();
if (list != null)
{
foreach (var ext in list)
{
_loadedExtensions[ext.Id] = ext;
if (!string.IsNullOrWhiteSpace(ext.Name))
_loadedExtensions[ext.Name] = ext;
}
}
}
catch
{
// 지원되지 않는 환경이거나 오류 시
}
}
}

View file

@ -0,0 +1,85 @@
using System.Windows.Controls;
using System.Windows;
using System.Windows.Interop;
using Microsoft.Web.WebView2.Core;
using Paca.Core.Config;
using Wv2 = Microsoft.Web.WebView2.Wpf;
namespace Paca.App.Browser;
/// <summary>T2-6 Glance: Alt+클릭 링크의 플로팅 미리보기(Zen Glance 패턴) — 닫기/탭으로 승격.</summary>
public partial class GlanceWindow : Window
{
private readonly Wv2.WebView2 _web = new() { DefaultBackgroundColor = System.Drawing.Color.Black };
private readonly CoreWebView2Environment _env;
private readonly AppConfig _config;
public string Url { get; }
public event Action<GlanceWindow>? ExpandRequested;
public GlanceWindow(CoreWebView2Environment env, AppConfig config, string url, bool isPrivate = false)
{
_env = env;
_config = config;
Url = url;
Width = 760; Height = 540;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.CanResize;
SetResourceReference(FontFamilyProperty, "UiFont");
SetResourceReference(BackgroundProperty, "WindowBgGradient");
var bar = new Border
{
Padding = new Thickness(12, 6, 12, 6),
BorderThickness = new Thickness(0, 0, 0, 1)
};
bar.SetResourceReference(Border.BackgroundProperty, "PanelBg");
bar.SetResourceReference(Border.BorderBrushProperty, "BorderSubtle");
var dock = new DockPanel();
var close = new Button { Content = "닫기", Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(4, 0, 0, 0) };
close.SetResourceReference(StyleProperty, "CardActionButton");
close.Click += (_, _) => Close();
var expand = new Button { Content = "탭으로 열기", Padding = new Thickness(12, 4, 12, 4) };
expand.SetResourceReference(StyleProperty, "PrimaryButton");
expand.Click += (_, _) => ExpandRequested?.Invoke(this);
var title = new TextBlock
{
Text = url,
FontSize = 11.5,
FontWeight = FontWeights.Medium,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(4, 0, 10, 0)
};
title.SetResourceReference(TextBlock.ForegroundProperty, "TextPrimary");
DockPanel.SetDock(close, Dock.Right);
DockPanel.SetDock(expand, Dock.Right);
dock.Children.Add(close);
dock.Children.Add(expand);
dock.Children.Add(title);
bar.Child = dock;
var grid = new Grid { RowDefinitions = { new RowDefinition { Height = GridLength.Auto }, new RowDefinition { Height = new GridLength(1, GridUnitType.Star) } } };
Grid.SetRow(bar, 0); Grid.SetRow(_web, 1);
grid.Children.Add(bar);
grid.Children.Add(_web);
Content = grid;
Loaded += async (_, _) =>
{
try
{
var options = _env.CreateCoreWebView2ControllerOptions();
if (_config.PrivateMode || isPrivate) options.IsInPrivateModeEnabled = true;
await _web.EnsureCoreWebView2Async(_env, options);
_web.CoreWebView2!.Navigate(url);
}
catch { }
};
}
}

View file

@ -0,0 +1,332 @@
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shell;
using System.Windows.Threading;
using QRCoder;
using Paca.Server;
using Paca.Server.Gateway;
namespace Paca.App.Browser;
/// <summary>
/// M0 원격 접근 페어링:
/// 1. Wi-Fi / 같은 LAN 자동 탐색 배지 + QR 코드
/// 2. 3G / 4G / 5G 셀룰러 원격 게이트웨이 ConnectCode 발급
/// 3. 모던 글래스 다이얼로그 및 Designpaca 표준 테마 준수
/// </summary>
public sealed class PairingWindow : Window
{
public string ConnectCode { get; }
public PairingWindow(ServerHost server, string deviceName)
{
Title = "기기 연결 - Paca";
Width = 460;
Height = 670;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
WindowStyle = WindowStyle.None;
ResizeMode = ResizeMode.NoResize;
UseLayoutRounding = true;
SetResourceReference(FontFamilyProperty, "UiFont");
SetResourceReference(BackgroundProperty, "WindowBgGradient");
ConnectCode = PacaConnectCodeGenerator.GenerateCode();
var chrome = new WindowChrome
{
CaptionHeight = 0,
ResizeBorderThickness = new Thickness(4),
GlassFrameThickness = new Thickness(1),
CornerRadius = new CornerRadius(0),
UseAeroCaptionButtons = false,
NonClientFrameEdges = NonClientFrameEdges.None
};
WindowChrome.SetWindowChrome(this, chrome);
var lanIp = GetLanIp();
var url = $"http://{lanIp}:{server.Port}/#t={server.Token}&cc={ConnectCode}";
var qrPayload = new QRCodeGenerator().CreateQrCode(url, QRCodeGenerator.ECCLevel.M);
var qr = new QRCode(qrPayload);
var bmp = qr.GetGraphic(12);
var rootGrid = new Grid();
rootGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 캡션바
rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); // 콘텐츠
// 1. 캡션바 (드래그 핸들 + 닫기 버튼)
var titleBar = new Border
{
Padding = new Thickness(18, 10, 0, 10),
BorderThickness = new Thickness(0, 0, 0, 1)
};
titleBar.SetResourceReference(Border.BackgroundProperty, "PanelBg");
titleBar.SetResourceReference(Border.BorderBrushProperty, "BorderSubtle");
titleBar.MouseLeftButtonDown += (_, e) => { if (e.LeftButton == MouseButtonState.Pressed) DragMove(); };
var titleDock = new DockPanel();
var closeBtn = new Button
{
Content = "\uE8BB",
ToolTip = "닫기"
};
closeBtn.SetResourceReference(StyleProperty, "CloseButton");
closeBtn.Click += (_, _) => Close();
DockPanel.SetDock(closeBtn, Dock.Right);
var titleText = new TextBlock
{
Text = "기기 연결 (LAN 및 3G / 4G / 5G)",
FontSize = 13.5,
FontWeight = FontWeights.SemiBold,
VerticalAlignment = VerticalAlignment.Center
};
titleText.SetResourceReference(TextBlock.ForegroundProperty, "TextPrimary");
titleDock.Children.Add(closeBtn);
titleDock.Children.Add(titleText);
titleBar.Child = titleDock;
Grid.SetRow(titleBar, 0);
rootGrid.Children.Add(titleBar);
// 2. 메인 콘텐츠
var stack = new StackPanel { Margin = new Thickness(22, 14, 22, 16) };
// 탭 스위처 (Wi-Fi / LAN vs 3G / 4G / 5G)
var tabSwitcher = new Grid { Margin = new Thickness(0, 0, 0, 14) };
tabSwitcher.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
tabSwitcher.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var lanTabBtn = new Button { Content = "Wi-Fi / 같은 LAN", Padding = new Thickness(12, 6, 12, 6), Margin = new Thickness(0, 0, 4, 0), FontWeight = FontWeights.SemiBold };
lanTabBtn.SetResourceReference(StyleProperty, "PrimaryButton");
Grid.SetColumn(lanTabBtn, 0);
var wanTabBtn = new Button { Content = "3G / 4G / 5G 원격", Padding = new Thickness(12, 6, 12, 6), Margin = new Thickness(4, 0, 0, 0) };
wanTabBtn.SetResourceReference(StyleProperty, "CardActionButton");
Grid.SetColumn(wanTabBtn, 1);
tabSwitcher.Children.Add(lanTabBtn);
tabSwitcher.Children.Add(wanTabBtn);
// --- LAN 패널 ---
var lanPanel = new StackPanel();
// LAN 비콘 배지
var beaconBadge = new Border
{
CornerRadius = new CornerRadius(6),
Padding = new Thickness(10, 6, 10, 6),
Margin = new Thickness(0, 0, 0, 10)
};
beaconBadge.SetResourceReference(Border.BackgroundProperty, "Surface2");
beaconBadge.SetResourceReference(Border.BorderBrushProperty, "BorderSubtle");
beaconBadge.BorderThickness = new Thickness(1);
var beaconText = new TextBlock
{
Text = "[LAN] 자동 탐색 활성화됨 — 주변 모바일 앱에서 원클릭 감지 가능",
FontSize = 11,
FontWeight = FontWeights.Medium
};
beaconText.SetResourceReference(TextBlock.ForegroundProperty, "SuccessBrush");
beaconBadge.Child = beaconText;
// QR 이미지 카드
var qrCardOuter = new Border
{
CornerRadius = new CornerRadius(14),
BorderThickness = new Thickness(1),
Padding = new Thickness(8),
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 10)
};
qrCardOuter.SetResourceReference(Border.BackgroundProperty, "CardBg");
qrCardOuter.SetResourceReference(Border.BorderBrushProperty, "GlassBorder");
var qrCardInner = new Border
{
Background = Brushes.White,
CornerRadius = new CornerRadius(10),
Padding = new Thickness(8)
};
var img = new Image { Width = 200, Height = 200, SnapsToDevicePixels = true };
using var ms = new MemoryStream();
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
ms.Position = 0;
img.Source = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
qrCardInner.Child = img;
qrCardOuter.Child = qrCardInner;
var urlLabel = new TextBlock
{
Text = "접속 주소 (수동 입력 시)",
FontSize = 11,
Margin = new Thickness(0, 0, 0, 4)
};
urlLabel.SetResourceReference(TextBlock.ForegroundProperty, "TextMuted");
var urlBox = new TextBox
{
Text = url,
IsReadOnly = true,
FontSize = 11.5,
Padding = new Thickness(8, 6, 8, 6),
Margin = new Thickness(0, 0, 0, 8),
TextWrapping = TextWrapping.NoWrap
};
urlBox.SetResourceReference(StyleProperty, "DarkTextBox");
var copyBtn = new Button
{
Content = "주소 복사",
Padding = new Thickness(16, 7, 16, 7),
HorizontalAlignment = HorizontalAlignment.Left
};
copyBtn.SetResourceReference(StyleProperty, "PrimaryButton");
copyBtn.Click += (_, _) =>
{
try
{
Clipboard.SetText(url);
copyBtn.Content = "복사됨";
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
timer.Tick += (_, _) => { copyBtn.Content = "주소 복사"; timer.Stop(); };
timer.Start();
}
catch { }
};
lanPanel.Children.Add(beaconBadge);
lanPanel.Children.Add(qrCardOuter);
lanPanel.Children.Add(urlLabel);
lanPanel.Children.Add(urlBox);
lanPanel.Children.Add(copyBtn);
// --- WAN (3G/4G/5G) 패널 ---
var wanPanel = new StackPanel { Visibility = Visibility.Collapsed };
var wanDesc = new TextBlock
{
Text = "집 밖에서도 3G / 4G / 5G 모바일 네트워크로 집 PC에 연결할 수 있습니다. 모바일 앱의 [원격 연결] 화면에서 아래 ConnectCode 를 입력하세요.",
FontSize = 11.5,
TextWrapping = TextWrapping.Wrap,
LineHeight = 1.4,
Margin = new Thickness(0, 0, 0, 14)
};
wanDesc.SetResourceReference(TextBlock.ForegroundProperty, "TextSecondary");
var codeCard = new Border
{
CornerRadius = new CornerRadius(12),
BorderThickness = new Thickness(1),
Padding = new Thickness(16),
Margin = new Thickness(0, 0, 0, 14)
};
codeCard.SetResourceReference(Border.BackgroundProperty, "CardBg");
codeCard.SetResourceReference(Border.BorderBrushProperty, "GlassBorder");
var codeStack = new StackPanel();
var codeTitle = new TextBlock
{
Text = "Paca ConnectCode (6자리 원격 연결 코드)",
FontSize = 11,
Margin = new Thickness(0, 0, 0, 6)
};
codeTitle.SetResourceReference(TextBlock.ForegroundProperty, "TextMuted");
var codeDisplay = new TextBlock
{
Text = ConnectCode,
FontSize = 26,
FontWeight = FontWeights.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 6, 0, 10)
};
codeDisplay.SetResourceReference(TextBlock.ForegroundProperty, "AccentTextBrush");
var copyCodeBtn = new Button
{
Content = "ConnectCode 복사",
Padding = new Thickness(16, 7, 16, 7),
HorizontalAlignment = HorizontalAlignment.Center
};
copyCodeBtn.SetResourceReference(StyleProperty, "PrimaryButton");
copyCodeBtn.Click += (_, _) =>
{
try
{
Clipboard.SetText(ConnectCode);
copyCodeBtn.Content = "복사됨";
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
timer.Tick += (_, _) => { copyCodeBtn.Content = "ConnectCode 복사"; timer.Stop(); };
timer.Start();
}
catch { }
};
codeStack.Children.Add(codeTitle);
codeStack.Children.Add(codeDisplay);
codeStack.Children.Add(copyCodeBtn);
codeCard.Child = codeStack;
var wanTip = new TextBlock
{
Text = "• 모바일에서 영상 링크를 'PC로 보내기' 하면 집 PC가 4K로 다운로드합니다.\n• 집 PC에 저장된 영상을 밖에서 5G 스트리밍으로 이어볼 수 있습니다.",
FontSize = 11,
LineHeight = 1.45,
TextWrapping = TextWrapping.Wrap
};
wanTip.SetResourceReference(TextBlock.ForegroundProperty, "TextMuted");
wanPanel.Children.Add(wanDesc);
wanPanel.Children.Add(codeCard);
wanPanel.Children.Add(wanTip);
// 탭 전환 핸들러
lanTabBtn.Click += (_, _) =>
{
lanPanel.Visibility = Visibility.Visible;
wanPanel.Visibility = Visibility.Collapsed;
lanTabBtn.SetResourceReference(StyleProperty, "PrimaryButton");
wanTabBtn.SetResourceReference(StyleProperty, "CardActionButton");
};
wanTabBtn.Click += (_, _) =>
{
lanPanel.Visibility = Visibility.Collapsed;
wanPanel.Visibility = Visibility.Visible;
wanTabBtn.SetResourceReference(StyleProperty, "PrimaryButton");
lanTabBtn.SetResourceReference(StyleProperty, "CardActionButton");
};
stack.Children.Add(tabSwitcher);
stack.Children.Add(lanPanel);
stack.Children.Add(wanPanel);
Grid.SetRow(stack, 1);
rootGrid.Children.Add(stack);
Content = rootGrid;
}
/// <summary>LAN IPv4(게이트웨이 있는 첫 후보) — 없으면 loopback.</summary>
private static string GetLanIp()
{
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
socket.Connect("8.8.8.8", 65530);
return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString() ?? "127.0.0.1";
}
catch { return "127.0.0.1"; }
}
}

View file

@ -0,0 +1,284 @@
using System.IO;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using Microsoft.Web.WebView2.Core;
using Paca.App.Browser.Features;
using Paca.Core.Config;
using Wv2 = Microsoft.Web.WebView2.Wpf;
namespace Paca.App.Browser;
/// <summary>
/// T2-10 미디어 재생목록 플레이어: Direct(MP4)/HLS(hls.js)/DASH(dash.js) 를
/// 생성된 HTML 플레이어로 재생. 곡 종료 시 다음 트랙 자동 재생(연속 재생).
/// </summary>
public sealed class PlayerWindow : Window
{
private readonly CoreWebView2Environment _env;
private readonly AppConfig _config;
private readonly Wv2.WebView2 _web = new() { DefaultBackgroundColor = System.Drawing.Color.Black };
private MediaPlaylist _playlist;
private readonly TextBlock _title;
private readonly bool _isPrivate;
public PlayerWindow(CoreWebView2Environment env, AppConfig config, MediaPlaylist playlist, bool isPrivate = false)
{
_env = env;
_config = config;
_playlist = playlist;
_isPrivate = isPrivate;
Title = "재생목록 - Paca";
Width = 900; Height = 620;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
SetResourceReference(FontFamilyProperty, "UiFont"); // 암시 스타일 폰트 강제 제거 후 창 루트 상속으로 통일
var bar = new Border
{
Padding = new Thickness(12, 6, 12, 6),
BorderThickness = new Thickness(0, 0, 0, 1)
};
bar.SetResourceReference(Border.BackgroundProperty, "PanelBg");
bar.SetResourceReference(Border.BorderBrushProperty, "BorderSubtle");
var dock = new DockPanel();
var prev = MkBtn("이전", () => { _playlist.Previous(); PlayCurrent(); });
var next = MkBtn("다음", () => { _playlist.Next(); PlayCurrent(); });
var close = MkBtn("닫기", () => Close());
// Topmost (항상 위) 토글 버튼
var pin = MkBtn("항상 위", () =>
{
Topmost = !Topmost;
Title = (Topmost ? "[고정] " : "") + "재생목록 - Paca";
});
// 배속 조절 콤보박스
var speedCombo = new ComboBox
{
Width = 76,
Height = 28,
Margin = new Thickness(4, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
ItemsSource = new[] { "0.5x", "0.75x", "1.0x", "1.25x", "1.5x", "2.0x" },
SelectedIndex = 2,
ToolTip = "재생 속도 조절"
};
speedCombo.SetResourceReference(StyleProperty, "MiniDarkComboBox");
speedCombo.SelectionChanged += (_, _) =>
{
if (speedCombo.SelectedItem is string text && double.TryParse(text.Replace("x", ""), out var rate))
{
SetPlaybackRate(rate);
}
};
// HUD 정보 토글 버튼
var info = MkBtn("ℹ 정보 (I)", () => ToggleInfoHud());
_title = new TextBlock
{
FontSize = 12,
FontWeight = FontWeights.Medium,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(10, 0, 10, 0)
};
_title.SetResourceReference(TextBlock.ForegroundProperty, "TextPrimary");
DockPanel.SetDock(close, Dock.Right);
DockPanel.SetDock(pin, Dock.Right);
DockPanel.SetDock(speedCombo, Dock.Right);
DockPanel.SetDock(info, Dock.Right);
DockPanel.SetDock(next, Dock.Right);
DockPanel.SetDock(prev, Dock.Left);
dock.Children.Add(close);
dock.Children.Add(pin);
dock.Children.Add(speedCombo);
dock.Children.Add(info);
dock.Children.Add(next);
dock.Children.Add(prev);
dock.Children.Add(_title);
bar.Child = dock;
var grid = new Grid { RowDefinitions = { new RowDefinition { Height = GridLength.Auto }, new RowDefinition { Height = new GridLength(1, GridUnitType.Star) } } };
Grid.SetRow(bar, 0); Grid.SetRow(_web, 1);
grid.Children.Add(bar);
grid.Children.Add(_web);
Content = grid;
Loaded += async (_, _) =>
{
try
{
var options = _env.CreateCoreWebView2ControllerOptions();
if (_config.PrivateMode || _isPrivate) options.IsInPrivateModeEnabled = true;
await _web.EnsureCoreWebView2Async(_env, options);
var cv = _web.CoreWebView2!;
cv.WebMessageReceived += (_, we) =>
{
try
{
var msg = we.WebMessageAsJson;
if (string.IsNullOrEmpty(msg)) return;
using var doc = System.Text.Json.JsonDocument.Parse(msg);
if (doc.RootElement.TryGetProperty("vdEnded", out var e) && e.GetBoolean())
{
Dispatcher.BeginInvoke(new Action(() =>
{
var cur = _playlist.Current;
if (cur != null) _playlist.MarkPlayed(cur);
// UC-M12 반복 모드: one=현 트랙 재생, all=끝에서 처음으로 순환, none=끝에서 정지
var mode = _config.PlaylistLoopMode;
if (mode == "one") { PlayCurrent(); return; }
var atEnd = _playlist.Tracks.Count > 0 && _playlist.CurrentIndex >= _playlist.Tracks.Count - 1;
if (atEnd && mode != "all") return;
_playlist.Next();
PlayCurrent();
}));
}
}
catch { }
};
PlayCurrent();
}
catch { }
};
}
public void SetPlaybackRate(double rate)
{
try
{
_web.CoreWebView2?.ExecuteScriptAsync($"if(window.vdSetSpeed) window.vdSetSpeed({rate}); else {{ var v=document.getElementById('v'); if(v) v.playbackRate={rate}; }}");
}
catch { }
}
public void ToggleInfoHud()
{
try
{
_web.CoreWebView2?.ExecuteScriptAsync("if(window.vdToggleHud) window.vdToggleHud();");
}
catch { }
}
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
try
{
var hwnd = new WindowInteropHelper(this).Handle;
int dark = 1;
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
}
catch { }
}
[System.Runtime.InteropServices.DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
private Button MkBtn(string text, Action onClick)
{
var b = new Button
{
Content = text,
Padding = new Thickness(10, 4, 10, 4),
Margin = new Thickness(3, 0, 3, 0)
};
b.SetResourceReference(StyleProperty, "CardActionButton");
b.Click += (_, _) => onClick();
return b;
}
public void PlayCurrent()
{
var track = _playlist.Current;
if (track == null) { _title.Text = "재생목록이 비었습니다"; return; }
_title.Text = $"{_playlist.CurrentIndex + 1}/{_playlist.Tracks.Count} · {track.Title} [{track.Kind}]";
try { _web.CoreWebView2?.NavigateToString(BuildPlayerHtml(track)); }
catch { }
}
/// <summary>트랙 재생 HTML — 단축키, 실시간 스트림 정보 HUD, 배속 조절 내장.</summary>
public static string BuildPlayerHtml(MediaTrack track)
{
var url = track.Url.Replace("&", "&amp;").Replace("\"", "&quot;").Replace("<", "&lt;");
var isHls = track.Kind == "Hls" || track.Url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase);
var isDash = track.Kind == "Dash" || track.Url.Contains(".mpd", StringComparison.OrdinalIgnoreCase);
var hls = isHls ? ReadResource("Paca.App.Assets.hls.min.js") : "";
var dash = isDash ? ReadResource("Paca.App.Assets.dash.all.min.js") : "";
var loader = "";
if (isHls) loader = "if (typeof Hls !== 'undefined' && Hls.isSupported()) { var h = new Hls(); h.loadSource(SRC); h.attachMedia(v); } else v.src = SRC;";
else if (isDash) loader = "if (typeof dashjs !== 'undefined') { var p = dashjs.MediaPlayer().create(); p.initialize(v, SRC, true); } else v.src = SRC;";
else loader = "v.src = SRC;";
var html = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>" +
"body{background:#000;margin:0;overflow:hidden;font-family:system-ui,-apple-system,'Pretendard',sans-serif;color:#fff}" +
"video{width:100vw;height:100vh;outline:none}" +
"#vd-hud{position:absolute;top:18px;left:18px;background:rgba(16,18,28,0.85);backdrop-filter:blur(24px);" +
"border:1px solid rgba(255,255,255,0.12);border-radius:12px;padding:14px 18px;font-size:12px;line-height:1.7;" +
"color:#e2e4ee;pointer-events:none;display:none;z-index:100;box-shadow:inset 0 1px 1px rgba(255,255,255,0.1),0 12px 36px rgba(0,0,0,0.6)}" +
"#vd-hud b{color:#fff;font-size:13px;letter-spacing:-0.01em;display:inline-block;margin-bottom:4px}" +
"#vd-toast{position:absolute;bottom:70px;left:50%;transform:translateX(-50%);background:rgba(16,18,28,0.9);" +
"backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,0.14);border-radius:24px;padding:8px 20px;" +
"font-size:13px;font-weight:600;color:#fff;pointer-events:none;opacity:0;transition:opacity 0.2s cubic-bezier(0.32,0.72,0,1);z-index:101;box-shadow:0 8px 28px rgba(0,0,0,0.5)}" +
"</style></head><body>" +
"<video id='v' controls autoplay playsinline></video>" +
"<div id='vd-hud'><b>미디어 스트림 정보 (I)</b><br>" +
"해상도: <span id='h-res' style='font-family:monospace'>-</span><br>" +
"시간: <span id='h-time' style='font-family:monospace'>-</span><br>" +
"배속: <span id='h-rate' style='font-family:monospace'>1.0x</span><br>" +
"볼륨: <span id='h-vol' style='font-family:monospace'>100%</span><br>" +
"유형: <span style='color:#a78bfa;font-weight:bold'>" + track.Kind + "</span></div>" +
"<div id='vd-toast'></div>" +
"<script>var SRC=" + System.Text.Json.JsonSerializer.Serialize(track.Url) + ";" +
(hls.Length > 0 ? "<script>" + hls + "</script>" : "") +
(dash.Length > 0 ? "<script>" + dash + "</script>" : "") +
"<script>(function(){" +
"var v=document.getElementById('v');" +
"var hud=document.getElementById('vd-hud');" +
"var toast=document.getElementById('vd-toast');" +
"var toastTimer=null;" +
"function showToast(msg){if(toastTimer)clearTimeout(toastTimer);toast.textContent=msg;toast.style.opacity='1';toastTimer=setTimeout(function(){toast.style.opacity='0';},1200);}" +
loader +
"window.vdSetSpeed=function(r){v.playbackRate=r;showToast('속도: '+r+'x');updateHud();};" +
"window.vdToggleHud=function(){hud.style.display=hud.style.display==='block'?'none':'block';updateHud();};" +
"function updateHud(){" +
"if(hud.style.display==='block'){" +
"document.getElementById('h-res').textContent=v.videoWidth>0?(v.videoWidth+'x'+v.videoHeight):'확인 중...';" +
"document.getElementById('h-time').textContent=Math.floor(v.currentTime)+'s / '+Math.floor(v.duration||0)+'s';" +
"document.getElementById('h-rate').textContent=v.playbackRate+'x';" +
"document.getElementById('h-vol').textContent=v.muted?'음소거':Math.round(v.volume*100)+'%';" +
"}};" +
"setInterval(updateHud,1000);" +
"window.addEventListener('keydown',function(e){" +
"if(e.target===v||e.target===document.body||e.target===document.documentElement){" +
"if(e.code==='Space'||e.key===' '){e.preventDefault();if(v.paused){v.play();showToast('재생');}else{v.pause();showToast('일시정지');}}" +
"else if(e.code==='ArrowLeft'){e.preventDefault();v.currentTime=Math.max(0,v.currentTime-5);showToast('-5초 ('+Math.floor(v.currentTime)+'s)');updateHud();}" +
"else if(e.code==='ArrowRight'){e.preventDefault();v.currentTime=Math.min(v.duration||999999,v.currentTime+5);showToast('+5초 ('+Math.floor(v.currentTime)+'s)');updateHud();}" +
"else if(e.code==='ArrowUp'){e.preventDefault();v.volume=Math.min(1,v.volume+0.1);v.muted=false;showToast('볼륨 '+Math.round(v.volume*100)+'%');updateHud();}" +
"else if(e.code==='ArrowDown'){e.preventDefault();v.volume=Math.max(0,v.volume-0.1);showToast('볼륨 '+Math.round(v.volume*100)+'%');updateHud();}" +
"else if(e.code==='KeyM'||e.key==='m'||e.key==='M'){e.preventDefault();v.muted=!v.muted;showToast(v.muted?'음소거':'소리 켬');updateHud();}" +
"else if(e.code==='KeyF'||e.key==='f'||e.key==='F'){e.preventDefault();if(document.fullscreenElement)document.exitFullscreen();else v.requestFullscreen();}" +
"else if(e.code==='KeyI'||e.key==='i'||e.key==='I'){e.preventDefault();window.vdToggleHud();}" +
"}});" +
"v.addEventListener('ended',function(){try{window.chrome.webview.postMessage({vdEnded:true});}catch(e){}});" +
"v.addEventListener('error',function(){try{window.chrome.webview.postMessage({vdEnded:true});}catch(e){}});})();" +
"</script></body></html>";
return html;
}
private static string ReadResource(string name)
{
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(name);
return stream == null ? "" : new StreamReader(stream).ReadToEnd();
}
}

View file

@ -0,0 +1,189 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Effects;
using System.Windows.Threading;
using Paca.App.ViewModels;
namespace Paca.App.Browser;
/// <summary>
/// 데스크톱 글로벌 빠른 캡처 HUD (Quick Capture HUD Floating Window):
/// 클립보드에 미디어 링크가 감지되거나 단축키 입력 시 화면 우하단에 미니멀하게 오버레이되어
/// 즉시 다운로드 또는 모바일 PacaDrop 전송을 지원함.
/// </summary>
public class QuickCaptureHudWindow : Window
{
public QuickCaptureHudViewModel ViewModel { get; }
public Button DownloadButton { get; }
public Button SendToPhoneButton { get; }
public Button DismissButton { get; }
private readonly DispatcherTimer _autoDismissTimer;
public QuickCaptureHudWindow(QuickCaptureHudViewModel vm)
{
ViewModel = vm ?? throw new ArgumentNullException(nameof(vm));
Title = "Paca 빠른 캡처";
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = Brushes.Transparent;
ShowInTaskbar = false;
Topmost = true;
SizeToContent = SizeToContent.WidthAndHeight;
UseLayoutRounding = true;
var rootBorder = new Border
{
Background = new SolidColorBrush(Color.FromRgb(0x18, 0x1A, 0x22)),
BorderBrush = new SolidColorBrush(Color.FromRgb(0x33, 0x37, 0x48)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 10, 14, 10),
Effect = new DropShadowEffect
{
BlurRadius = 20,
ShadowDepth = 4,
Color = Colors.Black,
Opacity = 0.5
}
};
var mainStack = new StackPanel { Orientation = Orientation.Vertical, Width = 360 };
// Header: Badge + Domain + Close
var headerGrid = new Grid();
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var badge = new Border
{
Background = new SolidColorBrush(Color.FromRgb(0x02, 0x84, 0xC7)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(6, 2, 6, 2),
Margin = new Thickness(0, 0, 8, 0),
Child = new TextBlock
{
Text = "PACA",
FontSize = 10,
FontWeight = FontWeights.Bold,
Foreground = Brushes.White
}
};
Grid.SetColumn(badge, 0);
headerGrid.Children.Add(badge);
var domainText = new TextBlock
{
Text = vm.DomainLabel,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(Color.FromRgb(0x38, 0xBD, 0xF8)),
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(domainText, 1);
headerGrid.Children.Add(domainText);
DismissButton = new Button
{
Content = "X",
Width = 24,
Height = 24,
MinHeight = 24,
Background = Brushes.Transparent,
Foreground = new SolidColorBrush(Color.FromRgb(0x94, 0xA3, 0xB8)),
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Command = vm.DismissCommand
};
Grid.SetColumn(DismissButton, 2);
headerGrid.Children.Add(DismissButton);
mainStack.Children.Add(headerGrid);
// URL display with ellipsis
var urlText = new TextBlock
{
Text = vm.TargetUrl,
FontSize = 11,
Foreground = new SolidColorBrush(Color.FromRgb(0x94, 0xA3, 0xB8)),
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(0, 6, 0, 10),
MaxWidth = 350
};
mainStack.Children.Add(urlText);
// Buttons
var btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right
};
SendToPhoneButton = new Button
{
Content = "폰으로 전송",
MinHeight = 32,
Padding = new Thickness(12, 4, 12, 4),
Margin = new Thickness(0, 0, 8, 0),
Background = new SolidColorBrush(Color.FromRgb(0x27, 0x2A, 0x38)),
Foreground = new SolidColorBrush(Color.FromRgb(0xE2, 0xE8, 0xF0)),
BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3E, 0x52)),
BorderThickness = new Thickness(1),
Cursor = Cursors.Hand,
Command = vm.SendToPhoneCommand
};
DownloadButton = new Button
{
Content = "다운로드",
MinHeight = 32,
Padding = new Thickness(14, 4, 14, 4),
Background = new SolidColorBrush(Color.FromRgb(0x02, 0x84, 0xC7)),
Foreground = Brushes.White,
FontWeight = FontWeights.SemiBold,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Command = vm.DownloadCommand
};
btnPanel.Children.Add(SendToPhoneButton);
btnPanel.Children.Add(DownloadButton);
mainStack.Children.Add(btnPanel);
rootBorder.Child = mainStack;
Content = rootBorder;
// Auto dismiss timer (6s)
_autoDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(6) };
_autoDismissTimer.Tick += (_, _) =>
{
_autoDismissTimer.Stop();
vm.DismissCommand.Execute(null);
};
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(QuickCaptureHudViewModel.IsVisible) && !vm.IsVisible)
{
Close();
}
};
MouseEnter += (_, _) => _autoDismissTimer.Stop();
MouseLeave += (_, _) => _autoDismissTimer.Start();
Loaded += (_, _) => _autoDismissTimer.Start();
}
public (double Left, double Top) CalculateDefaultPosition(double screenWidth, double screenHeight, double hudWidth, double hudHeight)
{
var left = screenWidth - hudWidth - 24;
var top = screenHeight - hudHeight - 56;
return (left, top);
}
}

View file

@ -0,0 +1,79 @@
<Window x:Class="Paca.App.BrowserTaskManagerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="작업 관리자 - Paca" Height="480" Width="640"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
UseLayoutRounding="True"
KeyboardNavigation.TabNavigation="Cycle"
AutomationProperties.Name="작업 관리자 창">
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 헤더 및 새로고침 -->
<DockPanel Grid.Row="0" Margin="0,0,0,14">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE756;" Style="{StaticResource FluentIcon}" FontSize="15" Foreground="{DynamicResource AccentTextBrush}" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="작업 관리자" FontSize="15" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center"/>
</StackPanel>
<Button x:Name="RefreshBtn" AutomationProperties.Name="새로고침" DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="새로고침"
Click="RefreshBtn_Click" HorizontalAlignment="Right" Padding="10,4" FontSize="11"/>
</DockPanel>
<!-- 태스크 목록 그리드 -->
<Border Grid.Row="1" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource GlassBorder}" BorderThickness="1" CornerRadius="10" Padding="6">
<ListView x:Name="TaskList" AutomationProperties.Name="프로세스 작업 목록" Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource TextPrimary}" SelectionMode="Single"
SelectionChanged="TaskList_SelectionChanged">
<ListView.View>
<GridView>
<GridViewColumn Header="작업" Width="320">
<GridViewColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontWeight="Medium" Foreground="{DynamicResource TextPrimary}" TextTrimming="CharacterEllipsis" MaxWidth="280"/>
</StackPanel>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="유형" Width="90">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Type}" Foreground="{DynamicResource TextSecondary}" FontSize="11"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="메모리 사용량" Width="100">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding MemoryDisplay}"
Foreground="{DynamicResource AccentTextBrush}" FontWeight="Medium" FontSize="11"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="CPU" Width="70">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding CpuPercent, StringFormat={}{0:0.0}%}"
Foreground="{DynamicResource TextMuted}" FontSize="11"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
</Border>
<!-- 하단 단추 바 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button x:Name="CloseBtn" AutomationProperties.Name="닫기" Style="{StaticResource GhostButton}" Content="닫기" Padding="14,6" Margin="0,0,8,0" Click="CloseBtn_Click"/>
<Button x:Name="EndTaskBtn" AutomationProperties.Name="프로세스 종료" Style="{StaticResource PrimaryButton}" Content="프로세스 종료"
Padding="16,6" FontWeight="SemiBold" Click="EndTaskBtn_Click" IsEnabled="False"/>
</StackPanel>
</Grid>
</Window>

View file

@ -0,0 +1,63 @@
using System;
using System.Windows;
using System.Windows.Controls;
using Paca.Browser;
using Paca.Browser.Extensions;
using Paca.Browser.Tabs;
namespace Paca.App;
public partial class BrowserTaskManagerWindow : Window
{
private readonly TabManager _tabMgr;
private readonly ExtensionManager? _extMgr;
public BrowserTaskManagerWindow(TabManager tabMgr, ExtensionManager? extMgr = null)
{
InitializeComponent();
_tabMgr = tabMgr;
_extMgr = extMgr;
RefreshTasks();
}
public void RefreshTasks()
{
var tasks = BrowserTaskManager.CollectTasks(_tabMgr, _extMgr);
TaskList.ItemsSource = tasks;
EndTaskBtn.IsEnabled = false;
}
private void RefreshBtn_Click(object sender, RoutedEventArgs e)
{
RefreshTasks();
}
private void TaskList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (TaskList.SelectedItem is BrowserTaskItem item)
{
EndTaskBtn.IsEnabled = item.Type != BrowserTaskType.Browser;
}
else
{
EndTaskBtn.IsEnabled = false;
}
}
private void EndTaskBtn_Click(object sender, RoutedEventArgs e)
{
if (TaskList.SelectedItem is not BrowserTaskItem item) return;
if (item.Type == BrowserTaskType.Browser) return;
bool success = BrowserTaskManager.EndTask(item, _tabMgr, _extMgr);
if (success)
{
RefreshTasks();
}
}
private void CloseBtn_Click(object sender, RoutedEventArgs e)
{
Close();
}
}

View file

@ -0,0 +1,81 @@
<Window x:Class="Paca.App.ClearBrowsingDataDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="인터넷 사용 기록 삭제" Height="460" Width="440"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
UseLayoutRounding="True" ResizeMode="NoResize"
KeyboardNavigation.TabNavigation="Cycle"
AutomationProperties.Name="인터넷 사용 기록 삭제 창">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 헤더 -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="&#xE74D;" Style="{StaticResource FluentIcon}" FontSize="16" Foreground="{DynamicResource AccentTextBrush}" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="인터넷 사용 기록 삭제" FontSize="16" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="선택한 기간 동안 기기에 저장된 브라우징 데이터를 삭제합니다." FontSize="11.5" Foreground="{DynamicResource TextSecondary}"/>
</StackPanel>
<!-- 옵션 본문 -->
<Border Grid.Row="1" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource GlassBorder}" BorderThickness="1" CornerRadius="10" Padding="16,14">
<StackPanel>
<!-- 기간 선택 -->
<DockPanel Margin="0,0,0,16">
<TextBlock Text="기간" FontSize="12" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center"/>
<ComboBox x:Name="TimeRangeCombo" AutomationProperties.Name="삭제 기간 선택" DockPanel.Dock="Right" HorizontalAlignment="Right" Width="140" Style="{StaticResource MiniDarkComboBox}">
<ComboBoxItem Content="지난 1시간" IsSelected="True"/>
<ComboBoxItem Content="지난 24시간"/>
<ComboBoxItem Content="지난 7일"/>
<ComboBoxItem Content="지난 4주"/>
<ComboBoxItem Content="전체 기간"/>
</ComboBox>
</DockPanel>
<Separator Margin="0,0,0,14" Background="{DynamicResource BorderSubtle}"/>
<!-- 체크박스 항목들 -->
<CheckBox x:Name="ClearHistoryCheck" AutomationProperties.Name="인터넷 사용 기록 삭제" Style="{StaticResource DarkCheckBox}" Margin="0,0,0,12" IsChecked="True">
<StackPanel Margin="6,0,0,0">
<TextBlock Text="인터넷 사용 기록" FontSize="12" FontWeight="Medium" Foreground="{DynamicResource TextPrimary}"/>
<TextBlock Text="주소창 자동 완성 및 방문한 페이지 목록을 삭제합니다." FontSize="10.5" Foreground="{DynamicResource TextMuted}" Margin="0,2,0,0"/>
</StackPanel>
</CheckBox>
<CheckBox x:Name="ClearCookiesCheck" AutomationProperties.Name="쿠키 및 기타 사이트 데이터 삭제" Style="{StaticResource DarkCheckBox}" Margin="0,0,0,12" IsChecked="True">
<StackPanel Margin="6,0,0,0">
<TextBlock Text="쿠키 및 기타 사이트 데이터" FontSize="12" FontWeight="Medium" Foreground="{DynamicResource TextPrimary}"/>
<TextBlock Text="대부분의 사이트에서 로그아웃됩니다." FontSize="10.5" Foreground="{DynamicResource TextMuted}" Margin="0,2,0,0"/>
</StackPanel>
</CheckBox>
<CheckBox x:Name="ClearCacheCheck" AutomationProperties.Name="캐시된 이미지 및 파일 삭제" Style="{StaticResource DarkCheckBox}" Margin="0,0,0,12" IsChecked="True">
<StackPanel Margin="6,0,0,0">
<TextBlock Text="캐시된 이미지 및 파일" FontSize="12" FontWeight="Medium" Foreground="{DynamicResource TextPrimary}"/>
<TextBlock Text="디스크 캐시를 비워 저장 공간을 확보합니다." FontSize="10.5" Foreground="{DynamicResource TextMuted}" Margin="0,2,0,0"/>
</StackPanel>
</CheckBox>
<CheckBox x:Name="ClearDownloadsCheck" AutomationProperties.Name="다운로드 기록 삭제" Style="{StaticResource DarkCheckBox}" Margin="0,0,0,0" IsChecked="False">
<StackPanel Margin="6,0,0,0">
<TextBlock Text="다운로드 기록" FontSize="12" FontWeight="Medium" Foreground="{DynamicResource TextPrimary}"/>
<TextBlock Text="다운로드 목록을 지웁니다(실제 파일은 보존됨)." FontSize="10.5" Foreground="{DynamicResource TextMuted}" Margin="0,2,0,0"/>
</StackPanel>
</CheckBox>
</StackPanel>
</Border>
<!-- 하단 액션 버튼 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,0,0">
<Button x:Name="CancelBtn" AutomationProperties.Name="취소" Style="{StaticResource GhostButton}" Content="취소" Padding="14,6" Margin="0,0,8,0" Click="CancelBtn_Click"/>
<Button x:Name="ClearBtn" AutomationProperties.Name="데이터 삭제" Style="{StaticResource PrimaryButton}" Content="데이터 삭제" Padding="16,6" FontWeight="SemiBold" Click="ClearBtn_Click"/>
</StackPanel>
</Grid>
</Window>

View file

@ -0,0 +1,60 @@
using System;
using System.Threading.Tasks;
using System.Windows;
using Paca.Browser;
namespace Paca.App;
public partial class ClearBrowsingDataDialog : Window
{
private readonly Func<ClearBrowsingDataOptions, Task>? _onExecuteClear;
public ClearBrowsingDataDialog(Func<ClearBrowsingDataOptions, Task>? onExecuteClear = null)
{
InitializeComponent();
_onExecuteClear = onExecuteClear;
}
public ClearBrowsingTimeRange SelectedTimeRange => TimeRangeCombo.SelectedIndex switch
{
0 => ClearBrowsingTimeRange.LastHour,
1 => ClearBrowsingTimeRange.Last24Hours,
2 => ClearBrowsingTimeRange.Last7Days,
3 => ClearBrowsingTimeRange.Last4Weeks,
_ => ClearBrowsingTimeRange.AllTime
};
public ClearBrowsingDataOptions SelectedOptions => new(
TimeRange: SelectedTimeRange,
ClearHistory: ClearHistoryCheck.IsChecked == true,
ClearCookies: ClearCookiesCheck.IsChecked == true,
ClearCache: ClearCacheCheck.IsChecked == true,
ClearDownloads: ClearDownloadsCheck.IsChecked == true);
private void CancelBtn_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
private async void ClearBtn_Click(object sender, RoutedEventArgs e)
{
ClearBtn.IsEnabled = false;
CancelBtn.IsEnabled = false;
try
{
if (_onExecuteClear != null)
{
await _onExecuteClear(SelectedOptions);
}
DialogResult = true;
Close();
}
catch (Exception ex)
{
MessageBox.Show(this, $"인터넷 사용 기록 삭제 중 오류가 발생했습니다: {ex.Message}", "오류", MessageBoxButton.OK, MessageBoxImage.Warning);
ClearBtn.IsEnabled = true;
CancelBtn.IsEnabled = true;
}
}
}

View file

@ -0,0 +1,172 @@
<UserControl x:Class="Paca.App.Controls.DesktopAdBannerControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="64" d:DesignWidth="700">
<Border x:Name="PART_RootBorder"
CornerRadius="8"
Background="#1E2028"
BorderBrush="#2D3139"
BorderThickness="1"
Padding="12,8"
Margin="0,4"
SnapsToDevicePixels="True">
<Grid>
<!-- 실시간 웹 디스플레이 광고 호스트 (Google Publisher Tag / WebView2) -->
<Grid x:Name="PART_RealAdHost" Visibility="Visible">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border x:Name="PART_WebViewContainer" Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" CornerRadius="8" ClipToBounds="True" Background="#1E2028"/>
<Button x:Name="PART_RealAdDismissBtn"
Grid.Column="1"
Width="32"
Height="32"
VerticalAlignment="Top"
Margin="6,2,0,0"
Background="Transparent"
Foreground="#94A3B8"
BorderThickness="0"
FontSize="13"
Cursor="Hand"
Visibility="Collapsed"
AutomationProperties.Name="실광고 닫기"
Click="OnDismissClicked">
<Button.Resources>
<Style TargetType="Border">
<Setter Property="CornerRadius" Value="16"/>
</Style>
</Button.Resources>
<TextBlock Text="✕" VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Button>
</Grid>
<!-- 네이티브 스폰서 카드 UI (실광고 미게재 시 폴백) -->
<Grid x:Name="PART_NativeCard" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 상단: 브랜드 로고/이미지 + 광고주 정보 및 헤드라인 + 닫기 버튼 -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 브랜드/상품 이미지 썸네일 영역 -->
<Border x:Name="PART_AdImageBorder"
Grid.Column="0"
Width="52"
Height="52"
CornerRadius="8"
Background="#252833"
BorderBrush="#333846"
BorderThickness="1"
Margin="0,0,10,0"
VerticalAlignment="Center"
ClipToBounds="True">
<Grid>
<TextBlock x:Name="PART_AdIconFallback"
Text="&#xE8D6;"
FontFamily="Segoe MDL2 Assets"
FontSize="20"
Foreground="#818CF8"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Image x:Name="PART_AdImage"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
</Border>
<!-- 텍스트 정보 영역 -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Cursor="Hand" MouseLeftButtonUp="OnCardClicked">
<StackPanel Orientation="Horizontal" Margin="0,0,0,3">
<Border CornerRadius="3" Background="#312E81" Padding="5,1" Margin="0,0,6,0">
<TextBlock x:Name="PART_BadgeText"
Text="AD"
FontSize="9.5"
FontWeight="Bold"
Foreground="#A5B4FC"
VerticalAlignment="Center"/>
</Border>
<TextBlock x:Name="PART_AdvertiserText"
Text="삼성전자 (Samsung)"
FontSize="11"
FontWeight="Medium"
Foreground="#94A3B8"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock x:Name="PART_TitleText"
Text="Galaxy S24 Ultra with Galaxy AI"
FontSize="12"
FontWeight="SemiBold"
Foreground="#F8FAFC"
TextTrimming="CharacterEllipsis"
Margin="0,0,0,2"/>
<TextBlock x:Name="PART_DescText"
Text="실시간 통역부터 서클 투 서치까지, 새로운 모바일 AI 시대를 만나보세요."
FontSize="10.5"
Foreground="#64748B"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<!-- 닫기 버튼 (32x32px 최소 타깃 및 스크린리더 명명) -->
<Button x:Name="PART_DismissBtn"
Grid.Column="2"
Width="32"
Height="32"
VerticalAlignment="Top"
Background="Transparent"
Foreground="#94A3B8"
BorderThickness="0"
FontSize="13"
Cursor="Hand"
AutomationProperties.Name="광고 닫기"
Click="OnDismissClicked">
<Button.Resources>
<Style TargetType="Border">
<Setter Property="CornerRadius" Value="16"/>
</Style>
</Button.Resources>
<TextBlock Text="✕" VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Button>
</Grid>
<!-- 하단: 전폭 CTA 액션 버튼 (32x32 이상 및 피츠의 법칙/WCAG 2.2 충족) -->
<Button x:Name="PART_CtaBtn"
Grid.Row="1"
Height="32"
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
Background="#6366F1"
Foreground="#FFFFFF"
BorderThickness="0"
FontSize="11.5"
FontWeight="SemiBold"
Cursor="Hand"
AutomationProperties.Name="스폰서 바로가기"
Click="OnCtaClicked">
<Button.Resources>
<Style TargetType="Border">
<Setter Property="CornerRadius" Value="6"/>
</Style>
</Button.Resources>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock x:Name="PART_CtaText" Text="구매 혜택 보기" VerticalAlignment="Center"/>
<TextBlock Text=" ↗" FontSize="11" VerticalAlignment="Center" Margin="3,0,0,0"/>
</StackPanel>
</Button>
</Grid>
</Grid>
</Border>
</UserControl>

View file

@ -0,0 +1,549 @@
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Microsoft.Web.WebView2.Core;
using Paca.Browser.Ads;
using Wv2 = Microsoft.Web.WebView2.Wpf;
namespace Paca.App.Controls;
/// <summary>
/// 데스크톱 브라우저 및 다운로드 뷰용 네이티브/실시간 웹 배너 컨트롤 (Designpaca 스위스 미니멀리즘)
/// </summary>
public partial class DesktopAdBannerControl : UserControl
{
private DesktopAdData? _adData;
private DesktopAdPlacement _placement;
private CoreWebView2Environment? _env;
private Wv2.WebView2? _webView;
private bool _isInitializing;
private readonly System.Threading.SemaphoreSlim _captureLock = new(1, 1);
public DesktopAdPlacement Placement
{
get => _placement;
set
{
_placement = value;
ApplyPlacementSizing();
}
}
public DesktopAdBannerControl() : this(DesktopAdPlacement.DownloadViewBanner)
{
}
private static readonly object s_initLock = new();
public DesktopAdBannerControl(DesktopAdPlacement placement)
{
_placement = placement;
lock (s_initLock)
{
InitializeComponent();
}
Loaded += OnLoaded;
}
private void ApplyPlacementSizing()
{
if (PART_WebViewContainer == null || PART_RootBorder == null) return;
PART_RootBorder.Padding = new Thickness(0);
PART_RootBorder.Background = System.Windows.Media.Brushes.Transparent;
PART_RootBorder.BorderThickness = new Thickness(0);
if (_placement == DesktopAdPlacement.BottomAppBanner)
{
PART_WebViewContainer.Width = 728;
PART_WebViewContainer.Height = 90;
PART_WebViewContainer.MinHeight = 90;
PART_RootBorder.Margin = new Thickness(0, 4, 0, 4);
Width = 728;
Height = 98;
}
else
{
PART_WebViewContainer.Width = 300;
PART_WebViewContainer.Height = 250;
PART_WebViewContainer.MinHeight = 250;
PART_RootBorder.Margin = new Thickness(0, 4, 0, 4);
Width = 300;
Height = 258;
}
}
private void LogAd(string msg)
{
try
{
var p = System.IO.Path.Combine(Paca.App.AppPaths.DataDir, "ad_debug.log");
System.IO.File.AppendAllText(p, $"[{DateTime.Now:HH:mm:ss.fff}] [{_placement}] {msg}\n");
}
catch { }
}
/// <summary>
/// 메인 윈도우의 공유 WebView2 환경을 주입하여 실제 구글 배너 광고 렌더링을 시작한다.
/// </summary>
public void AttachEnvironment(CoreWebView2Environment? env)
{
LogAd($"AttachEnvironment called. Env != null: {env != null}");
_env = env;
if (_adData == null)
{
_adData = DesktopAdManager.Instance.GetAdForPlacement(_placement);
}
TryLoadRealAdAsync();
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
LogAd("OnLoaded called");
RefreshAd();
}
public void RefreshAd()
{
_adData = DesktopAdManager.Instance.GetAdForPlacement(_placement);
if (_adData == null || !_adData.IsVisible)
{
Visibility = Visibility.Collapsed;
return;
}
Visibility = Visibility.Visible;
ApplyPlacementSizing();
PART_BadgeText.Text = _adData.Badge;
PART_TitleText.Text = _adData.Title;
PART_AdvertiserText.Text = _adData.Advertiser;
PART_DescText.Text = _adData.Description;
PART_CtaText.Text = _adData.CtaText;
PART_DismissBtn.Visibility = _adData.IsDismissible ? Visibility.Visible : Visibility.Collapsed;
if (!string.IsNullOrWhiteSpace(_adData.IconOrImageUrl))
{
try
{
var bitmap = new System.Windows.Media.Imaging.BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(_adData.IconOrImageUrl, UriKind.Absolute);
bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bitmap.EndInit();
PART_AdImage.Source = bitmap;
PART_AdImage.Visibility = Visibility.Visible;
PART_AdIconFallback.Visibility = Visibility.Collapsed;
}
catch
{
PART_AdImage.Visibility = Visibility.Collapsed;
PART_AdIconFallback.Visibility = Visibility.Visible;
}
}
else
{
PART_AdImage.Visibility = Visibility.Collapsed;
PART_AdIconFallback.Visibility = Visibility.Visible;
}
// 노출(Impression) 트래킹
DesktopAdManager.Instance.TrackImpression(_adData.Id);
if (_adData.IsRealAd)
{
TryLoadRealAdAsync();
}
}
private static Microsoft.Web.WebView2.Core.CoreWebView2Environment? _adEnvironment;
private static async System.Threading.Tasks.Task<Microsoft.Web.WebView2.Core.CoreWebView2Environment?> GetOrCreateAdEnvironmentAsync()
{
if (_adEnvironment != null) return _adEnvironment;
try
{
var adDataFolder = System.IO.Path.Combine(Paca.App.AppPaths.DataDir, "ad_profile");
System.IO.Directory.CreateDirectory(adDataFolder);
var options = new Microsoft.Web.WebView2.Core.CoreWebView2EnvironmentOptions
{
AreBrowserExtensionsEnabled = false,
AdditionalBrowserArguments = "--disable-extensions --allow-file-access-from-files"
};
_adEnvironment = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
browserExecutableFolder: null,
userDataFolder: adDataFolder,
options: options);
return _adEnvironment;
}
catch
{
return null;
}
}
private async void TryLoadRealAdAsync()
{
if (_isInitializing) return;
if (_adData == null)
{
_adData = DesktopAdManager.Instance.GetAdForPlacement(_placement);
}
if (_adData == null || !_adData.IsVisible) return;
_isInitializing = true;
try
{
if (_webView == null)
{
_webView = new Wv2.WebView2
{
DefaultBackgroundColor = System.Drawing.Color.FromArgb(30, 32, 40)
};
PART_WebViewContainer.Child = _webView;
}
if (_webView.CoreWebView2 == null)
{
var isolatedEnv = await GetOrCreateAdEnvironmentAsync().ConfigureAwait(true);
var env = isolatedEnv ?? _env ?? MainWindow.SharedEnvironment;
if (env != null)
{
LogAd($"EnsureCoreWebView2Async starting (isolated={isolatedEnv != null})...");
await _webView.EnsureCoreWebView2Async(env);
LogAd("EnsureCoreWebView2Async completed!");
}
else
{
LogAd("EnsureCoreWebView2Async aborted: env is null");
return;
}
}
_webView.NavigationCompleted -= OnNavigationCompleted;
_webView.NavigationCompleted += OnNavigationCompleted;
_webView.WebMessageReceived -= OnWebMessageReceived;
_webView.WebMessageReceived += OnWebMessageReceived;
if (_webView.CoreWebView2 != null)
{
_webView.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
_webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
}
var isBottom = _placement == DesktopAdPlacement.BottomAppBanner;
var adWidth = isBottom ? 728 : 300;
var adHeight = isBottom ? 90 : 250;
var divId = isBottom ? "div-gpt-ad-bottom" : "div-gpt-ad-banner";
if (_webView != null)
{
_webView.Width = adWidth;
_webView.Height = adHeight;
_webView.HorizontalAlignment = HorizontalAlignment.Center;
_webView.VerticalAlignment = VerticalAlignment.Center;
}
var slotId = !string.IsNullOrWhiteSpace(_adData.AdUnitId)
? _adData.AdUnitId
: (isBottom ? DesktopAdManager.Instance.Config.EffectiveNtpBannerUnitId : DesktopAdManager.Instance.Config.EffectiveDownloadBannerUnitId);
var snippet = _adData.HtmlEmbedSnippet ??
DesktopAdConfig.GenerateGoogleTagSnippet(slotId, adWidth, adHeight, divId);
var pageHtml = GenerateRealAdHtml(_adData, _placement, snippet, adWidth, adHeight);
var adDir = System.IO.Path.Combine(Paca.App.AppPaths.DataDir, "ad_html");
System.IO.Directory.CreateDirectory(adDir);
var tempAdHtmlPath = System.IO.Path.Combine(adDir, $"ad_{_placement}.html");
System.IO.File.WriteAllText(tempAdHtmlPath, pageHtml, new System.Text.UTF8Encoding(true));
// Google 크롤러 및 퍼블리셔 인증용 ads.txt 및 app-ads.txt를 가상 호스트 디렉터리에도 동기화
const string adsTxtLine = "google.com, pub-1039714767792854, DIRECT, f08c47fec0942fa0\n";
try
{
var localAdsTxt = System.IO.Path.Combine(adDir, "ads.txt");
var localAppAdsTxt = System.IO.Path.Combine(adDir, "app-ads.txt");
if (!System.IO.File.Exists(localAdsTxt)) System.IO.File.WriteAllText(localAdsTxt, adsTxtLine, new System.Text.UTF8Encoding(false));
if (!System.IO.File.Exists(localAppAdsTxt)) System.IO.File.WriteAllText(localAppAdsTxt, adsTxtLine, new System.Text.UTF8Encoding(false));
}
catch { }
ApplyPlacementSizing();
PART_RealAdHost.Visibility = Visibility.Visible;
PART_NativeCard.Visibility = Visibility.Collapsed;
var cacheBuster = DateTime.UtcNow.Ticks;
var virtualUrl = $"https://ads.paca.cloud/ad_{_placement}.html?v={cacheBuster}";
var fileUrl = new Uri(tempAdHtmlPath).AbsoluteUri + $"?v={cacheBuster}";
string targetUrl = virtualUrl;
try
{
_webView?.CoreWebView2?.SetVirtualHostNameToFolderMapping(
"ads.paca.cloud",
adDir,
Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
}
catch (Exception ex)
{
LogAd($"SetVirtualHostNameToFolderMapping warning: {ex.Message}, fallback to fileUrl");
targetUrl = fileUrl;
}
LogAd($"Loading real Google ad: {targetUrl} (file fallback: {fileUrl})");
try
{
_webView?.CoreWebView2?.Navigate(targetUrl);
}
catch (Exception ex)
{
LogAd($"Navigate target failed ({ex.Message}), trying fileUrl fallback...");
try
{
_webView?.CoreWebView2?.Navigate(fileUrl);
}
catch
{
_webView?.CoreWebView2?.NavigateToString(pageHtml);
}
}
}
catch (Exception ex)
{
LogAd($"TryLoadRealAdAsync EXCEPTION: {ex}");
PART_RealAdHost.Visibility = Visibility.Collapsed;
PART_NativeCard.Visibility = Visibility.Visible;
}
finally
{
_isInitializing = false;
}
}
private static string GenerateRealAdHtml(DesktopAdData ad, DesktopAdPlacement placement, string snippet, int width, int height)
{
return $@"<!DOCTYPE html>
<html lang=""ko"">
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width={width}, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"">
<meta name=""referrer"" content=""always"">
<meta name=""google-adsense-account"" content=""ca-pub-1039714767792854"">
<link rel=""canonical"" href=""https://ads.paca.cloud/ad_{placement}.html"">
<title>PACA Google Ad</title>
<script>
(function() {{
var send = function(level, args) {{
if (window.chrome && window.chrome.webview) {{
try {{
var str = Array.from(args).map(function(a) {{ return typeof a === 'object' ? JSON.stringify(a) : String(a); }}).join(' ');
window.chrome.webview.postMessage(JSON.stringify({{ type: 'JS_CONSOLE', level: level, msg: str }}));
}} catch(_) {{}}
}}
}};
var oldLog = console.log, oldWarn = console.warn, oldErr = console.error;
console.log = function() {{ send('LOG', arguments); oldLog.apply(console, arguments); }};
console.warn = function() {{ send('WARN', arguments); oldWarn.apply(console, arguments); }};
console.error = function() {{ send('ERROR', arguments); oldErr.apply(console, arguments); }};
window.onerror = function(msg, url, line) {{ send('ERROR', [msg, url, line]); }};
}})();
</script>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; user-select: none; }}
html, body {{
background: #181A22;
color: #F8FAFC;
width: {width}px;
height: {height}px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}}
.ad-google-wrapper {{
width: {width}px;
height: {height}px;
min-width: 320px;
margin-left: auto;
margin-right: auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}}
</style>
</head>
<body>
<div class=""ad-google-wrapper"">
{snippet}
</div>
<script>
window.addEventListener('message', function(e) {{
if (window.chrome && window.chrome.webview) {{
try {{
var data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
window.chrome.webview.postMessage(JSON.stringify(data));
}} catch(_) {{}}
}}
}});
</script>
</body>
</html>";
}
private async void OnNavigationCompleted(object? sender, Microsoft.Web.WebView2.Core.CoreWebView2NavigationCompletedEventArgs e)
{
LogAd($"OnNavigationCompleted: IsSuccess={e.IsSuccess}, WebErrorStatus={e.WebErrorStatus}, HttpStatusCode={e.HttpStatusCode}");
if (e.IsSuccess)
{
try
{
var previewsDir = System.IO.Path.Combine(Paca.App.AppPaths.DataDir, "ad_previews");
var previewFile = System.IO.Path.Combine(previewsDir, $"ad_{_placement}.png");
// 1차: 기본 DOM 준비 대기
await System.Threading.Tasks.Task.Delay(1000);
await CaptureAdPreviewAsync(previewFile);
// 2차: Google 원격 실시간 광고(gpt.js/doubleclick) 네트워크 다운로드 및 iframe 렌더링 완료 대기 후 정밀 캡처
await System.Threading.Tasks.Task.Delay(3000);
await CaptureAdPreviewAsync(previewFile);
}
catch { }
}
}
/// <summary>
/// WebView2의 실제 렌더링 화면 비트맵을 직접 캡처하여 PNG 파일로 저장한다.
/// </summary>
public async System.Threading.Tasks.Task<bool> CaptureAdPreviewAsync(string outputPath)
{
if (_webView?.CoreWebView2 == null) return false;
await _captureLock.WaitAsync();
try
{
var dir = System.IO.Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir);
using var stream = System.IO.File.Create(outputPath);
await _webView.CoreWebView2.CapturePreviewAsync(Microsoft.Web.WebView2.Core.CoreWebView2CapturePreviewImageFormat.Png, stream);
LogAd($"CaptureAdPreviewAsync saved: {outputPath}");
return System.IO.File.Exists(outputPath) && new System.IO.FileInfo(outputPath).Length > 0;
}
catch (Exception ex)
{
LogAd($"CaptureAdPreviewAsync EXCEPTION: {ex}");
return false;
}
finally
{
_captureLock.Release();
}
}
private void OnWebMessageReceived(object? sender, Microsoft.Web.WebView2.Core.CoreWebView2WebMessageReceivedEventArgs e)
{
try
{
var msg = e.TryGetWebMessageAsString();
if (string.IsNullOrWhiteSpace(msg)) return;
LogAd($"OnWebMessageReceived: {msg}");
using var doc = System.Text.Json.JsonDocument.Parse(msg);
if (doc.RootElement.TryGetProperty("type", out var typeProp))
{
var type = typeProp.GetString();
if (type == "JS_CONSOLE")
{
var level = doc.RootElement.TryGetProperty("level", out var l) ? l.GetString() : "LOG";
var text = doc.RootElement.TryGetProperty("msg", out var m) ? m.GetString() : "";
LogAd($"[Browser {level}] {text}");
return;
}
if (type == "AD_RENDERED")
{
LogAd($"AD_RENDERED event received from Google ad slot!");
_ = System.Threading.Tasks.Task.Run(async () =>
{
await System.Threading.Tasks.Task.Delay(500);
var previewsDir = System.IO.Path.Combine(Paca.App.AppPaths.DataDir, "ad_previews");
var previewFile = System.IO.Path.Combine(previewsDir, $"ad_{_placement}.png");
await Dispatcher.InvokeAsync(() => CaptureAdPreviewAsync(previewFile));
});
return;
}
if (type == "AD_CLICK")
{
Dispatcher.Invoke(() => HandleAction());
return;
}
if (type == "AD_DISMISS")
{
Dispatcher.Invoke(() =>
{
if (_adData != null) DesktopAdManager.Instance.DismissAd(_adData.Id);
Visibility = Visibility.Collapsed;
});
return;
}
}
Dispatcher.Invoke(() =>
{
PART_RealAdHost.Visibility = Visibility.Visible;
PART_NativeCard.Visibility = Visibility.Collapsed;
});
}
catch (Exception ex)
{
LogAd($"OnWebMessageReceived EXCEPTION: {ex}");
}
}
public void OnCtaClicked(object sender, RoutedEventArgs e)
{
HandleAction();
}
public void OnCardClicked(object sender, MouseButtonEventArgs e)
{
HandleAction();
}
private void HandleAction()
{
if (_adData == null) return;
DesktopAdManager.Instance.TrackClick(_adData.Id);
if (!string.IsNullOrWhiteSpace(_adData.TargetUrl))
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = _adData.TargetUrl,
UseShellExecute = true
});
}
catch
{
// 실패 방어
}
}
}
public void OnDismissClicked(object sender, RoutedEventArgs e)
{
if (_adData == null) return;
DesktopAdManager.Instance.DismissAd(_adData.Id);
Visibility = Visibility.Collapsed;
}
}

196
src/Paca.App/Converters.cs Normal file
View file

@ -0,0 +1,196 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace Paca.App;
/// <summary>컬렉션 카운트 > 0 일 때 Visible, 0일 때 Collapsed</summary>
public class CountToVisibilityConverter : IValueConverter
{
public static readonly CountToVisibilityConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> (value is int i && i > 0) ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> Binding.DoNothing;
}
/// <summary>컬렉션 카운트 → 빈 상태 표시용 (0일 때 Visible)</summary>
public class InverseCountToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> (value is int i && i > 0) ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> Binding.DoNothing;
}
/// <summary>다운로드 상태 문자열 → 표시 여부. parameter="done" 이면 완료일 때만 Visible(UC-A11 완료 아이콘).</summary>
public class StatusToVisibilityConverter : IValueConverter
{
public static readonly StatusToVisibilityConverter Done = new();
public static readonly StatusToVisibilityConverter NotDone = new() { Invert = true };
public bool Invert { get; set; }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
bool isDone = value is string s && s == "완료";
if (Invert) isDone = !isDone;
return isDone ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> Binding.DoNothing;
}
/// <summary>UC-D10 세그먼트 진행도(0~1) → 블록 배경색(진행 중 강조/대기 트랙).</summary>
public class SegmentFillConverter : IValueConverter
{
public static readonly SegmentFillConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is double d && d >= 1.0
? (System.Windows.Application.Current?.TryFindResource("SuccessBrush") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.DodgerBlue)
: value is double p && p > 0
? (System.Windows.Application.Current?.TryFindResource("AccentBrush") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.MediumSlateBlue)
: (System.Windows.Application.Current?.TryFindResource("TrackBrush") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.DimGray);
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> Binding.DoNothing;
}
/// <summary>bool 반전 → Visibility (false 일 때 Visible)</summary>
public class InverseBooleanToVisibilityConverter : IValueConverter
{
public static readonly InverseBooleanToVisibilityConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> (value is bool b && b) ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> value is Visibility v && v == Visibility.Collapsed;
}
/// <summary>bool 반전 (true -> false, false -> true)</summary>
public class InverseBoolConverter : IValueConverter
{
public static readonly InverseBoolConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b ? !b : false;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b ? !b : false;
}
/// <summary>
/// 프로필 아바타 아이콘(Emoji vs PUA 폰트 글리프)의 베이스라인 시각 정렬 보정 헬퍼.
/// Lucide/Fluent 등 PUA 글리프(\uE000~\uF8FF)는 폰트 자체적으로 중앙 정렬되어 있으나,
/// Segoe UI Emoji 등 이모지 및 일반 문자는 폰트 베이스라인 어센더로 인해 시각적으로 아래로 쏠림.
/// 상하 대칭 마진 (0, -shift, 0, shift)를 적용하여 0px 오차로 완벽하게 중앙 정렬한다.
/// </summary>
public static class ProfileIconHelper
{
public static bool IsPrivateUseGlyph(string? text)
{
if (string.IsNullOrEmpty(text)) return false;
// PUA(Private Use Area): \uE000 ~ \uF8FF
return text.Length == 1 && text[0] >= 0xE000 && text[0] <= 0xF8FF;
}
public static Thickness GetMargin(string? icon, double fontSize = 12)
{
if (IsPrivateUseGlyph(icon))
{
return new Thickness(0);
}
// 이모지 및 텍스트 글리프: 폰트 크기별 최적 시각 보정 (-shift, shift 대칭 마진)
// FontSize 12/14 (24px, 26px, 28px 뱃지): shift = 2 -> diffY = 0px
// FontSize 16+ (36px 뱃지): shift = 2 -> diffY = 1px (홀수 픽셀 높이 최적값)
int shift = 2;
return new Thickness(0, -shift, 0, shift);
}
}
/// <summary>
/// 바인딩용 프로필 아이콘 마진 변환기 (OtherProfilesList 등 DataTemplate에서 사용)
/// ConverterParameter로 fontSize(기본값 12) 전달 가능.
/// </summary>
public class ProfileIconMarginConverter : IValueConverter
{
public static readonly ProfileIconMarginConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var icon = value as string;
double fontSize = 12;
if (parameter != null && double.TryParse(parameter.ToString(), out var parsedSize))
{
fontSize = parsedSize;
}
return ProfileIconHelper.GetMargin(icon, fontSize);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> Binding.DoNothing;
}
/// <summary>
/// BrowserProfile 또는 AvatarImagePath를 받아 유효한 ImageBrush 또는 단색 Background Brush를 반환하는 변환기
/// </summary>
public class ProfileAvatarBrushConverter : IValueConverter
{
public static readonly ProfileAvatarBrushConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is Paca.Browser.Profiles.BrowserProfile profile)
{
if (!string.IsNullOrEmpty(profile.AvatarImagePath))
{
var brush = ProfileAvatarHelper.CreateAvatarBrush(profile.AvatarImagePath);
if (brush != null) return brush;
}
try
{
return (SolidColorBrush)new BrushConverter().ConvertFromString(profile.Color ?? "#4F46E5")!;
}
catch
{
return Brushes.Indigo;
}
}
return Brushes.Transparent;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> Binding.DoNothing;
}
/// <summary>
/// AvatarImagePath가 있으면 텍스트 숨김(Collapsed), 없으면 표시(Visible)하는 변환기
/// </summary>
public class ProfileAvatarTextVisibilityConverter : IValueConverter
{
public static readonly ProfileAvatarTextVisibilityConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var imagePath = value as string;
if (!string.IsNullOrEmpty(imagePath) && (File.Exists(imagePath) || imagePath.StartsWith("pack://", StringComparison.OrdinalIgnoreCase)))
{
return Visibility.Collapsed;
}
return Visibility.Visible;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> Binding.DoNothing;
}

View file

@ -0,0 +1,51 @@
using System.Text.Json;
using Paca.Core.Config;
namespace Paca.App.Core;
/// <summary>%AppData%/VideoDownloader/config.json 으로 설정 영속화</summary>
public static class ConfigStore
{
private static string Dir => AppPaths.DataDir;
private static string FilePath => Path.Combine(Dir, "config.json");
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
public static AppConfig Load()
{
try
{
if (File.Exists(FilePath))
{
var text = File.ReadAllText(FilePath, System.Text.Encoding.UTF8);
var config = JsonSerializer.Deserialize<AppConfig>(text);
if (config != null)
{
bool wasCorrupted = config.WorkspaceNames == null || config.WorkspaceNames.Any(WorkspaceConfigSanitizer.IsCorrupted);
config.Sanitize();
if (wasCorrupted)
{
// 디스크의 손상된 설정 파일 자동 치유 및 복구
Save(config);
}
return config;
}
}
}
catch { /* 손상 시 기본값 */ }
var fresh = new AppConfig();
fresh.Sanitize();
return fresh;
}
public static void Save(AppConfig config)
{
try
{
config.Sanitize();
Directory.CreateDirectory(Dir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(config, JsonOpts), new System.Text.UTF8Encoding(false));
}
catch { /* 저장 실패는 무시 */ }
}
}

View file

@ -0,0 +1,723 @@
using System.Collections.ObjectModel;
using System.Net;
using System.Net.Http;
using System.Windows.Data;
using Microsoft.Web.WebView2.Core;
using Paca.Core.Detection;
using Paca.App.ViewModels;
using Paca.Core.Config;
using Paca.Core.Dash;
using Paca.Core.Hls;
using Paca.Core.Models;
using Paca.Core.Social;
using Paca.Core.Util;
namespace Paca.App.Core;
/// <summary>감지 → (HLS: 마스터해석) → 목록(대기) 등록. 다운로드는 사용자가 선택 실행.</summary>
public class DownloadManager
{
private readonly AppConfig _config;
public AppConfig Config => _config;
private readonly Func<Uri, Task<List<CoreWebView2Cookie>>> _getCookies;
private readonly Action<string> _log;
private readonly object _itemsLock = new();
private readonly HashSet<string> _seenMedia = new();
private readonly object _seenLock = new();
private readonly string _reqLogPath;
private readonly object _fileLock = new();
// #5/#19 동시 다운로드(항목) 제한 — 런타임 cap 변경 지원(동적 게이트).
// 증가는 즉시 Release, 감소는 진행 중 항목이 완료될 때 슬롯을 채우지 않아 자연 수축.
private readonly SemaphoreSlim _slot;
private int _cap;
private int _held;
private readonly object _gateLock = new();
// #11 공유 커넥션 풀 — 호출마다 HttpClientHandler 를 new 하지 않아 소켓 고갈 방지.
// 쿠키는 컨테이너(프로세스 수준 누적·타사이트 누출) 대신 요청(다운로드) 단위 헤더로 스코프.
private static readonly SocketsHttpHandler SharedHandler = new()
{
UseCookies = false,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
AllowAutoRedirect = true,
MaxConnectionsPerServer = 32,
};
public ObservableCollection<DownloadItemViewModel> Items { get; }
/// <summary>진행 중인 다운로드 건수 (대기 중·다운로드 중·병합 중·리먹스 중 등 포함, 단순 감지 대기 제외).</summary>
public int ActiveDownloadsCount => Items.Count(i => i.IsActiveDownload);
/// <summary>배터리 세이버 및 전력 관리 매니저 연동.</summary>
public Paca.Core.Platform.BatterySaverPowerManager PowerManager { get; } = new();
/// <summary>새 미디어 감지됨(스마트 감지 플로팅 칩·배너용).</summary>
public event Action<DownloadItemViewModel>? ItemDetected;
/// <summary>다운로드 완료(§J 토스트 알림용).</summary>
public event Action<DownloadItemViewModel>? ItemCompleted;
/// <summary>다운로드 실패(§J 토스트·배너용).</summary>
public event Action<DownloadItemViewModel>? ItemFailed;
/// <summary>UC-D21 예약 게이트 — true 를 돌려줘야 다운로드가 시작된다(DownloadScheduler 연결).</summary>
public Func<bool>? DownloadGate { get; set; }
// ---- §E 속도/ETA 샘플러(항목별 최근 샘플) ----
private readonly Dictionary<string, (long Bytes, DateTime At)> _speedSamples = new();
private void ReportSpeed(DownloadItemViewModel item, DownloadProgress p)
{
if (p.ReceivedBytes < 0) return;
var now = DateTime.UtcNow;
if (_speedSamples.TryGetValue(item.Id, out var prev) && (now - prev.At).TotalMilliseconds < 400) return;
double bps = 0;
if (prev.Bytes > 0 && now > prev.At)
bps = Math.Max(0, (p.ReceivedBytes - prev.Bytes) / (now - prev.At).TotalSeconds);
_speedSamples[item.Id] = (p.ReceivedBytes, now);
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
item.UpdateSpeedEta(p.ReceivedBytes, p.TotalBytes, bps));
}
/// <summary>§E 세그먼트 맵 갱신 — 완료 세그먼트 수만큼 앞칸을 채운다.</summary>
private static void ReportSegmentMap(DownloadItemViewModel item, DownloadProgress p)
{
if (p.Total <= 0 || p.Stage != "downloading") return;
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
{
if (item.SegmentMap.Count != p.Total)
{
item.SegmentMap.Clear();
for (var i = 0; i < p.Total; i++) item.SegmentMap.Add(0);
}
for (var i = 0; i < Math.Min(p.Completed, item.SegmentMap.Count); i++)
if (item.SegmentMap[i] < 1.0) item.SegmentMap[i] = 1.0;
});
}
private readonly Services.ThumbnailCacheService _thumbnailCache;
public Services.ThumbnailCacheService ThumbnailCache => _thumbnailCache;
private void RaiseCompleted(DownloadItemViewModel item, string finalPath)
{
item.FinalFileName = Path.GetFileName(finalPath); // UC-D26 최종 저장 파일명
item.CompletedAt = DateTime.UtcNow; // UC-D08 자동 치움 판정용
ItemCompleted?.Invoke(item);
if (string.IsNullOrWhiteSpace(item.ThumbnailUrl) || item.ThumbnailUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
_ = Task.Run(async () =>
{
try
{
var thumb = await _thumbnailCache.EnsureThumbnailAsync(null, finalPath, null);
if (!string.IsNullOrWhiteSpace(thumb))
{
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
{
item.ThumbnailUrl = thumb;
});
}
}
catch { }
});
}
}
public DownloadManager(AppConfig config, Func<Uri, Task<List<CoreWebView2Cookie>>> getCookies, Action<string> log)
{
_config = config;
_thumbnailCache = new Services.ThumbnailCacheService(config);
_getCookies = getCookies;
_log = log;
_cap = Math.Max(1, config.MaxConcurrentDownloads);
_slot = new SemaphoreSlim(_cap);
Items = new ObservableCollection<DownloadItemViewModel>();
BindingOperations.EnableCollectionSynchronization(Items, _itemsLock);
var metaDir = Path.Combine(config.OutputFolder, ".paca");
try
{
var di = Directory.CreateDirectory(metaDir);
di.Attributes |= FileAttributes.Hidden;
}
catch { }
_reqLogPath = Path.Combine(metaDir, "requests.log");
try { File.WriteAllText(_reqLogPath, ""); } catch { }
// UC-S13/D22 프록시 설정 — 공유 핸들러는 첫 요청 후 변경 불가(SocketsHttpHandler 제약).
// 프로세스 최초 1회만 반영하고, 해제는 앱 재시작으로 적용한다(설정창 RestartBanner 안내).
if (!string.IsNullOrWhiteSpace(config.ProxyServer) && Uri.TryCreate(config.ProxyServer, UriKind.Absolute, out var proxyUri))
{
lock (_gateLock)
{
if (System.Threading.Interlocked.Exchange(ref _proxyApplied, 1) == 0)
{
SharedHandler.Proxy = new System.Net.WebProxy(proxyUri);
SharedHandler.UseProxy = true;
}
}
}
}
private static int _proxyApplied;
public void AppendAll(string line)
{
try
{
lock (_fileLock)
{
// #8 로테이션 (10MB 초과 시 .1 로 밀고 truncate)
try
{
var fi = new FileInfo(_reqLogPath);
if (fi.Exists && fi.Length > 10 * 1024 * 1024)
{
var bak = _reqLogPath + ".1";
if (File.Exists(bak)) File.Delete(bak);
File.Move(_reqLogPath, bak);
}
}
catch { }
File.AppendAllText(_reqLogPath, $"{DateTime.Now:HH:mm:ss} {line}{Environment.NewLine}");
}
}
catch { }
}
public void LogPanel(string line) => _log(line);
public void OnDetected(DetectionContext ctx) => _ = HandleAsync(ctx);
private async Task HandleAsync(DetectionContext ctx)
{
try
{
var detectedUrl = new Uri(ctx.Url);
_log($"[감지] {ctx.Kind} | {ctx.Url}");
// 즉시 중복체크 + 등록 → 목록에 바로 표시 (HLS 해석/쿠키 fetch 를 기다리지 않음).
// HLS 는 HlsDownloader 가 다운로드 시 마스터→미디어 를 직접 해석하므로 여기서 미리 해석할 필요 없음.
lock (_seenLock)
if (!_seenMedia.Add(ctx.Kind + "|" + detectedUrl)) { _log($"[중복 감지] {detectedUrl}"); return; }
var title = !string.IsNullOrWhiteSpace(ctx.Title)
? (ctx.Kind == MediaKind.Social ? CleanSocialTitle(ctx.Title!) : ctx.Title!.Trim())
: FileNameUtil.DeriveSmartTitle(null, detectedUrl, ctx.MediaIndex);
var outputBase = MakeUnique(Path.Combine(_config.OutputFolder, FileNameUtil.Sanitize(title)));
var fmt = ctx.Kind switch
{
MediaKind.Hls => "HLS",
MediaKind.Dash => "DASH",
MediaKind.Social => string.IsNullOrWhiteSpace(ctx.Tag) ? "SOCIAL" : ctx.Tag!,
_ => MediaDetector.MatchFormat(ctx.Url, ctx.ContentType ?? "")?.ToUpperInvariant() ?? "FILE",
};
var item = new DownloadItemViewModel(title)
{
Kind = ctx.Kind,
FormatTag = fmt,
MediaUrl = detectedUrl,
OutputBase = outputBase,
PageUrl = ctx.PageUrl,
ContentType = ctx.ContentType,
ThumbnailUrl = ctx.ThumbnailUrl,
};
// 백그라운드 섬네일 다운로드 및 로컬 캐싱 (403 방지 및 FFmpeg 프레임 추출)
_ = Task.Run(async () =>
{
try
{
var cached = await _thumbnailCache.EnsureThumbnailAsync(ctx.ThumbnailUrl, ctx.Url, ctx.PageUrl);
if (!string.IsNullOrWhiteSpace(cached))
{
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
{
item.ThumbnailUrl = cached;
});
}
}
catch { }
});
// 소셜 카드: 화질 프리셋 즉시 채우기(정밀 조회는 카드의 "불러오기" 버튼으로 별도)
if (ctx.Kind == MediaKind.Social)
{
foreach (var p in SocialPresets.All) item.Formats.Add(p);
item.SelectedFormat = SocialPresets.DefaultFor(_config.DefaultSocialQuality);
}
lock (_itemsLock) Items.Insert(0, item);
_log($"[등록] {title} | {detectedUrl}");
try { ItemDetected?.Invoke(item); } catch { }
// HLS: 백그라운드에서 마스터를 한 번 받아 모든 변환(미디어 재생목록) URL 을 seen 으로 마킹 →
// 브라우저가 이후 해당 미디어 재생목록을 요청해도 중복 등록되지 않게.
if (ctx.Kind == MediaKind.Hls)
_ = MarkVariantsSeenAsync(detectedUrl, ctx.PageUrl);
}
catch (Exception e) { _log($"[오류] {ctx.Url} :: {e}"); }
}
private async Task MarkVariantsSeenAsync(Uri masterUrl, string? referer)
{
try
{
var http = await BuildHttpClientAsync(masterUrl, referer);
var parsed = M3u8Parser.Parse(await http.GetStringAsync(masterUrl), masterUrl);
if (parsed.Type == PlaylistType.Master)
{
lock (_seenLock)
foreach (var v in parsed.Variants)
_seenMedia.Add(MediaKind.Hls + "|" + v.Url);
}
}
catch { }
}
public async Task StartDownload(DownloadItemViewModel item)
{
if (item.IsActiveDownload) return;
item.Started = true;
item.Error = null;
item.Status = "대기 중";
_log($"[다운로드 요청] {item.Title} | {item.MediaUrl}");
_ = RunWithSlotAsync(item);
}
// #5 다운로드 슬롯(동시수) 제한 하에 실행. fire-and-forget 이되 예외 관찰.
// #19 설정 변경 시 동시 항목 수를 런타임에 반영.
public void UpdateMaxConcurrent(int newMax)
{
newMax = Math.Max(1, newMax);
lock (_gateLock)
{
int delta = newMax - _cap;
_cap = newMax;
if (delta > 0) _slot.Release(delta);
}
}
// #5 다운로드 슬롯(동시수) 제한 하에 실행. fire-and-forget 이되 예외 관찰.
private async Task RunWithSlotAsync(DownloadItemViewModel item)
{
await _slot.WaitAsync();
lock (_gateLock) _held++;
HttpClient? http = null;
try
{
_log($"[다운로드 슬롯 진입] {item.Title} | {item.MediaUrl}");
// UC-D21 예약 창 밖이면 창이 열릴 때까지 대기(30초 폴링, 취소 가능)
while (DownloadGate != null && !DownloadGate())
{
item.Status = "예약 대기";
try { await Task.Delay(TimeSpan.FromSeconds(30), item.Cts?.Token ?? CancellationToken.None); }
catch (OperationCanceledException) { }
if (item.Cts?.IsCancellationRequested == true) { item.Status = "취소됨"; return; }
}
if (item.Status == "예약 대기") item.Status = "다운로드 중";
http = await BuildHttpClientAsync(item.MediaUrl, item.PageUrl);
_log($"[다운로드 엔진 시작] {item.Title} ({item.Kind})");
if (item.Kind == MediaKind.Hls) await DownloadHlsAsync(item, http);
else if (item.Kind == MediaKind.Dash) await DownloadDashAsync(item, http);
else if (item.Kind == MediaKind.Social) await DownloadSocialAsync(item);
else await DownloadDirectAsync(item, http);
}
catch (OperationCanceledException) { /* 다운로드 메서드가 상태 처리 */ }
catch (Exception e)
{
item.Status = "오류"; item.Error = e.Message;
_log($"오류({item.Title}): {e.Message}");
}
finally
{
bool refill;
lock (_gateLock) { _held--; refill = _held < _cap; }
if (refill) _slot.Release();
try { http?.Dispose(); } catch { } // disposeHandler=false → 공유 핸들러는 유지
}
}
/// <summary>선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시. UC-D09 재시도 횟수 카운트.</summary>
public async Task RetryDownload(DownloadItemViewModel item)
{
if (item.IsActiveDownload) return;
item.Started = false;
item.Progress = 0;
item.Segments = "";
item.Error = null;
item.RetryCount++;
item.SegmentMap.Clear();
await StartDownload(item);
}
/// <summary>#1 다운로드 취소</summary>
public void Cancel(DownloadItemViewModel item)
{
try { item.Cts?.Cancel(); }
catch { }
}
// ---- §E 일시정지/재개: 엔진 취소 + 파트(.part/.parts) 보존 → 재개 시 이어받기 ----
/// <summary>항목 일시정지 — 진행 중 항목을 취소하되 임시 파일을 남겨 재개 시 이어받는다.</summary>
public void Pause(DownloadItemViewModel item)
{
if (item.Status is not ("다운로드 중" or "병합 중" or "리먹스 중")) return;
item.PausedForResume = true;
item.Status = "일시정지";
try { item.Cts?.Cancel(); } catch { }
}
/// <summary>일시정지 항목 재개(이어받기).</summary>
public void Resume(DownloadItemViewModel item)
{
if (!item.PausedForResume) return;
item.PausedForResume = false;
item.Started = false;
_ = StartDownload(item);
}
/// <summary>UC-D06 전체 일시정지.</summary>
public void PauseAll()
{
foreach (var it in Items.Where(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중").ToList())
Pause(it);
}
/// <summary>UC-D07 전체 재개.</summary>
public void ResumeAll()
{
foreach (var it in Items.Where(i => i.PausedForResume).ToList())
Resume(it);
}
/// <summary>소셜 카드 제목 갱신(페이지 타이틀이 나중에 확정될 때). 아직 시작 전이면 출력 파일명도 갱신.</summary>
public void RenameSocial(Uri url, string title)
{
if (string.IsNullOrWhiteSpace(title)) return;
var clean = CleanSocialTitle(title);
lock (_itemsLock)
{
var it = Items.FirstOrDefault(i => i.Kind == MediaKind.Social && i.MediaUrl == url);
if (it == null) return;
it.Title = clean;
if (!it.Started)
it.OutputBase = MakeUnique(Path.Combine(_config.OutputFolder, FileNameUtil.Sanitize(clean)));
}
}
/// <summary>미디어 항목의 섬네일을 갱신하고 백그라운드에서 로컬 캐시를 구성한다.</summary>
public void UpdateThumbnail(Uri url, string thumb)
{
if (string.IsNullOrWhiteSpace(thumb)) return;
lock (_itemsLock)
{
var it = Items.FirstOrDefault(i => i.MediaUrl == url);
if (it == null) return;
_ = Task.Run(async () =>
{
try
{
var cached = await _thumbnailCache.EnsureThumbnailAsync(thumb, it.MediaUrl.ToString(), it.PageUrl);
if (!string.IsNullOrWhiteSpace(cached))
{
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
{
it.ThumbnailUrl = cached;
});
}
}
catch { }
});
}
}
private async Task DownloadHlsAsync(DownloadItemViewModel item, HttpClient http)
{
var cts = new CancellationTokenSource(); item.Cts = cts;
try
{
item.Status = "다운로드 중";
_log($"[HLS 다운로드 시작] {item.Title}");
Directory.CreateDirectory(_config.OutputFolder);
var powerMgr = _config.EnableBatterySaver ? PowerManager : null;
var dl = new HlsDownloader(http, _config, Paca.Core.Platform.DesktopMediaMuxer.Default, powerMgr);
var progress = new Progress<DownloadProgress>(p =>
{
item.Segments = p.Detail ?? $"{p.Completed}/{p.Total}";
item.Status = p.Stage switch { "merging" => "병합 중", "remuxing" => "리먹스 중", _ => "다운로드 중" };
if (p.Stage == "downloading") item.Progress = (int)Math.Round(p.Percent);
ReportSegmentMap(item, p); // UC-D10 세그먼트 맵
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"[HLS 다운로드 완료] {item.Title} -> {final}");
RaiseCompleted(item, final);
}
catch (OperationCanceledException)
{
item.Status = item.PausedForResume ? "일시정지" : "취소됨";
_log($"[HLS 다운로드 취소/일시정지] {item.Title}");
}
catch (Exception e)
{
item.Status = "오류"; item.Error = e.Message;
_log($"[HLS 다운로드 오류] ({item.Title}): {e.Message}");
ItemFailed?.Invoke(item);
}
finally
{
// #6 실패/취소 시 parts 정리 (성공·일시정지 시엔 이어받기 위해 보존)
if (item.Status != "완료" && !item.PausedForResume)
{
try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); }
catch { }
}
}
}
private async Task DownloadDirectAsync(DownloadItemViewModel item, HttpClient http)
{
var cts = new CancellationTokenSource(); item.Cts = cts;
try
{
item.Status = "다운로드 중";
_log($"[Direct 다운로드 시작] {item.Title}");
Directory.CreateDirectory(_config.OutputFolder);
var dl = new DirectDownloader(http, _config);
var progress = new Progress<DownloadProgress>(p =>
{
item.Segments = p.Detail ?? "";
if (p.Stage == "ratelimit")
{
item.Status = "속도제한 대기";
item.IsIndeterminate = true;
}
else
{
if (item.Status == "속도제한 대기") item.Status = "다운로드 중";
if (p.OverridePercent >= 0)
{
item.IsIndeterminate = false;
item.Progress = (int)Math.Round(p.OverridePercent);
}
else item.IsIndeterminate = true; // #9 길이 미상 → 부정확 모드
ReportSpeed(item, p); // §E 속도·ETA
}
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"[Direct 다운로드 완료] {item.Title} -> {final}");
RaiseCompleted(item, final);
}
catch (OperationCanceledException)
{
item.Status = item.PausedForResume ? "일시정지" : "취소됨";
_log($"[Direct 다운로드 취소/일시정지] {item.Title}");
}
catch (Exception e)
{
item.Status = "오류"; item.Error = e.Message;
_log($"[Direct 다운로드 오류] ({item.Title}): {e.Message}");
ItemFailed?.Invoke(item);
}
finally
{
// #6/#12 임시 .part 정리(일시정지 시 이어받기 위해 보존)
if (item.Status != "완료" && !item.PausedForResume)
{
try { var part = item.OutputBase + ".part"; if (File.Exists(part)) File.Delete(part); }
catch { }
}
}
}
private async Task DownloadDashAsync(DownloadItemViewModel item, HttpClient http)
{
var cts = new CancellationTokenSource(); item.Cts = cts;
try
{
item.Status = "다운로드 중";
_log($"[DASH 다운로드 시작] {item.Title}");
Directory.CreateDirectory(_config.OutputFolder);
var dl = new DashDownloader(http, _config);
var progress = new Progress<DownloadProgress>(p =>
{
item.Segments = p.Detail ?? $"{p.Completed}/{p.Total}";
item.Status = p.Stage switch { "merging" => "병합 중", "remuxing" => "리먹스 중", _ => "다운로드 중" };
if (p.Stage == "downloading") item.Progress = (int)Math.Round(p.Percent);
ReportSegmentMap(item, p); // UC-D10 세그먼트 맵
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"[DASH 다운로드 완료] {item.Title} -> {final}");
RaiseCompleted(item, final);
}
catch (OperationCanceledException)
{
item.Status = item.PausedForResume ? "일시정지" : "취소됨";
_log($"[DASH 다운로드 취소/일시정지] {item.Title}");
}
catch (Exception e)
{
item.Status = "오류"; item.Error = e.Message;
_log($"[DASH 다운로드 오류] ({item.Title}): {e.Message}");
ItemFailed?.Invoke(item);
}
finally
{
if (item.Status != "완료" && !item.PausedForResume)
{
try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); }
catch { }
}
}
}
private async Task DownloadSocialAsync(DownloadItemViewModel item)
{
var cts = new CancellationTokenSource(); item.Cts = cts;
string? cookiesPath = null;
try
{
item.Status = "다운로드 중";
item.IsIndeterminate = true;
Directory.CreateDirectory(_config.OutputFolder);
cookiesPath = await WriteCookiesAsync(item.MediaUrl);
var fmt = item.SelectedFormat ?? SocialPresets.All[0];
var dl = new YtDlpDownloader(_config);
var progress = new Progress<DownloadProgress>(p =>
{
item.Status = p.Stage switch { "remuxing" => "리먹스 중", _ => "다운로드 중" };
if (p.OverridePercent >= 0)
{
item.IsIndeterminate = false;
item.Progress = (int)Math.Round(p.OverridePercent);
}
else item.IsIndeterminate = true; // 길이 미상/변환 단계 → 부정확 모드
if (!string.IsNullOrEmpty(p.Detail)) item.Segments = p.Detail;
ReportSpeed(item, p); // §E 속도·ETA(yt-dlp 가 바이트 보고할 때)
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, fmt, cookiesPath, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
RaiseCompleted(item, final);
}
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
finally
{
// 잔류 임시/fragment 정리(성공 여부 무관). 무관 파일 미삭제를 위해 outputBase 접두로 한정.
try { CleanupYtDlpTemp(item.OutputBase); } catch { }
try { if (cookiesPath != null && File.Exists(cookiesPath)) File.Delete(cookiesPath); } catch { }
}
}
/// <summary>카드 "화질 불러오기" 버튼용: yt-dlp -J 로 실제 포맷을 조회해 드롭다운을 교체.</summary>
public async Task FetchFormatsAsync(DownloadItemViewModel item)
{
if (item.IsFetchingFormats) return;
item.IsFetchingFormats = true;
string? cookiesPath = null;
try
{
cookiesPath = await WriteCookiesAsync(item.MediaUrl);
var dl = new YtDlpDownloader(_config);
var fetched = await dl.FetchFormatsAsync(item.MediaUrl, cookiesPath, CancellationToken.None);
if (fetched.Count > 0)
{
var prev = item.SelectedFormat;
item.Formats.Clear();
foreach (var f in fetched) item.Formats.Add(f);
item.SelectedFormat = item.Formats.FirstOrDefault(f => prev != null && f.Selector == prev.Selector)
?? item.Formats[0];
_log($"화질 {fetched.Count}개 조회됨: {item.Title}");
}
else _log("사용 가능한 화질을 조회하지 못했습니다(프리셋 유지)");
}
catch (Exception e) { _log($"화질 조회 오류: {e.Message}"); }
finally
{
item.IsFetchingFormats = false;
try { if (cookiesPath != null && File.Exists(cookiesPath)) File.Delete(cookiesPath); } catch { }
}
}
/// <summary>WebView2 쿠키를 Netscape 파일로 내보냄. UseCookiesForSocial 이고 쿠키가 있을 때만.</summary>
private async Task<string?> WriteCookiesAsync(Uri url)
{
if (!_config.UseCookiesForSocial) return null;
try
{
var cookies = await _getCookies(url);
if (cookies == null || cookies.Count == 0) return null;
var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var records = cookies.Select(c => new CookieRecord(
c.Domain ?? url.Host, c.Path ?? "/", c.Name, c.Value, c.IsSecure, c.IsHttpOnly,
c.Expires <= epoch ? 0 : new DateTimeOffset(c.Expires).ToUnixTimeSeconds())).ToList();
var path = Path.Combine(Path.GetTempPath(), $"vd_cookies_{Guid.NewGuid():N}.txt");
CookieJarWriter.Write(path, records);
return path;
}
catch { return null; }
}
private static void CleanupYtDlpTemp(string outputBase)
{
var dir = Path.GetDirectoryName(outputBase);
var name = Path.GetFileName(outputBase);
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return;
foreach (var f in Directory.EnumerateFiles(dir, name + ".*"))
{
var fn = f;
if (fn.EndsWith(".part", StringComparison.OrdinalIgnoreCase) ||
fn.EndsWith(".ytdl", StringComparison.OrdinalIgnoreCase) ||
fn.Contains(".part-Frag", StringComparison.OrdinalIgnoreCase)) File.Delete(f);
}
}
private static string CleanSocialTitle(string t)
{
// 사이트 접미사 제거
foreach (var suffix in new[] { " - YouTube", " | Instagram", " | TikTok", " on Twitter", " / X", " - Facebook" })
if (t.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) { t = t[..^suffix.Length]; break; }
return t.Trim();
}
private async Task<HttpClient> BuildHttpClientAsync(Uri url, string? referer)
{
var http = new HttpClient(SharedHandler, disposeHandler: false) { Timeout = TimeSpan.FromMinutes(10) };
http.DefaultRequestHeaders.UserAgent.ParseAdd(_config.UserAgent);
if (!string.IsNullOrEmpty(referer))
{
try { http.DefaultRequestHeaders.Referrer = new Uri(referer); } catch { }
}
// #11 쿠키를 이 HttpClient(=이 다운로드) 에만 스코프 — 공유 컨테이너의 프로세스 수준 누적/타사이트 누출 방지.
// WebView2 가 해당 URL 에 대해 돌려준 쿠키만 Cookie 헤더로 구성(TryAddWithoutValidation: 값 검증 느슨하게).
try
{
var cookies = await _getCookies(url);
if (cookies is { Count: > 0 })
{
var header = string.Join("; ", cookies.Select(c => $"{c.Name}={c.Value}"));
if (!string.IsNullOrEmpty(header))
http.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", header);
}
}
catch { }
return http;
}
private static readonly string[] OutExts =
{ ".mp4", ".ts", ".webm", ".mkv", ".m4v", ".mov", ".avi", ".m4a", ".mp3", ".ogg", ".opus", ".wav", ".flac" };
private static string MakeUnique(string base_)
{
var dir = Path.GetDirectoryName(base_) ?? ".";
var file = Path.GetFileName(base_);
var p = Path.Combine(dir, file);
int i = 1;
while (OutExts.Any(ext => File.Exists(p + ext)) || Directory.Exists(p + ".parts"))
p = Path.Combine(dir, $"{file} ({i++})");
return p;
}
}

View file

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Windows.Input;
namespace Paca.App.Core;
public static class GlobalHotkeyParser
{
public static bool TryParse(string rawShortcut, out ModifierKeys modifiers, out Key key)
{
modifiers = ModifierKeys.None;
key = Key.None;
if (string.IsNullOrWhiteSpace(rawShortcut)) return false;
var parts = rawShortcut.Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0) return false;
for (int i = 0; i < parts.Length; i++)
{
var p = parts[i];
if (string.Equals(p, "Ctrl", StringComparison.OrdinalIgnoreCase) ||
string.Equals(p, "Control", StringComparison.OrdinalIgnoreCase))
{
modifiers |= ModifierKeys.Control;
}
else if (string.Equals(p, "Alt", StringComparison.OrdinalIgnoreCase))
{
modifiers |= ModifierKeys.Alt;
}
else if (string.Equals(p, "Shift", StringComparison.OrdinalIgnoreCase))
{
modifiers |= ModifierKeys.Shift;
}
else if (string.Equals(p, "Win", StringComparison.OrdinalIgnoreCase) ||
string.Equals(p, "Windows", StringComparison.OrdinalIgnoreCase))
{
modifiers |= ModifierKeys.Windows;
}
else
{
if (Enum.TryParse<Key>(p, true, out var parsedKey))
{
key = parsedKey;
}
}
}
return key != Key.None;
}
}
public class GlobalHotkeyManager
{
private readonly Dictionary<string, Action> _callbacks = new(StringComparer.OrdinalIgnoreCase);
public static uint GetWin32ModifierFlags(ModifierKeys modifiers)
{
uint flags = 0;
if (modifiers.HasFlag(ModifierKeys.Alt)) flags |= 0x0001;
if (modifiers.HasFlag(ModifierKeys.Control)) flags |= 0x0002;
if (modifiers.HasFlag(ModifierKeys.Shift)) flags |= 0x0004;
if (modifiers.HasFlag(ModifierKeys.Windows)) flags |= 0x0008;
return flags;
}
public void RegisterVirtualHotkey(string shortcut, Action callback)
{
if (string.IsNullOrWhiteSpace(shortcut) || callback == null) return;
_callbacks[shortcut] = callback;
}
public bool SimulateHotkeyTrigger(string shortcut)
{
if (_callbacks.TryGetValue(shortcut, out var action))
{
action();
return true;
}
return false;
}
}

View file

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Windows;
namespace Paca.App.Core;
public static class MultiDisplayLayoutEngine
{
public static Point ClampToWorkArea(
double requestedLeft,
double requestedTop,
double windowWidth,
double windowHeight,
Rect workArea,
double margin = 16)
{
var minX = workArea.Left + margin;
var maxX = workArea.Right - windowWidth - margin;
var minY = workArea.Top + margin;
var maxY = workArea.Bottom - windowHeight - margin;
var clampedX = Math.Clamp(requestedLeft, minX, Math.Max(minX, maxX));
var clampedY = Math.Clamp(requestedTop, minY, Math.Max(minY, maxY));
return new Point(clampedX, clampedY);
}
public static double PixelsToDips(double pixels, double dpi)
{
if (dpi <= 0) return pixels;
return pixels * (96.0 / dpi);
}
public static Rect FindBestScreen(Rect windowBounds, IEnumerable<Rect> screens)
{
Rect bestScreen = Rect.Empty;
double maxOverlapArea = -1;
foreach (var screen in screens)
{
var intersection = Rect.Intersect(windowBounds, screen);
var area = intersection.IsEmpty ? 0 : intersection.Width * intersection.Height;
if (area > maxOverlapArea)
{
maxOverlapArea = area;
bestScreen = screen;
}
}
return bestScreen;
}
}

View file

@ -0,0 +1,46 @@
<Window x:Class="Paca.App.CrashReportDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="예기치 않은 오류" Height="380" Width="560"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterScreen" ShowInTaskbar="False" Topmost="True"
UseLayoutRounding="True"
KeyboardNavigation.TabNavigation="Cycle"
AutomationProperties.Name="오류 보고 대화상자">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource GlassBorder}" BorderThickness="1" CornerRadius="12" Padding="14,12" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="&#xE7BA;" FontFamily="{DynamicResource IconFont}" FontSize="20"
Foreground="{DynamicResource ErrorBrush}" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Text="문제가 발생해 작업을 계속하지 못했습니다" Style="{StaticResource Type.Body}"
FontWeight="SemiBold" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock x:Name="MessageText" Foreground="{DynamicResource ErrorBrush}" FontSize="12"
FontWeight="Medium" TextWrapping="Wrap" Margin="30,0,0,0" LineHeight="1.4"/>
</StackPanel>
</Border>
<!-- UC-N05 오류 상세 펼침 -->
<Expander Grid.Row="1" Header="오류 상세 로그 (Stack Trace)" Foreground="{DynamicResource TextSecondary}" FontSize="12" FontWeight="SemiBold" IsExpanded="True">
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource GlassBorder}" BorderThickness="1" CornerRadius="8" Margin="0,6,0,0" Padding="2">
<TextBox x:Name="DetailBox" AutomationProperties.Name="오류 상세 스택트레이스" Style="{StaticResource DarkTextBox}" IsReadOnly="True" BorderThickness="0"
TextWrapping="NoWrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" FontFamily="{DynamicResource MonoFont}" FontSize="11"
Padding="10"/>
</Border>
</Expander>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button x:Name="CopyDetailBtn" AutomationProperties.Name="오류 내용 복사" Style="{StaticResource GhostButton}" Content="오류 내용 복사" Click="Copy_Click" Margin="0,0,8,0"/>
<Button x:Name="CloseBtn" AutomationProperties.Name="닫기" Style="{StaticResource PrimaryButton}" Content="닫기" Width="90" IsCancel="True" Click="Close_Click"/>
</StackPanel>
</Grid>
</Window>

View file

@ -0,0 +1,40 @@
using System.Windows;
namespace Paca.App;
/// <summary>UC-N18 크래시 리포트 대화상자 — 처리되지 않은 예외의 요약·스택을 보여주고 복사를 제공한다.
/// App 글로벌 예외 핸들러가 기록(crash.log) 후 사용자에게 표시한다.</summary>
public partial class CrashReportDialog : Window
{
/// <summary>같은 예외 폭주로 다이얼로그가 연쇄 뜨는 것을 막는 재진입 가드.</summary>
public static bool IsOpen { get; private set; }
public CrashReportDialog(Exception ex)
{
InitializeComponent();
MessageText.Text = $"{ex.GetType().Name}: {ex.Message}";
var details = $"{ex.GetType().FullName}: {ex.Message}\r\n\r\n{ex.StackTrace}";
if (ex.InnerException != null)
{
details += $"\r\n\r\n[InnerException] {ex.InnerException.GetType().FullName}: {ex.InnerException.Message}\r\n{ex.InnerException.StackTrace}";
}
DetailBox.Text = details;
Closed += (_, _) => IsOpen = false;
}
/// <summary>비모달 표시(재진입 가드). 이미 떠 있으면 무시한다.</summary>
public static void Show(Exception ex)
{
if (IsOpen) return;
IsOpen = true;
try { new CrashReportDialog(ex).Show(); }
catch { IsOpen = false; }
}
private void Copy_Click(object sender, RoutedEventArgs e)
{
try { Clipboard.SetText(DetailBox.Text); } catch { }
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
}

View file

@ -0,0 +1,154 @@
<Window x:Class="Paca.App.CredentialVaultWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewmodels="clr-namespace:Paca.App.ViewModels"
xmlns:local="clr-namespace:Paca.App"
mc:Ignorable="d"
Title="Paca 자격증명 볼트 &amp; Windows Hello"
Height="680" Width="960"
MinHeight="550" MinWidth="750"
WindowStartupLocation="CenterOwner"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
Foreground="{DynamicResource TextPrimary}"
UseLayoutRounding="True"
KeyboardNavigation.TabNavigation="Cycle"
AutomationProperties.Name="자격증명 금고 창">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<local:InverseBooleanToVisibilityConverter x:Key="InverseBoolToVis"/>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 상단 헤더 -->
<Border Grid.Row="0" Padding="20,16" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,0,1" Background="{DynamicResource CardBg}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="&#xE72E;" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets" FontSize="20" Foreground="{DynamicResource AccentBrush}" VerticalAlignment="Center" Margin="0,0,12,0"/>
<StackPanel>
<TextBlock Text="보안 자격증명 금고 (Windows Hello / DPAPI)" FontSize="16" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}"/>
<TextBlock Text="OS 수준 하드웨어 암호화로 보호되는 계정 및 비밀번호 관리자" FontSize="12" Foreground="{DynamicResource TextSecondary}" Margin="0,2,0,0"/>
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Grid.Column="2" VerticalAlignment="Center">
<Button x:Name="LockUnlockBtn" Content="금고 잠금" Padding="12,6" Margin="0,0,8,0" Click="LockUnlockBtn_Click" Style="{DynamicResource CardActionButton}"/>
<Button x:Name="AuditBtn" Content="보안 감사 실행" Padding="12,6" Margin="0,0,8,0" Click="AuditBtn_Click" Style="{DynamicResource CardActionButton}"/>
<Button x:Name="AddCredentialBtn" Content="+ 새 계정 추가" Padding="14,6" Click="AddCredentialBtn_Click" Style="{DynamicResource PrimaryButton}"/>
<Button x:Name="CloseBtn" Content="닫기" Padding="12,6" Margin="8,0,0,0" IsCancel="True" Click="Close_Click" Style="{DynamicResource CardActionButton}" AutomationProperties.Name="자격증명 금고 닫기"/>
</StackPanel>
</Grid>
</Border>
<!-- 메인 콘텐츠 영역 (2단 분할) -->
<Grid Grid.Row="1" Margin="20,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="320"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 좌측: 검색 및 계정 목록 -->
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 검색창 -->
<TextBox x:Name="SearchBox" Grid.Row="0" Margin="0,0,0,12" Padding="10,8" Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Style="{DynamicResource DarkTextBox}"/>
<!-- 계정 목록 -->
<ListBox x:Name="CredentialList" Grid.Row="1" ItemsSource="{Binding FilteredCredentials}" SelectedItem="{Binding SelectedCredential}" Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Padding="12,10" Margin="0,0,0,6" CornerRadius="8" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding SiteName}" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource TextPrimary}"/>
<TextBlock Text="{Binding Username}" FontSize="12" Foreground="{DynamicResource TextSecondary}" Margin="0,2,0,0"/>
<TextBlock Text="{Binding Domain}" FontSize="11" Foreground="{DynamicResource TextMuted}" Margin="0,2,0,0"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="⭐" Visibility="{Binding IsFavorite, Converter={StaticResource BoolToVis}}" VerticalAlignment="Top"/>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<!-- 우측: 상세 정보 및 편집/생성 뷰 -->
<Border Grid.Column="2" Padding="20" CornerRadius="12" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
<Grid>
<!-- 선택된 계정이 없을 때 -->
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Visibility="{Binding HasSelection, Converter={StaticResource InverseBoolToVis}}">
<TextBlock Text="&#xE8D7;" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets" FontSize="48" Foreground="{DynamicResource TextMuted}" HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="계정을 선택하거나 새 계정을 추가하세요" FontSize="15" Foreground="{DynamicResource TextSecondary}" HorizontalAlignment="Center"/>
</StackPanel>
<!-- 선택된 계정 상세 뷰 -->
<ScrollViewer VerticalScrollBarVisibility="Auto" Visibility="{Binding HasSelection, Converter={StaticResource BoolToVis}}">
<StackPanel>
<TextBlock Text="계정 상세 정보" FontSize="18" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,16"/>
<TextBlock Text="사이트 이름" FontSize="12" Foreground="{DynamicResource TextSecondary}" Margin="0,0,0,4"/>
<TextBox Text="{Binding SelectedCredential.SiteName, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12" Padding="8,6"/>
<TextBlock Text="도메인 / URL" FontSize="12" Foreground="{DynamicResource TextSecondary}" Margin="0,0,0,4"/>
<TextBox Text="{Binding SelectedCredential.Domain, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12" Padding="8,6"/>
<TextBlock Text="아이디 / 사용자명" FontSize="12" Foreground="{DynamicResource TextSecondary}" Margin="0,0,0,4"/>
<TextBox Text="{Binding SelectedCredential.Username, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12" Padding="8,6"/>
<TextBlock Text="비밀번호" FontSize="12" Foreground="{DynamicResource TextSecondary}" Margin="0,0,0,4"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="PasswordBox" Grid.Column="0" Text="{Binding SelectedCredential.DecryptedPassword, UpdateSourceTrigger=PropertyChanged}" Padding="8,6"/>
<Button Grid.Column="1" Content="복사" Margin="6,0,0,0" Padding="12,6" Click="CopyPassword_Click"/>
<Button Grid.Column="2" Content="생성" Margin="6,0,0,0" Padding="12,6" Click="GeneratePassword_Click"/>
</Grid>
<TextBlock Text="2단계 인증(TOTP) 시크릿" FontSize="12" Foreground="{DynamicResource TextSecondary}" Margin="0,0,0,4"/>
<TextBox Text="{Binding SelectedCredential.TotpSecret, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" Padding="8,6"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,0,0">
<Button Content="삭제" Padding="14,6" Margin="0,0,8,0" Click="DeleteBtn_Click" Style="{DynamicResource CardActionButton}"/>
<Button Content="저장" Padding="18,6" Click="SaveBtn_Click" Style="{DynamicResource PrimaryButton}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</Grid>
<!-- 하단 상태바 -->
<Border Grid.Row="2" Padding="16,8" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,1,0,0">
<Grid>
<TextBlock Text="{Binding StatusMessage}" FontSize="12" Foreground="{DynamicResource TextSecondary}" VerticalAlignment="Center"/>
</Grid>
</Border>
</Grid>
</Window>

View file

@ -0,0 +1,101 @@
using System.Windows;
using Paca.App.ViewModels;
using Paca.Core.Security;
namespace Paca.App;
public partial class CredentialVaultWindow : Window
{
private readonly CredentialVaultViewModel _viewModel;
public CredentialVaultViewModel ViewModel => _viewModel;
public CredentialVaultWindow(OsProtectedCredentialVault? vault = null)
{
InitializeComponent();
_viewModel = new CredentialVaultViewModel(vault ?? new OsProtectedCredentialVault());
DataContext = _viewModel;
Loaded += OnLoaded;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
if (_viewModel.Vault.IsLocked)
{
var res = await _viewModel.UnlockAsync();
if (!res.IsSuccess)
{
MessageBox.Show($"생체 인증에 실패하였습니다: {res.ErrorMessage ?? res.Status.ToString()}", "Paca 보안 금고", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
}
private async void LockUnlockBtn_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.Vault.IsLocked)
{
await _viewModel.UnlockAsync();
}
else
{
_viewModel.Lock();
}
}
private void AddCredentialBtn_Click(object sender, RoutedEventArgs e)
{
_viewModel.AddNewCredential("example.com", "user@example.com", PasswordSecurityEngine.GeneratePassword(), "새 웹사이트");
}
private void CopyPassword_Click(object sender, RoutedEventArgs e)
{
_viewModel.CopyPasswordToClipboard(
text => Clipboard.SetText(text),
() => Clipboard.Clear());
}
private void GeneratePassword_Click(object sender, RoutedEventArgs e)
{
_viewModel.GeneratePassword();
if (_viewModel.SelectedCredential != null)
{
_viewModel.SelectedCredential.DecryptedPassword = _viewModel.GeneratedPassword;
}
}
private void SaveBtn_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedCredential != null)
{
_viewModel.UpdateCredential(_viewModel.SelectedCredential);
MessageBox.Show("자격증명이 안전하게 저장되었습니다.", "Paca 보안 금고", MessageBoxButton.OK, MessageBoxImage.Information);
}
}
private void DeleteBtn_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedCredential != null)
{
if (MessageBox.Show($"'{_viewModel.SelectedCredential.SiteName}' 계정을 정말 삭제하시겠습니까?", "삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
{
_viewModel.DeleteSelectedCredential();
}
}
}
private void AuditBtn_Click(object sender, RoutedEventArgs e)
{
_viewModel.RunSecurityAudit();
var report = _viewModel.AuditReport;
if (report != null)
{
MessageBox.Show($"보안 건강 점수: {report.OverallHealthScore}/100\n총 계정: {report.TotalAccounts}\n취약한 비밀번호: {report.WeakPasswordCount}\n재사용된 비밀번호: {report.ReusedPasswordCount}\n90일 이상 미변경: {report.OldPasswordCount}\n2FA 미설정: {report.Missing2FaCount}", "보안 감사 결과", MessageBoxButton.OK, MessageBoxImage.Information);
}
}
private void Close_Click(object sender, RoutedEventArgs e)
{
Close();
}
}

View file

@ -0,0 +1,57 @@
using System;
namespace Paca.App;
public enum DesktopStartupMode
{
NormalGui,
GatewayDaemonSilent
}
public static class DesktopStartupModeParser
{
public const string GatewayDaemonFlag = "--gateway-daemon";
public static DesktopStartupMode Parse(string[]? args)
{
if (args == null || args.Length == 0) return DesktopStartupMode.NormalGui;
foreach (var arg in args)
{
if (string.Equals(arg, GatewayDaemonFlag, StringComparison.OrdinalIgnoreCase))
{
return DesktopStartupMode.GatewayDaemonSilent;
}
}
return DesktopStartupMode.NormalGui;
}
public static bool TryExtractDeepLink(string[]? args, out Paca.Mobile.Core.PacaDeepLinkAction? action)
{
action = null;
if (args == null || args.Length == 0) return false;
foreach (var arg in args)
{
if (string.IsNullOrWhiteSpace(arg)) continue;
if (Paca.Mobile.Core.PacaDeepLinkParser.TryParse(arg.Trim('\"'), out action))
{
return true;
}
}
return false;
}
}
public static class WindowsStartupRegistryHelper
{
public const string RunKeyName = "PacaGatewayDaemon";
public static string FormatRunCommand(string exePath)
{
if (string.IsNullOrWhiteSpace(exePath)) return string.Empty;
var cleanPath = exePath.Trim('\"');
return $"\"{cleanPath}\" {DesktopStartupModeParser.GatewayDaemonFlag}";
}
}

View file

@ -0,0 +1,21 @@
using System;
namespace Paca.App;
public enum TrayNotificationType
{
Info,
Success,
Warning,
Error
}
/// <summary>
/// 윈도우 시스템 트레이 알림 페이로드
/// </summary>
public record DesktopTrayNotificationPayload(
string Title,
string Message,
TrayNotificationType Type,
DateTimeOffset Timestamp
);

View file

@ -0,0 +1,339 @@
<Window x:Class="Paca.App.ExtensionManagerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Paca.App"
Title="확장 프로그램 (Chrome Extensions)" Height="660" Width="880"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
MinHeight="520" MinWidth="680"
UseLayoutRounding="True"
KeyboardNavigation.TabNavigation="Cycle"
AutomationProperties.Name="확장 프로그램 관리 창">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/Shared.xaml"/>
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<local:InverseBooleanToVisibilityConverter x:Key="InverseBoolToVis"/>
<local:InverseBoolConverter x:Key="InverseBool"/>
</ResourceDictionary>
</Window.Resources>
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- 상단 헤더 & 탭 -->
<RowDefinition Height="Auto"/> <!-- 개발자 모드 툴바 -->
<RowDefinition Height="Auto"/> <!-- 토스트 알림 바 -->
<RowDefinition Height="*"/> <!-- 메인 컨텐츠 (탭별) -->
<RowDefinition Height="Auto"/> <!-- 하단 푸터 -->
</Grid.RowDefinitions>
<!-- 1. 상단 헤더 & 탭 네비게이션 & 개발자 모드 토글 -->
<DockPanel Grid.Row="0" Margin="0,0,0,12">
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center">
<Border Width="34" Height="34" CornerRadius="8" Background="{DynamicResource AccentFadeBrush}" Margin="0,0,10,0">
<TextBlock Text="&#xEA86;" Style="{DynamicResource FluentIcon}" FontSize="18" Foreground="{DynamicResource AccentBrush}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="확장 프로그램" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}"/>
<TextBlock Text="Chrome Extensions" FontSize="11" Foreground="{DynamicResource TextMuted}"/>
</StackPanel>
</StackPanel>
<!-- 우측: 개발자 모드 스위치 & 웹스토어 링크 -->
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="6" Padding="10,5" Margin="0,0,8,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="개발자 모드" FontSize="11.5" Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<CheckBox x:Name="DevModeCheck" Click="DevModeCheck_Click" VerticalAlignment="Center" Cursor="Hand"/>
</StackPanel>
</Border>
<Button Style="{StaticResource GhostButton}" Click="OpenWebStore_Click" ToolTip="크롬 웹 스토어 사이트로 이동합니다">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE774;" Style="{DynamicResource FluentIcon}" FontSize="12" Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Chrome 웹 스토어" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
<!-- 중앙: 탭 전환 버튼 (설치된 확장 vs 스토어 마켓플레이스) -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<RadioButton x:Name="TabInstalledRadio" GroupName="ExtTabs" IsChecked="True" Checked="TabRadio_Checked" Style="{StaticResource {x:Type ToggleButton}}" Margin="0,0,6,0" Cursor="Hand" Padding="14,6">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xEA86;" Style="{DynamicResource FluentIcon}" FontSize="12" Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock x:Name="TabInstalledText" Text="설치된 확장 (0)" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center"/>
</StackPanel>
</RadioButton>
<RadioButton x:Name="TabStoreRadio" GroupName="ExtTabs" Checked="TabRadio_Checked" Style="{StaticResource {x:Type ToggleButton}}" Cursor="Hand" Padding="14,6">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE719;" Style="{DynamicResource FluentIcon}" FontSize="12" Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="스토어 / 찾기" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center"/>
</StackPanel>
</RadioButton>
</StackPanel>
</DockPanel>
<!-- 2. 개발자 모드 액션 바 (토글 ON 시 표시) -->
<Border x:Name="DevModeBar" Grid.Row="1" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource AccentBrush}" BorderThickness="1" CornerRadius="6" Padding="10,7" Margin="0,0,0,12" Visibility="Collapsed">
<DockPanel>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,14,0">
<TextBlock Text="&#xE7BE;" Style="{DynamicResource FluentIcon}" FontSize="12" Foreground="{DynamicResource AccentBrush}" Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="개발자 모드" FontSize="11.5" FontWeight="SemiBold" Foreground="{DynamicResource AccentBrush}" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource GhostButton}" Click="LoadUnpacked_Click" Margin="0,0,6,0" ToolTip="manifest.json이 포함된 개발 폴더를 로드합니다">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xED25;" Style="{DynamicResource FluentIcon}" FontSize="12" Margin="0,0,5,0" VerticalAlignment="Center"/>
<TextBlock Text="압축 해제된 확장 로드..." VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource GhostButton}" Click="InstallCrx_Click" Margin="0,0,6,0" ToolTip=".crx 또는 .zip 패키지 파일을 설치합니다">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE7B8;" Style="{DynamicResource FluentIcon}" FontSize="12" Margin="0,0,5,0" VerticalAlignment="Center"/>
<TextBlock Text="CRX / ZIP 파일 설치..." VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource GhostButton}" Click="InstallFromUrl_Click" ToolTip="웹스토어 주소나 확장 ID를 입력해 직접 설치합니다">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE710;" Style="{DynamicResource FluentIcon}" FontSize="12" Margin="0,0,5,0" VerticalAlignment="Center"/>
<TextBlock Text="웹스토어 URL 직접 설치..." VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</DockPanel>
</Border>
<!-- 3. 토스트 알림 배너 -->
<Border x:Name="ToastBanner" Grid.Row="2" Background="{DynamicResource AccentFadeBrush}" BorderBrush="{DynamicResource AccentBrush}" BorderThickness="1" CornerRadius="6" Padding="10,6" Margin="0,0,0,10" Visibility="Collapsed">
<DockPanel>
<Button DockPanel.Dock="Right" Content="✕" Style="{StaticResource GhostButton}" Padding="4,0" Click="DismissToast_Click"/>
<TextBlock x:Name="ToastText" Text="알림 메시지" FontSize="11.5" Foreground="{DynamicResource AccentTextBrush}" FontWeight="Medium" VerticalAlignment="Center"/>
</DockPanel>
</Border>
<!-- 4. 메인 컨텐츠 영역 -->
<Grid Grid.Row="3">
<!-- TAB A: 설치된 확장 프로그램 목록 -->
<Grid x:Name="InstalledView" Visibility="Visible">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 검색창 -->
<Border Grid.Row="0" Style="{StaticResource SearchInputContainer}" Padding="8,4" Margin="0,0,0,10">
<DockPanel VerticalAlignment="Center">
<TextBlock Text="&#xE721;" Style="{DynamicResource FluentIcon}" FontSize="12" Foreground="{DynamicResource TextMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="InstalledSearchBox" Style="{StaticResource AddressTextBox}" TextChanged="InstalledSearchBox_TextChanged" Tag="설치된 확장 프로그램 검색 (이름, 설명, ID)..."/>
</DockPanel>
</Border>
<!-- 목록 스크롤뷰어 -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<!-- 빈 목록 안내 -->
<Border x:Name="EmptyState" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="8" Padding="36" Margin="0,10,0,0" Visibility="Collapsed">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="&#xEA86;" Style="{DynamicResource FluentIcon}" FontSize="40" Foreground="{DynamicResource TextMuted}" HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="설치된 확장 프로그램이 없습니다." FontSize="14.5" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}" HorizontalAlignment="Center"/>
<TextBlock Text="스토어에서 광고 차단, 동영상 도우미, 다크 모드 등 유용한 확장을 설치해보세요." FontSize="11.5" Foreground="{DynamicResource TextMuted}" HorizontalAlignment="Center" Margin="0,4,0,16"/>
<Button Style="{StaticResource PrimaryButton}" Click="GoToStore_Click" HorizontalAlignment="Center" Padding="14,6">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE719;" Style="{DynamicResource FluentIcon}" FontSize="12" Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="스토어에서 확장 찾아보기" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
<!-- 확장 리스트 아이템즈 컨트롤 -->
<ItemsControl x:Name="ExtensionList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,8">
<DockPanel LastChildFill="True">
<!-- 확장 아이콘 -->
<Border DockPanel.Dock="Left" Width="44" Height="44" CornerRadius="8" Background="{DynamicResource Surface2}" Margin="0,0,14,0" VerticalAlignment="Center">
<Grid>
<TextBlock Text="&#xEA86;" Style="{DynamicResource FluentIcon}" FontSize="22" Foreground="{DynamicResource TextMuted}"/>
<Image Source="{Binding IconPath}" Stretch="Uniform" Width="36" Height="36"/>
</Grid>
</Border>
<!-- 토글 스위치 및 액션 버튼들 -->
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0">
<!-- 도구모음 고정 핀 체크박스 -->
<CheckBox Content="도구 모음에 고정" IsChecked="{Binding IsPinned, Mode=TwoWay}" Click="TogglePin_Click" Tag="{Binding}" Margin="0,0,14,0" Style="{StaticResource DarkCheckBox}" FontSize="11"/>
<!-- 옵션 버튼 (HasOptions 있을 때만 활성화) -->
<Button Style="{StaticResource GhostButton}" Click="OpenOptions_Click" Tag="{Binding}" Margin="0,0,6,0" Padding="8,4" FontSize="11"
Visibility="{Binding HasOptions, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE713;" Style="{DynamicResource FluentIcon}" FontSize="11" Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBlock Text="설정" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!-- 삭제 버튼 -->
<Button Style="{StaticResource GhostButton}" Click="Uninstall_Click" Tag="{Binding}" Margin="0,0,10,0" Padding="8,4" FontSize="11" Foreground="{DynamicResource ErrorBrush}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE74D;" Style="{DynamicResource FluentIcon}" FontSize="11" Foreground="{DynamicResource ErrorBrush}" Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBlock Text="삭제" Foreground="{DynamicResource ErrorBrush}" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!-- 사용 설정 토글 스위치 -->
<CheckBox Style="{StaticResource DarkCheckBox}" IsChecked="{Binding IsEnabled, Mode=TwoWay}" Click="ToggleEnable_Click" Tag="{Binding}" VerticalAlignment="Center"/>
</StackPanel>
<!-- 정보 텍스트 -->
<StackPanel VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<TextBlock Text="{Binding EffectiveName}" FontWeight="SemiBold" FontSize="13.5" Foreground="{DynamicResource TextPrimary}" Margin="0,0,8,0"/>
<TextBlock Text="{Binding Manifest.Version, StringFormat='v{0}'}" FontSize="11" Foreground="{DynamicResource TextMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Border Background="{DynamicResource AccentFadeBrush}" CornerRadius="4" Padding="4,1" Visibility="{Binding IsUnpacked, Converter={StaticResource BoolToVis}}">
<TextBlock Text="개발자 로드" FontSize="9.5" Foreground="{DynamicResource AccentBrush}"/>
</Border>
</StackPanel>
<TextBlock Text="{Binding EffectiveDescription}" FontSize="11.5" Foreground="{DynamicResource TextSecondary}" TextWrapping="Wrap" MaxHeight="34" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Id, StringFormat='ID: {0}'}" FontSize="10" FontFamily="{DynamicResource MonoFont}" Foreground="{DynamicResource TextMuted}" Margin="0,2,0,0"/>
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- TAB B: 확장 프로그램 스토어 / 마켓플레이스 -->
<Grid x:Name="StoreView" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- 검색 및 카테고리 필터 -->
<RowDefinition Height="*"/> <!-- 마켓플레이스 카드 그리드 -->
<RowDefinition Height="Auto"/> <!-- 하단 웹스토어 안내 -->
</Grid.RowDefinitions>
<!-- 검색창 및 카테고리 필터 칩 -->
<StackPanel Grid.Row="0" Margin="0,0,0,10">
<Border Style="{StaticResource SearchInputContainer}" Padding="8,4" Margin="0,0,0,8">
<DockPanel VerticalAlignment="Center">
<TextBlock Text="&#xE721;" Style="{DynamicResource FluentIcon}" FontSize="12" Foreground="{DynamicResource TextMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="StoreSearchBox" Style="{StaticResource AddressTextBox}" TextChanged="StoreSearchBox_TextChanged" KeyDown="StoreSearchBox_KeyDown" Tag="스토어에서 확장 프로그램 검색 (예: adblock, sponsor, volume, youtube, 번역, 다크모드...)" />
</DockPanel>
</Border>
<!-- 카테고리 칩 -->
<StackPanel Orientation="Horizontal">
<RadioButton Content="전체" Tag="전체" GroupName="StoreCat" IsChecked="True" Click="CategoryChip_Click" Style="{StaticResource {x:Type ToggleButton}}" Margin="0,0,6,0" Padding="10,4" FontSize="11"/>
<RadioButton Content="광고/보안" Tag="광고/보안" GroupName="StoreCat" Click="CategoryChip_Click" Style="{StaticResource {x:Type ToggleButton}}" Margin="0,0,6,0" Padding="10,4" FontSize="11"/>
<RadioButton Content="미디어 도우미" Tag="미디어 도우미" GroupName="StoreCat" Click="CategoryChip_Click" Style="{StaticResource {x:Type ToggleButton}}" Margin="0,0,6,0" Padding="10,4" FontSize="11"/>
<RadioButton Content="생산성" Tag="생산성" GroupName="StoreCat" Click="CategoryChip_Click" Style="{StaticResource {x:Type ToggleButton}}" Margin="0,0,6,0" Padding="10,4" FontSize="11"/>
</StackPanel>
</StackPanel>
<!-- 스토어 아이템 목록 -->
<Grid Grid.Row="1">
<!-- 검색 결과 없을 때 안내 -->
<Border x:Name="StoreEmptyState" Visibility="Collapsed" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="8" Padding="24" VerticalAlignment="Center" HorizontalAlignment="Center" MaxWidth="440">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="&#xe151;" FontFamily="{DynamicResource LucideFont}" FontSize="32" Foreground="{DynamicResource TextMuted}" HorizontalAlignment="Center" Margin="0,0,0,8"/>
<TextBlock Text="내장 카탈로그에서 일치하는 확장을 찾을 수 없습니다." FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}" HorizontalAlignment="Center" TextAlignment="Center"/>
<TextBlock Text="Chrome 웹 스토어에는 수만 개의 확장이 등록되어 있습니다. 공식 스토어에서 직접 검색하여 설치해 보세요!" FontSize="11" Foreground="{DynamicResource TextSecondary}" TextWrapping="Wrap" TextAlignment="Center" Margin="0,6,0,14"/>
<Button x:Name="StoreSearchOnlineBtn" Style="{StaticResource PrimaryButton}" Click="StoreSearchOnline_Click" Padding="14,7" HorizontalAlignment="Center" Cursor="Hand">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xe0e8; " FontFamily="{DynamicResource LucideFont}" VerticalAlignment="Center"/>
<TextBlock x:Name="StoreSearchOnlineBtnText" Text="Chrome 웹 스토어에서 온라인 검색 ↗" FontWeight="SemiBold" FontSize="11.5"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<ItemsControl x:Name="StoreList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="8" Padding="14" Margin="0,0,0,10"
Cursor="Hand" MouseLeftButtonUp="StoreCard_MouseLeftButtonUp" ToolTip="클릭하여 확장 프로그램을 설치하거나 웹스토어를 엽니다">
<DockPanel>
<!-- 우측 액션 버튼 (설치 vs 설치됨) -->
<StackPanel DockPanel.Dock="Right" VerticalAlignment="Center" Margin="14,0,0,0" Width="135">
<!-- 미설치 시: 설치 버튼 -->
<Button Style="{StaticResource PrimaryButton}" Click="InstallStoreItem_Click" Padding="12,7" HorizontalAlignment="Stretch" Cursor="Hand"
IsEnabled="{Binding IsInstalling, Converter={StaticResource InverseBool}}"
Visibility="{Binding IsInstalled, Converter={StaticResource InverseBoolToVis}}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock Text="설치 중..." FontWeight="SemiBold" FontSize="11" Visibility="{Binding IsInstalling, Converter={StaticResource BoolToVis}}"/>
<StackPanel Orientation="Horizontal" Visibility="{Binding IsInstalling, Converter={StaticResource InverseBoolToVis}}">
<TextBlock Text="+ " FontWeight="Bold"/>
<TextBlock Text="Chrome에 추가" FontWeight="SemiBold" FontSize="11.5"/>
</StackPanel>
</StackPanel>
</Button>
<!-- 설치 완료 시: 설치됨 배지 & 웹스토어 링크 -->
<StackPanel Visibility="{Binding IsInstalled, Converter={StaticResource BoolToVis}}">
<Border Background="{DynamicResource AccentFadeBrush}" CornerRadius="4" Padding="8,4" HorizontalAlignment="Stretch">
<TextBlock Text="설치됨" FontSize="11" FontWeight="SemiBold" Foreground="{DynamicResource AccentTextBrush}" HorizontalAlignment="Center"/>
</Border>
<Button Style="{StaticResource GhostButton}" Content="웹스토어 ↗" Click="OpenItemWebStore_Click" Margin="0,4,0,0" FontSize="10" Padding="4,2" HorizontalAlignment="Center"/>
</StackPanel>
</StackPanel>
<!-- 좌측 아이콘 (Lucide 글리프) -->
<Border DockPanel.Dock="Left" Width="44" Height="44" CornerRadius="8" Background="{DynamicResource PanelBg}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" Margin="0,0,14,0" VerticalAlignment="Center">
<TextBlock Text="{Binding IconGlyph}" FontFamily="{DynamicResource LucideFont}" Foreground="{DynamicResource AccentTextBrush}" FontSize="20" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<!-- 중앙 상세 정보 -->
<StackPanel VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}" FontSize="13.5" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center"/>
<Border Background="{DynamicResource PanelBg}" CornerRadius="4" Padding="5,1" Margin="8,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Category}" FontSize="9.5" Foreground="{DynamicResource TextMuted}"/>
</Border>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="8,0,0,0">
<TextBlock Text="&#xe176; " FontFamily="{DynamicResource LucideFont}" FontSize="10" Foreground="#F59E0B" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Rating, StringFormat='{}{0:0.0}'}" FontSize="10" Foreground="#F59E0B" FontWeight="SemiBold" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding Users, StringFormat='({0})'}" FontSize="10" Foreground="{DynamicResource TextMuted}" Margin="4,0,0,0" VerticalAlignment="Center"/>
<!-- 웹스토어 원본 상세 보기 버튼 -->
<Button Style="{StaticResource GhostButton}" Content="상세 ↗" Click="OpenItemWebStore_Click" Margin="8,0,0,0" FontSize="10" Padding="4,1" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding Description}" FontSize="11" Foreground="{DynamicResource TextSecondary}" TextWrapping="Wrap" Margin="0,3,0,3" MaxHeight="36" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Id}" Style="{StaticResource Type.Mono}" FontSize="9.5" Foreground="{DynamicResource TextMuted}"/>
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
<!-- 하단 웹스토어 안내 바 -->
<Border Grid.Row="2" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,10,0,0">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="Chrome 웹 스토어 열기 ↗" Click="OpenWebStore_Click" Padding="10,4" VerticalAlignment="Center"/>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="원하는 확장 프로그램이 목록에 없나요?" FontSize="11.5" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}"/>
<TextBlock Text="Google 공식 Chrome 웹 스토어에서 직접 수만 개의 확장 프로그램을 둘러보실 수 있습니다." FontSize="10.5" Foreground="{DynamicResource TextMuted}"/>
</StackPanel>
</DockPanel>
</Border>
</Grid>
</Grid>
<!-- 5. 하단 푸터 -->
<DockPanel Grid.Row="4" Margin="0,12,0,0">
<TextBlock Text="※ 크롬 확장 프로그램은 Chromium 기반 WebView2 프로필에 격리되어 안전하게 실행됩니다." FontSize="10.5" Foreground="{DynamicResource TextMuted}" VerticalAlignment="Center"/>
<Button DockPanel.Dock="Right" Style="{StaticResource PrimaryButton}" Content="닫기" Width="90" Click="Close_Click" IsCancel="True" AutomationProperties.Name="확장 프로그램 창 닫기" HorizontalAlignment="Right"/>
</DockPanel>
</Grid>
</Window>

View file

@ -0,0 +1,541 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
using Microsoft.Win32;
using Paca.Browser.Extensions;
namespace Paca.App;
/// <summary>
/// 크롬 확장 프로그램 관리 및 마켓플레이스 스토어 대화상자.
/// </summary>
public partial class ExtensionManagerWindow : Window
{
private readonly ExtensionManager _manager;
private readonly Action<string>? _openUrlInTab;
private string _currentStoreCategory = "전체";
private List<MarketplaceItem> _currentStoreItems = new();
public ExtensionManagerWindow(ExtensionManager manager, Action<string>? openUrlInTab = null)
{
InitializeComponent();
_manager = manager;
_openUrlInTab = openUrlInTab;
ExtensionList.ItemsSource = _manager.Extensions;
System.Collections.Specialized.NotifyCollectionChangedEventHandler handler = (_, _) =>
{
UpdateInstalledCounts();
RefreshStoreItems();
};
_manager.Extensions.CollectionChanged += handler;
Closed += (_, _) => _manager.Extensions.CollectionChanged -= handler;
UpdateInstalledCounts();
RefreshStoreItems();
}
private void UpdateInstalledCounts()
{
var count = _manager.Extensions.Count;
TabInstalledText.Text = $"설치된 확장 ({count})";
EmptyState.Visibility = count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
private void RefreshStoreItems()
{
var q = StoreSearchBox?.Text?.Trim() ?? "";
var installedIds = _manager.Extensions.Select(x => x.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
_currentStoreItems = ExtensionMarketplace.Search(q, _currentStoreCategory, installedIds);
if (StoreList != null)
{
StoreList.ItemsSource = null;
StoreList.ItemsSource = _currentStoreItems;
}
if (StoreEmptyState != null)
{
var isEmpty = _currentStoreItems.Count == 0;
StoreEmptyState.Visibility = isEmpty ? Visibility.Visible : Visibility.Collapsed;
if (isEmpty && StoreSearchOnlineBtnText != null)
{
StoreSearchOnlineBtnText.Text = string.IsNullOrWhiteSpace(q)
? "Chrome 웹 스토어 전체 둘러보기 ↗"
: $"Chrome 웹 스토어에서 '{q}' 검색 ↗";
}
}
}
private void StoreSearchBox_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key == System.Windows.Input.Key.Enter)
{
var q = StoreSearchBox?.Text?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(q)) return;
// 만약 32자리 ID 또는 URL인 경우 바로 설치 시도
var extId = ChromeWebStoreClient.ExtractExtensionId(q);
if (!string.IsNullOrEmpty(extId) && _currentStoreItems.Count > 0 && !_currentStoreItems[0].IsInstalled)
{
InstallCustomItemDirectly(_currentStoreItems[0]);
return;
}
// 결과가 없거나 Enter 입력 시 Chrome 웹 스토어 검색 페이지로 바로 이동 제안
if (_currentStoreItems.Count == 0)
{
OpenStoreSearchOnline(q);
}
}
}
private void StoreSearchOnline_Click(object sender, RoutedEventArgs e)
{
var q = StoreSearchBox?.Text?.Trim() ?? "";
OpenStoreSearchOnline(q);
}
private void OpenStoreSearchOnline(string q)
{
if (string.IsNullOrWhiteSpace(q))
{
OpenUrl("https://chromewebstore.google.com/category/extensions");
}
else
{
OpenUrl($"https://chromewebstore.google.com/search/{Uri.EscapeDataString(q)}");
}
}
private async void InstallCustomItemDirectly(MarketplaceItem item)
{
try
{
item.IsInstalling = true;
ShowToast($"'{item.Name}' 확장 프로그램을 다운로드 및 설치 중입니다...");
var installed = await _manager.InstallFromWebStoreAsync(item.Id);
item.IsInstalled = true;
ShowToast($"'{installed.Name}' (v{installed.Version}) 확장이 성공적으로 설치되었습니다!");
MessageBox.Show(this, $"'{installed.Name}' (v{installed.Version}) 확장이 성공적으로 설치되었습니다!\n\n이제 브라우저에서 확장이 활성화되어 즉시 작동합니다.", "확장 프로그램 설치 완료", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show(this, $"확장 프로그램 설치 중 오류가 발생했습니다:\n{ex.Message}", "설치 실패", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
item.IsInstalling = false;
RefreshStoreItems();
UpdateInstalledCounts();
}
}
private void TabRadio_Checked(object sender, RoutedEventArgs e)
{
if (InstalledView == null || StoreView == null) return;
if (TabInstalledRadio.IsChecked == true)
{
InstalledView.Visibility = Visibility.Visible;
StoreView.Visibility = Visibility.Collapsed;
}
else
{
InstalledView.Visibility = Visibility.Collapsed;
StoreView.Visibility = Visibility.Visible;
RefreshStoreItems();
}
}
private void GoToStore_Click(object sender, RoutedEventArgs e)
{
TabStoreRadio.IsChecked = true;
}
private void DevModeCheck_Click(object sender, RoutedEventArgs e)
{
DevModeBar.Visibility = DevModeCheck.IsChecked == true ? Visibility.Visible : Visibility.Collapsed;
}
private void InstalledSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var q = InstalledSearchBox.Text?.Trim() ?? "";
var view = CollectionViewSource.GetDefaultView(_manager.Extensions);
if (view == null) return;
view.Filter = q.Length == 0
? null
: o => o is ExtensionItem item &&
(item.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
item.Description.Contains(q, StringComparison.OrdinalIgnoreCase) ||
item.Id.Contains(q, StringComparison.OrdinalIgnoreCase));
}
private void StoreSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
RefreshStoreItems();
}
private void CategoryChip_Click(object sender, RoutedEventArgs e)
{
if (sender is RadioButton rb && rb.Tag is string cat)
{
_currentStoreCategory = cat;
RefreshStoreItems();
}
}
private async void InstallStoreItem_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not MarketplaceItem item) return;
try
{
item.IsInstalling = true;
ShowToast($"'{item.Name}' 확장 프로그램을 Chrome 웹 스토어에서 다운로드 및 설치 중입니다...");
var installed = await _manager.InstallFromWebStoreAsync(item.Id);
item.IsInstalled = true;
ShowToast($"'{installed.Name}' (v{installed.Version}) 확장이 성공적으로 설치되었습니다!");
MessageBox.Show(this, $"'{installed.Name}' (v{installed.Version}) 확장이 성공적으로 설치되었습니다!\n\n이제 브라우저에서 확장이 활성화되어 즉시 작동합니다.", "확장 프로그램 설치 완료", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show(this, $"확장 프로그램 설치 중 오류가 발생했습니다:\n{ex.Message}", "설치 실패", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
item.IsInstalling = false;
RefreshStoreItems();
UpdateInstalledCounts();
}
}
private void StoreCard_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
// 만약 버튼(예: 설치 버튼, 상세 버튼)을 직접 클릭한 경우 중복 실행 방지
if (e.OriginalSource is DependencyObject dep)
{
var btn = FindVisualParent<Button>(dep);
if (btn != null) return;
}
if ((sender as FrameworkElement)?.DataContext is not MarketplaceItem item) return;
if (!item.IsInstalled && !item.IsInstalling)
{
InstallCustomItemDirectly(item);
}
else if (item.IsInstalled)
{
OpenUrl(item.WebStoreUrl);
}
}
private static T? FindVisualParent<T>(DependencyObject child) where T : DependencyObject
{
if (child is not Visual and not System.Windows.Media.Media3D.Visual3D)
return (child as FrameworkContentElement)?.Parent is DependencyObject p ? (p is T tp ? tp : FindVisualParent<T>(p)) : null;
var parentObject = VisualTreeHelper.GetParent(child);
if (parentObject == null) return null;
if (parentObject is T parent) return parent;
return FindVisualParent<T>(parentObject);
}
private void OpenItemWebStore_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not MarketplaceItem item) return;
OpenUrl(item.WebStoreUrl);
}
private void OpenWebStore_Click(object sender, RoutedEventArgs e)
{
OpenUrl("https://chromewebstore.google.com/category/extensions");
}
private void OpenUrl(string url)
{
if (string.IsNullOrWhiteSpace(url)) return;
try
{
if (_openUrlInTab != null)
{
_openUrlInTab(url);
Close();
}
else
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
}
catch
{
try
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
catch (Exception ex)
{
MessageBox.Show(this, $"링크를 열 수 없습니다:\n{ex.Message}", "오류", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
private async void LoadUnpacked_Click(object sender, RoutedEventArgs e)
{
try
{
var dialog = new OpenFolderDialog
{
Title = "압축 해제된 확장 프로그램 폴더 선택 (manifest.json 포함)"
};
if (dialog.ShowDialog(this) == true)
{
var folder = dialog.FolderName;
if (!File.Exists(Path.Combine(folder, "manifest.json")))
{
MessageBox.Show(this, "선택한 폴더에 manifest.json 파일이 존재하지 않습니다.", "오류", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var item = await _manager.InstallFromFolderAsync(folder, copyToStorage: true);
if (item != null)
ShowToast($"'{item.Name}' (v{item.Version}) 언팩 확장이 로드되었습니다.");
else
ShowToast("확장 프로그램 로드에 실패했습니다.");
}
}
catch (Exception ex)
{
MessageBox.Show(this, $"확장 프로그램 로드 실패:\n{ex.Message}", "설치 실패", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async void InstallCrx_Click(object sender, RoutedEventArgs e)
{
try
{
var dialog = new OpenFileDialog
{
Title = "크롬 확장 프로그램 패키지 선택 (.crx 또는 .zip)",
Filter = "Chrome Extensions (*.crx;*.zip)|*.crx;*.zip|모든 파일 (*.*)|*.*"
};
if (dialog.ShowDialog(this) == true)
{
using var stream = File.OpenRead(dialog.FileName);
var item = await _manager.InstallFromPackageStreamAsync(stream);
ShowToast($"'{item.Name}' (v{item.Version}) 패키지가 설치되었습니다.");
}
}
catch (Exception ex)
{
MessageBox.Show(this, $"확장 패키지 설치 실패:\n{ex.Message}", "설치 실패", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async void InstallFromUrl_Click(object sender, RoutedEventArgs e)
{
var input = InputBox("Chrome 웹 스토어에서 확장 설치", "웹 스토어 주소(URL) 또는 확장 ID를 입력하세요:", "");
if (string.IsNullOrWhiteSpace(input)) return;
try
{
var item = await _manager.InstallFromWebStoreAsync(input.Trim());
ShowToast($"'{item.Name}' (v{item.Version}) 확장이 설치되었습니다!");
}
catch (Exception ex)
{
MessageBox.Show(this, $"웹 스토어 확장 설치 실패:\n{ex.Message}", "설치 실패", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OpenOptions_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not ExtensionItem item) return;
var optUrl = _manager.GetOptionsUrl(item);
if (!string.IsNullOrEmpty(optUrl))
{
OpenUrl(optUrl);
}
}
private async void TogglePin_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not ExtensionItem item) return;
try
{
await _manager.SetPinnedAsync(item.Id, item.IsPinned);
ShowToast($"{(item.IsPinned ? "도구 모음에 고정됨" : "고정 해제됨")}: {item.Name}");
}
catch { }
}
private void InstalledCard_MouseRightButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not ExtensionItem item) return;
var menu = new ContextMenu();
var header = new MenuItem { Header = $"{item.Name} (v{item.Version})", IsEnabled = false, FontWeight = FontWeights.SemiBold };
menu.Items.Add(header);
menu.Items.Add(new Separator());
if (item.HasOptions)
{
var optItem = new MenuItem { Header = "옵션 페이지 열기" };
optItem.Click += (_, _) =>
{
var optUrl = _manager.GetOptionsUrl(item);
if (!string.IsNullOrEmpty(optUrl)) OpenUrl(optUrl);
};
menu.Items.Add(optItem);
}
var pinItem = new MenuItem { Header = item.IsPinned ? "도구 모음에서 고정 해제" : "도구 모음에 고정" };
pinItem.Click += async (_, _) =>
{
await _manager.TogglePinAsync(item.Id);
ShowToast($"{(item.IsPinned ? "도구 모음에 고정됨" : "고정 해제됨")}: {item.Name}");
};
menu.Items.Add(pinItem);
var toggleEnableItem = new MenuItem { Header = item.IsEnabled ? "비활성화" : "활성화" };
toggleEnableItem.Click += async (_, _) =>
{
await _manager.SetEnabledAsync(item.Id, !item.IsEnabled);
ShowToast($"{(item.IsEnabled ? "활성화됨" : "비활성화됨")}: {item.Name}");
};
menu.Items.Add(toggleEnableItem);
menu.Items.Add(new Separator());
var folderItem = new MenuItem { Header = "설치 폴더 열기" };
folderItem.Click += (_, _) => OpenFolder_Click(sender, e);
menu.Items.Add(folderItem);
var deleteItem = new MenuItem { Header = "확장 프로그램 삭제" };
deleteItem.Click += (_, _) => Uninstall_Click(sender, e);
menu.Items.Add(deleteItem);
menu.PlacementTarget = sender as UIElement;
menu.IsOpen = true;
e.Handled = true;
}
private async void ToggleEnable_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not ExtensionItem item) return;
try
{
await _manager.SetEnabledAsync(item.Id, item.IsEnabled);
ShowToast($"{(item.IsEnabled ? "활성화됨" : "비활성화됨")}: {item.Name}");
}
catch (Exception ex)
{
MessageBox.Show(this, $"상태 변경 실패: {ex.Message}", "오류", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OpenFolder_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not ExtensionItem item) return;
try
{
if (Directory.Exists(item.FolderPath))
{
Process.Start("explorer.exe", item.FolderPath);
}
}
catch (Exception ex)
{
MessageBox.Show(this, $"폴더 열기 실패: {ex.Message}", "오류", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async void Uninstall_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not ExtensionItem item) return;
var result = MessageBox.Show(this, $"'{item.Name}' 확장 프로그램을 정말 삭제하시겠습니까?", "확장 프로그램 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result != MessageBoxResult.Yes) return;
try
{
await _manager.UninstallAsync(item.Id);
ShowToast($"'{item.Name}' 확장 프로그램이 제거되었습니다.");
}
catch (Exception ex)
{
MessageBox.Show(this, $"삭제 실패: {ex.Message}", "오류", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void ShowToast(string message)
{
ToastText.Text = message;
ToastBanner.Visibility = Visibility.Visible;
}
private void DismissToast_Click(object sender, RoutedEventArgs e)
{
ToastBanner.Visibility = Visibility.Collapsed;
}
private void Close_Click(object sender, RoutedEventArgs e)
{
Close();
}
private static string? InputBox(string title, string prompt, string defaultText)
{
var dlg = new Window
{
Title = title,
Width = 440,
Height = 170,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
ShowInTaskbar = false,
ResizeMode = ResizeMode.NoResize,
Background = Application.Current?.Resources["CardBg"] as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.DimGray
};
var sp = new StackPanel { Margin = new Thickness(16) };
var label = new TextBlock
{
Text = prompt,
Foreground = Application.Current?.Resources["TextPrimary"] as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.White,
Margin = new Thickness(0, 0, 0, 8)
};
var tb = new TextBox
{
Text = defaultText,
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 0, 12)
};
var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
var ok = new Button { Content = "확인", Width = 70, Height = 28, Margin = new Thickness(0, 0, 8, 0), IsDefault = true };
var cancel = new Button { Content = "취소", Width = 70, Height = 28, IsCancel = true };
string? result = null;
ok.Click += (_, _) => { result = tb.Text; dlg.DialogResult = true; dlg.Close(); };
cancel.Click += (_, _) => { dlg.DialogResult = false; dlg.Close(); };
btnPanel.Children.Add(ok);
btnPanel.Children.Add(cancel);
sp.Children.Add(label);
sp.Children.Add(tb);
sp.Children.Add(btnPanel);
dlg.Content = sp;
tb.Focus();
return dlg.ShowDialog() == true ? result : null;
}
}

View file

@ -0,0 +1,53 @@
<Window x:Class="Paca.App.LogViewerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="실시간 로그 콘솔 - PACA" Height="500" Width="800"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource WindowBgGradient}"
FontFamily="{DynamicResource UiFont}"
UseLayoutRounding="True"
KeyboardNavigation.TabNavigation="Cycle"
AutomationProperties.Name="실시간 로그 콘솔 창">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 상단 툴바 -->
<DockPanel Grid.Row="0" Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="실시간 시스템 및 다운로드 로그" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center"/>
<TextBlock x:Name="LogCountText" Text="(0줄)" Foreground="{DynamicResource TextMuted}" FontSize="12" Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right">
<CheckBox x:Name="AutoScrollCheck" Content="자동 스크롤" IsChecked="True" Style="{StaticResource DarkCheckBox}" VerticalAlignment="Center" Margin="0,0,10,0"
AutomationProperties.Name="자동 스크롤 토글"/>
<Button Style="{StaticResource GhostButton}" Content="전체 복사" Click="CopyAll_Click" Margin="0,0,6,0" Padding="8,4" FontSize="11"
AutomationProperties.Name="로그 전체 복사"/>
<Button Style="{StaticResource GhostButton}" Content="지우기" Click="Clear_Click" Margin="0,0,6,0" Padding="8,4" FontSize="11"
AutomationProperties.Name="로그 지우기"/>
<Button Style="{StaticResource GhostButton}" Content="로그 폴더 열기" Click="OpenFolder_Click" Padding="8,4" FontSize="11"
AutomationProperties.Name="로그 저장 폴더 열기"/>
</StackPanel>
</DockPanel>
<!-- 로그 텍스트 박스 -->
<Border Grid.Row="1" Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="8" Padding="6">
<TextBox x:Name="LogBox" Style="{StaticResource DarkTextBox}"
FontFamily="{DynamicResource MonoFont}" FontSize="11.5"
IsReadOnly="True" AcceptsReturn="True" TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
AutomationProperties.Name="실시간 로그 콘솔 텍스트"
BorderThickness="0" Background="Transparent" Foreground="{DynamicResource TextPrimary}"/>
</Border>
<!-- 하단 닫기 바 -->
<DockPanel Grid.Row="2" Margin="0,8,0,0">
<TextBlock Text="다운로드 요청, HLS/Direct 엔진 진행 상태, 웹뷰 메시지가 실시간으로 기록됩니다." Foreground="{DynamicResource TextMuted}" FontSize="11" VerticalAlignment="Center"/>
<Button DockPanel.Dock="Right" Style="{StaticResource PrimaryButton}" Content="닫기" Click="Close_Click" IsCancel="True" HorizontalAlignment="Right" Padding="14,6" FontSize="12"
AutomationProperties.Name="로그 창 닫기"/>
</DockPanel>
</Grid>
</Window>

View file

@ -0,0 +1,77 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Windows;
namespace Paca.App;
public partial class LogViewerWindow : Window
{
private readonly string _logFolder;
private int _lineCount;
private readonly StringBuilder _buffer = new();
public LogViewerWindow(string initialLogs, string logFolder)
{
InitializeComponent();
_logFolder = logFolder;
if (!string.IsNullOrEmpty(initialLogs))
{
_buffer.Append(initialLogs);
LogBox.Text = initialLogs;
UpdateLineCount();
if (AutoScrollCheck.IsChecked == true)
LogBox.ScrollToEnd();
}
}
public void AppendLog(string message)
{
Dispatcher.BeginInvoke(new Action(() =>
{
_buffer.AppendLine(message);
LogBox.AppendText(message + Environment.NewLine);
UpdateLineCount();
if (AutoScrollCheck.IsChecked == true)
LogBox.ScrollToEnd();
}));
}
private void UpdateLineCount()
{
_lineCount = LogBox.LineCount;
LogCountText.Text = $"({_lineCount}줄)";
}
private void CopyAll_Click(object sender, RoutedEventArgs e)
{
try
{
Clipboard.SetText(LogBox.Text);
}
catch { }
}
private void Clear_Click(object sender, RoutedEventArgs e)
{
_buffer.Clear();
LogBox.Clear();
UpdateLineCount();
}
private void OpenFolder_Click(object sender, RoutedEventArgs e)
{
try
{
if (Directory.Exists(_logFolder))
Process.Start(new ProcessStartInfo("explorer.exe", _logFolder) { UseShellExecute = true });
}
catch { }
}
private void Close_Click(object sender, RoutedEventArgs e)
{
Close();
}
}

File diff suppressed because it is too large Load diff

3247
src/Paca.App/MainWindow.xaml Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,69 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<LangVersion>latest</LangVersion>
<RootNamespace>Paca.App</RootNamespace>
<AssemblyName>Paca</AssemblyName>
<!-- 멀티 아키텍처 배포: win-x64 + win-arm64 (self-contained 폴더). PlatformTarget 은 RID 에서 파생. -->
<Platforms>x64;arm64</Platforms>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>false</PublishReadyToRun>
<Version>1.0.0</Version>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\paca.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Resource Include="Assets\paca.ico" />
<Resource Include="Assets\paca.png" />
<Resource Include="Assets\Fonts\lucide.ttf" />
<Resource Include="Assets\Avatars\*.png" />
<Content Include="Assets\Avatars\*.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Paca.Core\Paca.Core.csproj" />
<ProjectReference Include="..\Paca.Browser\Paca.Browser.csproj" />
<ProjectReference Include="..\Paca.Server\Paca.Server.csproj" />
<ProjectReference Include="..\Paca.Mobile.Core\Paca.Mobile.Core.csproj" />
</ItemGroup>
<!-- .NET 10 SDK 의 net8.0-windows WPF implicit usings 보강 -->
<ItemGroup>
<Using Include="System.IO" />
<Using Include="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="System.Speech" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
</ItemGroup>
<!-- T2-4 리더 모드: Mozilla Readability.js 번들 (MIT) -->
<ItemGroup>
<EmbeddedResource Include="Assets/readability.js" />
<EmbeddedResource Include="Assets/hls.min.js" />
<EmbeddedResource Include="Assets/dash.all.min.js" />
</ItemGroup>
<!-- E2E 테스트(InternalsVisibleTo) -->
<ItemGroup>
<InternalsVisibleTo Include="Paca.Tests" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace Paca.App;
/// <summary>
/// 프로필 아바타 이미지(프리셋 20종 및 커스텀 이미지 업로드) 관리 및 브러시 생성 헬퍼.
/// </summary>
public static class ProfileAvatarHelper
{
private static readonly string[] SupportedExtensions = [".png", ".jpg", ".jpeg", ".webp", ".bmp"];
public static bool IsSupportedImageExtension(string? extension)
{
if (string.IsNullOrWhiteSpace(extension)) return false;
var ext = extension.Trim();
if (!ext.StartsWith('.')) ext = "." + ext;
return SupportedExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
}
public static IReadOnlyList<string> GetPresetAvatarPaths()
{
// 1. 배포/실행 디렉터리 내 Assets/Avatars 경로 탐색
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
var avatarDir = Path.Combine(baseDir, "Assets", "Avatars");
// 개발 환경(bin/Debug 폴더)에서 상위 프로젝트 경로 fallback
if (!Directory.Exists(avatarDir))
{
var projectDir = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "Assets", "Avatars"));
if (Directory.Exists(projectDir)) avatarDir = projectDir;
}
if (Directory.Exists(avatarDir))
{
var files = Directory.GetFiles(avatarDir, "avatar_*.png")
.OrderBy(f => f)
.Take(20)
.ToList();
if (files.Count > 0) return files;
}
// pack URI fallback
return Enumerable.Range(1, 20)
.Select(i => $"pack://application:,,,/Assets/Avatars/avatar_{i:D2}.png")
.ToList();
}
public static ImageBrush? CreateAvatarBrush(string? imagePath)
{
if (string.IsNullOrWhiteSpace(imagePath)) return null;
try
{
BitmapImage bitmap = new();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
if (imagePath.StartsWith("pack://", StringComparison.OrdinalIgnoreCase))
{
bitmap.UriSource = new Uri(imagePath);
}
else
{
if (!File.Exists(imagePath)) return null;
bitmap.UriSource = new Uri(Path.GetFullPath(imagePath));
}
bitmap.EndInit();
bitmap.Freeze();
var brush = new ImageBrush(bitmap)
{
Stretch = Stretch.UniformToFill,
AlignmentX = AlignmentX.Center,
AlignmentY = AlignmentY.Center
};
brush.Freeze();
return brush;
}
catch
{
return null;
}
}
public static string SaveCustomAvatar(string sourceFilePath, string appDataDir, string profileId)
{
if (!File.Exists(sourceFilePath))
throw new FileNotFoundException("소스 이미지 파일을 찾을 수 없습니다.", sourceFilePath);
var ext = Path.GetExtension(sourceFilePath).ToLowerInvariant();
if (!IsSupportedImageExtension(ext))
throw new InvalidOperationException($"지원하지 않는 이미지 형식입니다: {ext}");
var targetDir = Path.Combine(appDataDir, "avatars");
Directory.CreateDirectory(targetDir);
var targetFileName = $"{profileId}_{DateTime.UtcNow.Ticks}{ext}";
var targetFilePath = Path.Combine(targetDir, targetFileName);
File.Copy(sourceFilePath, targetFilePath, overwrite: true);
return targetFilePath;
}
}

View file

@ -0,0 +1,41 @@
<Window x:Class="Paca.App.QrCodeDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="이 페이지의 QR 코드" Height="440" Width="360"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
UseLayoutRounding="True" ResizeMode="NoResize"
KeyboardNavigation.TabNavigation="Cycle"
AutomationProperties.Name="QR 코드 공유 창">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 헤더 -->
<StackPanel Grid.Row="0" Margin="0,0,0,14">
<TextBlock Text="이 페이지의 QR 코드" FontSize="15" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock x:Name="PageTitleText" Text="페이지 제목" FontSize="11" Foreground="{DynamicResource TextSecondary}" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<!-- QR 코드 카드 -->
<Border Grid.Row="1" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource GlassBorder}" BorderThickness="1" CornerRadius="12" Padding="16" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel HorizontalAlignment="Center">
<Border Background="White" CornerRadius="8" Padding="12" Margin="0,0,0,12" SnapsToDevicePixels="True">
<Image x:Name="QrImage" Width="200" Height="200" Stretch="Uniform" RenderOptions.BitmapScalingMode="NearestNeighbor"/>
</Border>
<TextBox x:Name="UrlDisplayBox" AutomationProperties.Name="공유 URL 텍스트" Style="{StaticResource AddressTextBox}" IsReadOnly="True" FontSize="11" MaxWidth="240"
TextWrapping="NoWrap" HorizontalContentAlignment="Center" Background="Transparent" BorderThickness="0"/>
</StackPanel>
</Border>
<!-- 하단 액션 버튼 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button x:Name="CopyUrlBtn" AutomationProperties.Name="URL 복사" Style="{StaticResource GhostButton}" Content="URL 복사" Padding="12,6" Margin="0,0,8,0" Click="CopyUrlBtn_Click"/>
<Button x:Name="DoneBtn" AutomationProperties.Name="완료" Style="{StaticResource PrimaryButton}" Content="완료" Padding="16,6" FontWeight="SemiBold" Click="DoneBtn_Click"/>
</StackPanel>
</Grid>
</Window>

View file

@ -0,0 +1,58 @@
using System;
using System.IO;
using System.Windows;
using System.Windows.Media.Imaging;
using QRCoder;
namespace Paca.App;
public partial class QrCodeDialog : Window
{
public string PageUrl { get; }
public string PageTitle { get; }
public QrCodeDialog(string url, string title)
{
InitializeComponent();
PageUrl = url;
PageTitle = title;
PageTitleText.Text = string.IsNullOrWhiteSpace(title) ? url : title;
UrlDisplayBox.Text = url;
GenerateQrImage(url);
}
private void GenerateQrImage(string url)
{
try
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(string.IsNullOrWhiteSpace(url) ? " " : url, QRCodeGenerator.ECCLevel.M);
var qr = new QRCode(data);
using var bmp = qr.GetGraphic(8);
using var ms = new MemoryStream();
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
ms.Position = 0;
var frame = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
frame.Freeze();
QrImage.Source = frame;
}
catch { }
}
private void CopyUrlBtn_Click(object sender, RoutedEventArgs e)
{
try
{
Clipboard.SetText(PageUrl);
CopyUrlBtn.Content = "복사됨 ✓";
}
catch { }
}
private void DoneBtn_Click(object sender, RoutedEventArgs e)
{
Close();
}
}

View file

@ -0,0 +1,397 @@
<Window x:Class="Paca.App.QuickActionLauncherWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="PACA Quick Actions"
Width="860" Height="540"
WindowStartupLocation="Manual"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ShowInTaskbar="False"
Topmost="True"
FontFamily="{DynamicResource UiFont}"
Deactivated="Window_Deactivated"
PreviewKeyDown="Window_PreviewKeyDown">
<Window.Resources>
<!-- 부드러운 팝업 등장 애니메이션 (CubicEase 감속, 150ms) -->
<Storyboard x:Key="OpenLauncherStoryboard">
<DoubleAnimation Storyboard.TargetName="LauncherScaleTransform"
Storyboard.TargetProperty="ScaleX"
From="0.96" To="1.0" Duration="0:0:0.150">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="LauncherScaleTransform"
Storyboard.TargetProperty="ScaleY"
From="0.96" To="1.0" Duration="0:0:0.150">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="LauncherTranslateTransform"
Storyboard.TargetProperty="Y"
From="-8" To="0" Duration="0:0:0.150">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="RootCardBorder"
Storyboard.TargetProperty="Opacity"
From="0" To="1" Duration="0:0:0.150">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
<!-- 부드러운 닫힘 애니메이션 (100ms) -->
<Storyboard x:Key="CloseLauncherStoryboard">
<DoubleAnimation Storyboard.TargetName="RootCardBorder"
Storyboard.TargetProperty="Opacity"
From="1" To="0" Duration="0:0:0.100">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseIn"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="LauncherScaleTransform"
Storyboard.TargetProperty="ScaleX"
From="1.0" To="0.98" Duration="0:0:0.100">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseIn"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="LauncherScaleTransform"
Storyboard.TargetProperty="ScaleY"
From="1.0" To="0.98" Duration="0:0:0.100">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseIn"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
<Style x:Key="ResultItemContainer" TargetType="ListBoxItem">
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Margin" Value="0,2"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="ItemBorder" Background="{TemplateBinding Background}"
CornerRadius="8" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<Grid>
<Border x:Name="AccentIndicator" HorizontalAlignment="Left" Width="3" Height="22"
Background="{DynamicResource AccentBrush}" CornerRadius="1.5"
Visibility="Collapsed" Margin="0"/>
<ContentPresenter Margin="2,0,0,0"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource SelectionBrush}"/>
<Setter TargetName="AccentIndicator" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource CardBgHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SpotlightSearchBoxStyle" TargetType="TextBox">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="CaretBrush" Value="{DynamicResource AccentBrush}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource SelectionBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="FontSize" Value="16.5"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border Background="Transparent" BorderThickness="0">
<ScrollViewer x:Name="PART_ContentHost" Margin="0" Padding="0"
Focusable="False" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<!-- 외곽 스포트라이트 카드 (스케일/페이드 애니메이션 & 2단 분할 레이아웃) -->
<Grid Margin="20" MouseLeftButtonDown="Window_MouseLeftButtonDown">
<Border x:Name="RootCardBorder"
Background="{DynamicResource WindowBgGradient}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1"
CornerRadius="12"
Opacity="0"
SnapsToDevicePixels="True"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform x:Name="LauncherScaleTransform" ScaleX="0.96" ScaleY="0.96"/>
<TranslateTransform x:Name="LauncherTranslateTransform" X="0" Y="-8"/>
</TransformGroup>
</Border.RenderTransform>
<Border.Effect>
<DropShadowEffect BlurRadius="24" ShadowDepth="6" Opacity="0.38" Color="#000000"/>
</Border.Effect>
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 1. 상단: 스마트 클립보드 추천 배너 + 검색창 및 인라인 고스트 자동완성 힌트 -->
<StackPanel Grid.Row="0">
<Border x:Name="ClipboardSuggestionBanner" Visibility="Collapsed"
Background="{DynamicResource CardBgHover}"
BorderBrush="{DynamicResource AccentBrush}"
BorderThickness="1" CornerRadius="8" Padding="10,6" Margin="0,0,0,8"
Cursor="Hand" MouseLeftButtonDown="ClipboardSuggestionBanner_MouseLeftButtonDown">
<DockPanel LastChildFill="True">
<TextBlock Text="⚡" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Button DockPanel.Dock="Right" Style="{DynamicResource IconButton}" Content="&#xE711;"
Width="20" Height="20" FontSize="10" Click="ClipboardBannerDismiss_Click"
ToolTip="제안 닫기"/>
<TextBlock x:Name="ClipboardBannerTitle" Text="클립보드 추천"
FontSize="12.5" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
</DockPanel>
</Border>
<Border Background="{DynamicResource CardBg}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="8" Padding="12,8">
<DockPanel LastChildFill="True">
<TextBlock Text="&#xE721;" FontFamily="Segoe MDL2 Assets" FontSize="18"
Foreground="{DynamicResource TextMuted}" VerticalAlignment="Center" Margin="0,0,10,0"/>
<!-- ActionPill (캡슐 형태의 Scoped Action Badge: Tab 활성화 시 텍스트 감싸기 & 콜론) -->
<Border x:Name="ActionPill" DockPanel.Dock="Left" Visibility="Collapsed"
Background="{DynamicResource AccentFadeBrush}"
BorderBrush="{DynamicResource AccentBrush}"
BorderThickness="1" CornerRadius="6"
Padding="8,3" Margin="0,0,8,0"
VerticalAlignment="Center"
RenderTransformOrigin="0.5,0.5"
ToolTip="클릭하거나 Backspace를 누르면 해제됩니다"
Cursor="Hand"
MouseLeftButtonDown="ActionPill_MouseLeftButtonDown">
<Border.RenderTransform>
<ScaleTransform x:Name="ActionPillScaleTransform" ScaleX="1" ScaleY="1"/>
</Border.RenderTransform>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="ActionPillIcon" Text="&#xE707;" FontFamily="Segoe MDL2 Assets"
FontSize="12" Foreground="{DynamicResource AccentBrush}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock x:Name="ActionPillTitle" Text="카카오맵 장소 검색"
FontSize="12.5" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center"/>
<TextBlock x:Name="ActionPillColon" Text=" :"
FontSize="12.5" FontWeight="Bold"
Foreground="{DynamicResource AccentBrush}"
VerticalAlignment="Center" Margin="2,0,0,0"/>
</StackPanel>
</Border>
<Grid VerticalAlignment="Center">
<!-- 고스트 자동완성 힌트 텍스트 및 Pill 모드 플레이스홀더 (0px 오차 픽셀 정합) -->
<TextBlock x:Name="AutocompleteHintText" Text=""
FontFamily="{DynamicResource UiFont}"
FontSize="16.5" Foreground="{DynamicResource TextMuted}"
Opacity="0.45" VerticalAlignment="Center"
Padding="0" Margin="0"
IsHitTestVisible="False" SnapsToDevicePixels="True"/>
<TextBox x:Name="QuickActionSearchBox"
Style="{StaticResource SpotlightSearchBoxStyle}"
AutomationProperties.Name="퀵 액션 검색창"
TextChanged="QuickActionSearchBox_TextChanged"/>
</Grid>
</DockPanel>
</Border>
</StackPanel>
<!-- 2. 본문: 2단 분할 그리드 (좌: 카테고리칩 + 수식평가 + 결과리스트, 우: 디테일 프리뷰 패널) -->
<Grid Grid.Row="1" Margin="0,8,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="280"/>
</Grid.ColumnDefinitions>
<!-- 좌측: 카테고리 칩 + 리스트 -->
<Grid Grid.Column="0" Margin="0,0,8,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 카테고리 칩 필터 바: 가로 스크롤바 관통 버그 원천 박멸(Hidden) 및 마우스 휠 부드러운 스크롤 지원 -->
<ScrollViewer x:Name="CategoryChipScrollViewer" Grid.Row="0"
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
PreviewMouseWheel="CategoryChipScrollViewer_PreviewMouseWheel"
Margin="0,0,0,6" Focusable="False">
<StackPanel x:Name="CategoryChipBar" Orientation="Horizontal"/>
</ScrollViewer>
<!-- 인라인 실시간 계산/변환 평가 카드 -->
<Border x:Name="InlineEvaluatorCard" Grid.Row="1" Visibility="Collapsed"
Background="{DynamicResource CardBgHover}"
BorderBrush="{DynamicResource AccentBrush}"
BorderThickness="1" CornerRadius="8" Padding="10,7" Margin="0,0,0,6">
<DockPanel LastChildFill="True">
<TextBlock Text="=" FontWeight="Bold" FontSize="18" Foreground="{DynamicResource AccentBrush}"
VerticalAlignment="Center" Margin="0,0,10,0"/>
<StackPanel Orientation="Vertical">
<TextBlock x:Name="InlineEvaluatorValue" FontSize="14.5" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" TextTrimming="CharacterEllipsis"/>
<TextBlock x:Name="InlineEvaluatorDesc" FontSize="11" Foreground="{DynamicResource TextMuted}"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DockPanel>
</Border>
<!-- 액션 검색 결과 리스트 (Ctrl+1~9 퀵 배지 & 다국어 바인딩) -->
<ListBox x:Name="QuickActionResultsList" Grid.Row="2"
Background="Transparent" BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ItemContainerStyle="{StaticResource ResultItemContainer}"
SelectionChanged="QuickActionResultsList_SelectionChanged"
MouseMove="QuickActionResultsList_MouseMove"
MouseDoubleClick="QuickActionResultsList_MouseDoubleClick">
<ListBox.ItemTemplate>
<DataTemplate>
<DockPanel LastChildFill="True">
<!-- 우측: 다국어화된 카테고리 배지 -->
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Border Background="{DynamicResource BorderSubtle}" CornerRadius="4" Padding="5,2" Margin="6,0,0,0">
<TextBlock Text="{Binding CategoryName}" FontSize="10" Foreground="{DynamicResource TextSecondary}"/>
</Border>
</StackPanel>
<!-- 좌측: Ctrl+1~9 퀵 숏컷 번호 배지 -->
<Border DockPanel.Dock="Left" Width="18" Height="18"
Background="{DynamicResource BorderSubtle}"
CornerRadius="4" Margin="0,0,8,0" VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayIndex}" FontSize="10" FontWeight="Bold"
Foreground="{DynamicResource TextMuted}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<!-- 아이콘 -->
<TextBlock Text="{Binding Action.IconGlyph}" FontFamily="Segoe MDL2 Assets"
FontSize="14.5" Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<!-- 제목 및 키워드 (다국어 바인딩) -->
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="Medium"
Foreground="{DynamicResource TextPrimary}" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Keywords}" FontSize="10.5"
Foreground="{DynamicResource TextMuted}" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DockPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<!-- 우측: 디테일 프리뷰 패널 (ActionDetailPane) -->
<Border x:Name="ActionDetailPane" Grid.Column="1"
Background="{DynamicResource CardBg}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="8" Padding="14">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 아이콘 및 카테고리 태그 -->
<DockPanel Grid.Row="0" LastChildFill="False" Margin="0,0,0,12">
<Border Background="{DynamicResource CardBgHover}" CornerRadius="8" Width="40" Height="40"
HorizontalAlignment="Left">
<TextBlock x:Name="DetailActionIcon" Text="&#xE710;" FontFamily="Segoe MDL2 Assets"
FontSize="20" Foreground="{DynamicResource AccentBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center"/>
</Border>
<Border x:Name="DetailCategoryBadge" DockPanel.Dock="Right"
Background="{DynamicResource BorderSubtle}" CornerRadius="4"
Padding="8,3" VerticalAlignment="Center">
<TextBlock x:Name="DetailCategoryText" Text="Browser" FontSize="10.5"
FontWeight="SemiBold" Foreground="{DynamicResource TextSecondary}"/>
</Border>
</DockPanel>
<!-- 액션 제목 및 ID -->
<StackPanel Grid.Row="1" Margin="0,0,0,12">
<TextBlock x:Name="DetailActionTitle" Text="액션 이름"
FontSize="15" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}"
TextWrapping="Wrap" Margin="0,0,0,4"/>
<TextBlock x:Name="DetailActionId" Text="action.id"
FontSize="10.5" Foreground="{DynamicResource TextMuted}"
FontFamily="Consolas" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<!-- 상세 설명 및 가이드 -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="설명" FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource TextMuted}" Margin="0,0,0,4"/>
<TextBlock x:Name="DetailActionDesc" Text="액션에 대한 상세 설명입니다."
FontSize="12" Foreground="{DynamicResource TextSecondary}"
TextWrapping="Wrap" LineHeight="18" Margin="0,0,0,14"/>
<TextBlock Text="단축키 / 인자" FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource TextMuted}" Margin="0,0,0,4"/>
<Border Background="{DynamicResource CardBgHover}" CornerRadius="6" Padding="8,6" Margin="0,0,0,8">
<TextBlock x:Name="DetailShortcutHint" Text="Enter: 즉시 실행"
FontSize="11.5" Foreground="{DynamicResource AccentBrush}"
FontFamily="Consolas"/>
</Border>
</StackPanel>
</ScrollViewer>
<!-- 하단 브랜드 태그 -->
<Border Grid.Row="3" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="0,8,0,0">
<TextBlock Text="PACA Spotlight Inspector" FontSize="10" Foreground="{DynamicResource TextMuted}"/>
</Border>
</Grid>
</Border>
</Grid>
<!-- 3. 하단 상태 바 (다국어 단축키 가이드 및 실시간 피드백) -->
<Border Grid.Row="2" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="4,8,4,0">
<DockPanel LastChildFill="True">
<TextBlock x:Name="StatusCountText" DockPanel.Dock="Right" Text="PACA Actions" FontSize="11"
Foreground="{DynamicResource TextMuted}"/>
<TextBlock x:Name="StatusShortcutsText"
Text="Enter: 실행 | Ctrl+Enter: 복사 | Tab: 자동완성 | Ctrl+1~9: 즉시 실행 | Esc: 닫기"
FontSize="11" Foreground="{DynamicResource TextSecondary}"/>
</DockPanel>
</Border>
</Grid>
</Border>
</Grid>
</Window>

File diff suppressed because it is too large Load diff

63
src/Paca.App/README.md Normal file
View file

@ -0,0 +1,63 @@
# Video Downloader (WPF 데스크톱 앱)
임베디드 브라우저(WebView2)로 영상을 재생하면, 네트워크 요청을 자동으로 관찰해
`.m3u8`(HLS)를 감지하고 **즉시 MP4로 다운로드**하는 Windows 데스크톱 앱.
> 브라우저에서 재생만 하면 됩니다 — m3u8 주소를 직접 찾을 필요가 없습니다.
## 구조
- `VideoDownloader.Core` (net8.0) — HLS 다운로더 엔진 (파서/AES-128/fMP4/병렬다운로드/ffmpeg 리먹스). UI/브라우저 의존성 없음.
- `VideoDownloader.Cli` (net8.0) — 엔진 단독 테스트 하네스 (m3u8 URL 직접 입력 → MP4).
- `VideoDownloader.App` (net8.0-windows, WPF + WebView2) — 데스크톱 앱 본체.
## 요구사항
- **.NET 8 SDK** 이상 (Windows)
- **WebView2 런타임** — Windows 11 은 기본 설치됨 (Win10 은 Microsoft Edge 기반 자동 설치)
- **ffmpeg** — 시스템 PATH 에 있으면 자동 사용 (TS/fMP4 → MP4 리먹스). 없으면 설정에서 경로 지정.
## 빌드 및 실행
```bash
# 빌드 (x64 필수 — net8.0-windows WPF)
dotnet build VideoDownloader.App/VideoDownloader.App.csproj -c Debug -p:Platform=x64
# 실행
VideoDownloader.App\bin\x64\Debug\net8.0-windows\VideoDownloader.exe
# 또는
dotnet run --project VideoDownloader.App -p:Platform=x64
```
배포용 단일 디렉토리 게시:
```bash
dotnet publish VideoDownloader.App/VideoDownloader.App.csproj -c Release -p:Platform=x64 -r win-x64 --self-contained false -o publish
```
## 사용법
1. 앱 실행 → 주소창에 재생페이지 URL 입력 후 **Enter**
2. 페이지에서 영상 **재생**
3. 영상이 재생되는 순간 앱이 `.m3u8` 요청을 자동 감지 → 하단 **다운로드 목록**에 진행률 표시
4. 완료되면 `%USERPROFILE%\Videos\VideoDownloader\` 에 MP4 저장
- 파일명은 페이지 제목(document.title)에서 자동 생성
- 같은 영상은 자동으로 중복 다운로드 방지
### 설정 (상단 **설정** 버튼)
- 저장 폴더 (기본: `%USERPROFILE%\Videos\VideoDownloader`)
- 동시 다운로드 연결 수 (기본 8)
- 자동 다운로드 토글 (기본 켜짐 — 감지 즉시 받기)
- ffmpeg 경로 (비워두면 PATH 사용)
## 작동 원리
1. **탐지**: WebView2 의 `WebResourceRequested` + `All` 리소스 필터로 모든 네트워크 요청 관찰 → URL 경로에 `.m3u8` 이면 감지
2. **해석**: 감지된 m3u8 을 재요청(브라우저 쿠키 + Referer 복사) → 마스터 재생목록이면 **최고 화질** 변환 선택 → 미디어 재생목록 확정 (최종 URL 기준 중복제거)
3. **다운로드**: 세그먼트 병렬 다운로드(이어백기 지원) + AES-128 복호화 + fMP4 처리 → 병합 → ffmpeg `-c copy` 무손실 리먹스 → MP4
## 문제 해결
- **영상이 감지되지 않음** — 사이트가 DRM(Widevine)을 쓰거나 비-HLS(mp4 직링크/재생자체 난독화)일 수 있음. DRM 영상은 다운로드 불가.
- **403/401 오류** — 쿠키/Referer 자동 복사가 안 된 경우. 페이지에서 실제로 재생을 시작했는지 확인 (재생해야 토큰 URL이 발행됨).
- **MP4가 안 만들어짐** — ffmpeg 확인 (`ffmpeg -version`). 설정에서 경로 지정.
- **라이브 방송** — VOD(EXT-X-ENDLIST) 우선 지원. 슬라이딩 윈도우 라이브는 v1 미지원.
- 엔진 단독 점검: `dotnet run --project VideoDownloader.Cli -- "<m3u8-url>"`
## 범위 외
- DRM/Widevine 암호화 영상 (복호화 불가)
- 라이브 스트림(sliding window)
- 오디오/자막 트랙 분리

View file

@ -0,0 +1,294 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Paca.App.Core;
namespace Paca.App.Services;
/// <summary>
/// PACA 데스크톱 애플리케이션 화면 디버깅 캡처 서비스.
/// Win32 GDI / PrintWindow / RenderTargetBitmap 하이브리드 파이프라인으로
/// WPF 및 WebView2(HWND) 자식 컨트롤이 포함된 실제 앱 윈도우 전체를 스크린샷으로 캡처하여 저장한다.
/// </summary>
public static class AppDebugScreenshotService
{
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[DllImport("user32.dll")]
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll")]
private static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBkgnd, uint nFlags);
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern bool BringWindowToTop(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
private const int SW_RESTORE = 9;
private const uint PW_RENDERFULLCONTENT = 0x00000002;
private static DispatcherTimer? _triggerTimer;
public static string GetDefaultScreenshotPath() =>
Path.Combine(AppPaths.DataDir, "debug_screenshot.png");
public static string ResolveTriggerPath(string? input)
{
if (string.IsNullOrWhiteSpace(input))
return GetDefaultScreenshotPath();
return input.Trim();
}
/// <summary>
/// 대상 Window의 현재 화면을 PNG 파일로 캡처하여 저장한다.
/// </summary>
public static bool CaptureWindow(Window window, string outputPath)
{
if (window == null) return false;
try
{
var dir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
var helper = new WindowInteropHelper(window);
var hWnd = helper.Handle;
if (hWnd != IntPtr.Zero && GetWindowRect(hWnd, out var rect))
{
try
{
ShowWindow(hWnd, SW_RESTORE);
SetForegroundWindow(hWnd);
BringWindowToTop(hWnd);
System.Threading.Thread.Sleep(200);
GetWindowRect(hWnd, out rect);
}
catch { }
int width = rect.Right - rect.Left;
int height = rect.Bottom - rect.Top;
if (width > 0 && height > 0)
{
try
{
using var bmp = new System.Drawing.Bitmap(width, height);
using (var g = System.Drawing.Graphics.FromImage(bmp))
{
var hdc = g.GetHdc();
try
{
PrintWindow(hWnd, hdc, PW_RENDERFULLCONTENT);
}
finally
{
g.ReleaseHdc(hdc);
}
// WebView2 자식 컨트롤 실시간 렌더링 화면 비트맵 오버레이
OverlayAdBanners(window, g, width, height);
}
bmp.Save(outputPath, System.Drawing.Imaging.ImageFormat.Png);
if (File.Exists(outputPath) && new FileInfo(outputPath).Length > 0)
{
return true;
}
}
catch
{
// GDI 캡처 실패 시 하단 WPF RenderTargetBitmap 폴백 진행
}
}
}
// 폴백: 순수 WPF 비주얼 트리 렌더링
int rtbWidth = (int)Math.Max(1, window.ActualWidth > 0 ? window.ActualWidth : window.Width);
int rtbHeight = (int)Math.Max(1, window.ActualHeight > 0 ? window.ActualHeight : window.Height);
if (rtbWidth <= 1) rtbWidth = 1024;
if (rtbHeight <= 1) rtbHeight = 768;
var rtb = new RenderTargetBitmap(rtbWidth, rtbHeight, 96, 96, PixelFormats.Pbgra32);
rtb.Render(window);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(rtb));
using (var fs = File.Create(outputPath))
{
encoder.Save(fs);
}
return File.Exists(outputPath) && new FileInfo(outputPath).Length > 0;
}
catch
{
return false;
}
}
/// <summary>
/// 외부 신호 파일(%AppData%/VideoDownloader/screenshot_trigger.req)을 감지하여
/// 백그라운드에서 실시간 스크린샷을 찍도록 타이머를 구동한다.
/// </summary>
public static void StartFileTriggerWatcher(Window window, Action<string>? onCaptured = null)
{
if (_triggerTimer != null) return;
var triggerFile = Path.Combine(AppPaths.DataDir, "screenshot_trigger.req");
_triggerTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(1)
};
_triggerTimer.Tick += (_, _) =>
{
try
{
if (File.Exists(triggerFile))
{
string target = "";
try
{
target = File.ReadAllText(triggerFile);
File.Delete(triggerFile);
}
catch
{
// 읽기 경합 방어
return;
}
var savePath = ResolveTriggerPath(target);
bool ok = CaptureWindow(window, savePath);
if (ok)
{
onCaptured?.Invoke(savePath);
}
}
}
catch
{
// 백그라운드 폴러 방어
}
};
_triggerTimer.Start();
}
/// <summary>
/// 앱 실행 후 指定된 지연 시간(기본 3초) 뒤에 자동으로 스크린샷을 찍어 저장한다.
/// </summary>
public static void ScheduleAutoCapture(Window window, int delayMs = 3000, string? targetPath = null)
{
Task.Delay(delayMs).ContinueWith(_ =>
{
window.Dispatcher.Invoke(() =>
{
var dest = ResolveTriggerPath(targetPath);
CaptureWindow(window, dest);
});
});
}
private static void OverlayAdBanners(Window window, System.Drawing.Graphics g, int windowWidth, int windowHeight)
{
try
{
window.Dispatcher.Invoke(() =>
{
var banners = FindVisualChildren<Paca.App.Controls.DesktopAdBannerControl>(window);
foreach (var banner in banners)
{
if (banner.Visibility != Visibility.Visible || banner.ActualWidth <= 0 || banner.ActualHeight <= 0)
continue;
var previewPath = Path.Combine(AppPaths.DataDir, "ad_previews", $"ad_{banner.Placement}.png");
if (!File.Exists(previewPath)) continue;
try
{
var transform = banner.TransformToAncestor(window);
var point = transform.Transform(new Point(0, 0));
using var fs = new FileStream(previewPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var adImg = System.Drawing.Image.FromStream(fs);
int x = (int)point.X;
int y = (int)point.Y;
int w = (int)banner.ActualWidth;
int h = (int)banner.ActualHeight;
if (banner.Placement == Paca.Browser.Ads.DesktopAdPlacement.BottomAppBanner)
{
x += (w - 728) / 2;
y += (h - 90) / 2;
w = 728;
h = 90;
}
else if (banner.Placement == Paca.Browser.Ads.DesktopAdPlacement.DownloadViewBanner)
{
x += (w - 300) / 2;
y += (h - 250) / 2;
w = 300;
h = 250;
}
g.DrawImage(adImg, x, y, w, h);
}
catch (Exception ex)
{
try
{
var p = Path.Combine(AppPaths.DataDir, "ad_debug.log");
File.AppendAllText(p, $"[{DateTime.Now:HH:mm:ss.fff}] Overlay exception for {banner.Placement}: {ex.Message}\n");
}
catch { }
}
}
});
}
catch { }
}
private static IEnumerable<T> FindVisualChildren<T>(DependencyObject depObj) where T : DependencyObject
{
if (depObj == null) yield break;
int count = VisualTreeHelper.GetChildrenCount(depObj);
for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(depObj, i);
if (child is T match)
{
yield return match;
}
foreach (var childOfChild in FindVisualChildren<T>(child))
{
yield return childOfChild;
}
}
}
}

View file

@ -0,0 +1,90 @@
using System.Windows.Threading;
using Paca.App.ViewModels;
using Paca.Server;
using Paca.Core.Detection;
namespace Paca.App.Services;
/// <summary>
/// DownloadManager(UI 컬렉션) ↔ 서버 큐 API 브릿지. 모든 접근을 UI 디스패처로 마샬링.
/// </summary>
public sealed class DownloadQueueAdapter : IQueueAdapter
{
private readonly Core.DownloadManager _manager;
private readonly Dispatcher _dispatcher;
public DownloadQueueAdapter(Core.DownloadManager manager, Dispatcher dispatcher)
{
_manager = manager;
_dispatcher = dispatcher;
}
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => _dispatcher.InvokeAsync(() =>
(IReadOnlyList<QueueItemDto>)_manager.Items.Select(Map).ToList()).Task;
public Task<string> AddAsync(string url, string? title)
{
// 서버에서 온 URL → 감지 파이프라인으로(소셜/HLS/DASH 자동 분류) 및 자동 다운로드 시작
return _dispatcher.InvokeAsync(() =>
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
SocialSiteDetector.TryMatch(uri, out var normalized, out var label))
{
Action<DownloadItemViewModel>? handler = null;
handler = item =>
{
if (item.MediaUrl?.ToString() == normalized)
{
_manager.ItemDetected -= handler;
_ = _manager.StartDownload(item);
}
};
_manager.ItemDetected += handler;
_manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label));
return normalized;
}
Action<DownloadItemViewModel>? directHandler = null;
directHandler = item =>
{
if (item.MediaUrl?.ToString() == url)
{
_manager.ItemDetected -= directHandler;
_ = _manager.StartDownload(item);
}
};
_manager.ItemDetected += directHandler;
var kind = MediaDetector.Classify(url, "", -1, _manager.Config) ?? MediaKind.Direct;
_manager.OnDetected(new DetectionContext(url, url, kind, ""));
return url;
}).Task;
}
public Task PauseAsync(string id) => _dispatcher.InvokeAsync(() =>
{
var item = Find(id);
if (item != null) _manager.Cancel(item); // v1: 제어 매핑 — 진행 항목 취소
}).Task;
public Task ResumeAsync(string id) => _dispatcher.InvokeAsync(() =>
{
var item = Find(id);
if (item != null) _ = _manager.RetryDownload(item);
}).Task;
public Task CancelAsync(string id) => _dispatcher.InvokeAsync(() =>
{
var item = Find(id);
if (item != null) _manager.Cancel(item);
}).Task;
public event Action? Changed { add { } remove { } }
private DownloadItemViewModel? Find(string id) =>
_manager.Items.FirstOrDefault(i => i.Id == id);
private static QueueItemDto Map(DownloadItemViewModel i) =>
new(i.Id, i.Title, i.Url ?? "", i.Status ?? "", i.Progress);
}

View file

@ -0,0 +1,59 @@
namespace Paca.App.Services;
/// <summary>
/// UC-D21 다운로드 예약 — 지정 시간대(예: 야간 02:00~08:00)에만 다운로드를 허용하는 게이트.
/// 시간 창은 "HH:mm-HH:mm" 형식. 자정을 가로지르는 창(22:00-06:00)도 지원한다.
/// </summary>
public sealed class DownloadScheduler
{
/// <summary>예약 창("HH:mm-HH:mm"). null/빈이면 항상 허용.</summary>
public string? Window { get; }
/// <summary>예약 창이 설정돼 있으면 true.</summary>
public bool Enabled => !string.IsNullOrWhiteSpace(Window);
public DownloadScheduler(string? window = null)
{
Window = string.IsNullOrWhiteSpace(window) ? null : window.Trim();
}
/// <summary>지정 시각에 다운로드가 허용되는지.</summary>
public bool CanRunNow(DateTime now)
{
if (!Enabled || TryParse(Window!, out var start, out var end) == false) return true;
var t = now.TimeOfDay;
return start <= end
? t >= start && t < end
: t >= start || t < end; // 자정 가로지름(예: 22:00-06:00)
}
public bool IsOpen(DateTime now) => CanRunNow(now);
/// <summary>다음 예약 창 시작까지 남은 시간. 항상 허용이면 Zero.</summary>
public TimeSpan TimeUntilNextWindow(DateTime now)
{
if (!Enabled || !TryParse(Window!, out var start, out _)) return TimeSpan.Zero;
var target = now.Date + start;
if (target <= now) target = target.AddDays(1);
return target - now;
}
/// <summary>"HH:mm-HH:mm" 파싱. 형식이 틀리면 false(게이트는 열린 채 둔다).</summary>
public static bool TryParse(string window, out TimeSpan start, out TimeSpan end)
{
start = end = TimeSpan.Zero;
var parts = (window ?? "").Split('-');
if (parts.Length != 2) return false;
return TryParseTime(parts[0], out start) && TryParseTime(parts[1], out end);
}
private static bool TryParseTime(string s, out TimeSpan ts)
{
ts = TimeSpan.Zero;
var p = s.Trim().Split(':');
if (p.Length != 2 || !int.TryParse(p[0], out var h) || !int.TryParse(p[1], out var m)) return false;
if (h is < 0 or > 23 || m is < 0 or > 59) return false;
ts = new TimeSpan(h, m, 0);
return true;
}
}

View file

@ -0,0 +1,106 @@
using System;
using System.Runtime.InteropServices;
using System.Windows.Input;
namespace Paca.App.Services;
public sealed class GlobalHotkeyService : IDisposable
{
public const uint MOD_ALT = 0x0001;
public const uint MOD_CONTROL = 0x0002;
public const uint MOD_SHIFT = 0x0004;
public const uint MOD_WIN = 0x0008;
public const uint MOD_NOREPEAT = 0x4000;
public const int WM_HOTKEY = 0x0312;
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
private IntPtr _hWnd;
private int _currentId;
private bool _isRegistered;
public bool IsRegistered => _isRegistered;
public event Action? HotkeyPressed;
public bool Register(IntPtr hWnd, int id, string hotkeyStr)
{
Unregister();
if (!TryParseHotkey(hotkeyStr, out uint modifiers, out uint vk))
{
return false;
}
_hWnd = hWnd;
_currentId = id;
_isRegistered = RegisterHotKey(_hWnd, _currentId, modifiers | MOD_NOREPEAT, vk);
return _isRegistered;
}
public void Unregister()
{
if (_isRegistered && _hWnd != IntPtr.Zero)
{
UnregisterHotKey(_hWnd, _currentId);
_isRegistered = false;
}
}
public IntPtr HandleWindowMessages(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_HOTKEY && wParam.ToInt32() == _currentId)
{
HotkeyPressed?.Invoke();
handled = true;
}
return IntPtr.Zero;
}
public static bool TryParseHotkey(string? hotkeyStr, out uint fsModifiers, out uint vk)
{
fsModifiers = 0;
vk = 0;
if (string.IsNullOrWhiteSpace(hotkeyStr)) return false;
var parts = hotkeyStr.Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0) return false;
for (int i = 0; i < parts.Length - 1; i++)
{
var part = parts[i].ToLowerInvariant();
if (part is "ctrl" or "control") fsModifiers |= MOD_CONTROL;
else if (part is "alt") fsModifiers |= MOD_ALT;
else if (part is "shift") fsModifiers |= MOD_SHIFT;
else if (part is "win" or "windows") fsModifiers |= MOD_WIN;
else return false;
}
var keyPart = parts[^1].ToUpperInvariant();
if (keyPart is "SPACE") vk = 0x20;
else if (keyPart is "RETURN" or "ENTER") vk = 0x0D;
else if (keyPart is "ESCAPE" or "ESC") vk = 0x1B;
else if (keyPart.Length == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z') vk = (uint)keyPart[0];
else if (keyPart.Length == 1 && keyPart[0] >= '0' && keyPart[0] <= '9') vk = (uint)keyPart[0];
else if (keyPart.StartsWith("F") && int.TryParse(keyPart[1..], out int fNum) && fNum >= 1 && fNum <= 24)
{
vk = (uint)(0x70 + (fNum - 1)); // VK_F1 = 0x70
}
else
{
return false;
}
return true;
}
public void Dispose()
{
Unregister();
}
}

View file

@ -0,0 +1,119 @@
namespace Paca.App.Services;
/// <summary>단일 단축키 정의(명령 키 + 표시 조합).</summary>
public sealed record ShortcutBinding(string Command, string Gesture);
/// <summary>
/// UC-K02/K03 단축키 관리 — 기본 조합표 위에 사용자 재배치(AppConfig.Shortcuts)를 얹고,
/// 저장 전에 같은 조합을 쓰는 명령 충돌을 검증한다.
/// </summary>
public sealed class ShortcutManager
{
private readonly Dictionary<string, string> _user;
/// <summary>기본 단축키표(명령 키 → 기본 조합).</summary>
public static readonly IReadOnlyDictionary<string, string> Defaults = new Dictionary<string, string>
{
["newtab"] = "Ctrl+T",
["closetab"] = "Ctrl+W",
["reopentab"] = "Ctrl+Shift+T",
["find"] = "Ctrl+F",
["address"] = "Ctrl+L",
["downloads"] = "Ctrl+J",
["palette"] = "Ctrl+K",
["devtools"] = "F12",
["fullscreen"] = "F11",
["refresh"] = "F5",
["print"] = "Ctrl+P",
["pauseall"] = "Ctrl+Shift+P",
};
public ShortcutManager(IDictionary<string, string>? userShortcuts = null)
{
_user = new Dictionary<string, string>(userShortcuts ?? new Dictionary<string, string>(), StringComparer.OrdinalIgnoreCase);
}
/// <summary>명령의 현재 조합(사용자 지정 우선, 없으면 기본값).</summary>
public string GestureFor(string command)
=> _user.TryGetValue(command, out var g) ? g : Defaults.GetValueOrDefault(command, "");
/// <summary>전체 목록(기본 전체 + 사용자 오버라이드 반영).</summary>
public List<ShortcutBinding> All()
=> Defaults.Keys
.Select(c => new ShortcutBinding(c, GestureFor(c)))
.Concat(_user.Where(kv => !Defaults.ContainsKey(kv.Key)).Select(kv => new ShortcutBinding(kv.Key, kv.Value)))
.ToList();
/// <summary>UC-K03 충돌 검증 — 같은 조합에 묶인 명령 그룹(2개 이상일 때만 반환).</summary>
public List<List<ShortcutBinding>> FindConflicts()
=> All()
.GroupBy(b => Normalize(b.Gesture))
.Where(g => g.Count() > 1 && g.Key.Length > 0)
.Select(g => g.ToList())
.ToList();
/// <summary>조합 문자열 파싱("Ctrl+Shift+P" → ModifierKeys+Key). 파싱 실패 시 null.</summary>
public static (System.Windows.Input.ModifierKeys Mods, System.Windows.Input.Key Key)? Parse(string gesture)
{
if (string.IsNullOrWhiteSpace(gesture)) return null;
var s = gesture.Trim();
var mods = System.Windows.Input.ModifierKeys.None;
var key = System.Windows.Input.Key.None;
// "Ctrl++" 와 같이 '+' 키 자체가 마지막 키인 경우 처리
bool endsWithPlus = s.EndsWith("++", StringComparison.Ordinal) || s == "+";
if (endsWithPlus)
{
key = System.Windows.Input.Key.OemPlus;
s = s[..^1].TrimEnd('+');
}
var parts = s.Split('+', StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var p = part.Trim();
switch (p.ToLowerInvariant())
{
case "ctrl" or "control": mods |= System.Windows.Input.ModifierKeys.Control; continue;
case "shift": mods |= System.Windows.Input.ModifierKeys.Shift; continue;
case "alt": mods |= System.Windows.Input.ModifierKeys.Alt; continue;
case "win" or "windows": mods |= System.Windows.Input.ModifierKeys.Windows; continue;
}
// 만약 이미 key가 정해진 상태(예: Ctrl++)면 추가 키 파싱 불필요
if (key != System.Windows.Input.Key.None) continue;
// 1순위: 숫자 및 특수키 매핑 (Enum.TryParse(1) -> Key.Cancel 오탐 방지)
key = p switch
{
"0" => System.Windows.Input.Key.D0,
"1" => System.Windows.Input.Key.D1,
"2" => System.Windows.Input.Key.D2,
"3" => System.Windows.Input.Key.D3,
"4" => System.Windows.Input.Key.D4,
"5" => System.Windows.Input.Key.D5,
"6" => System.Windows.Input.Key.D6,
"7" => System.Windows.Input.Key.D7,
"8" => System.Windows.Input.Key.D8,
"9" => System.Windows.Input.Key.D9,
"=" => System.Windows.Input.Key.OemPlus,
"-" or "_" => System.Windows.Input.Key.OemMinus,
"," or "<" => System.Windows.Input.Key.OemComma,
"." or ">" => System.Windows.Input.Key.OemPeriod,
"/" or "?" => System.Windows.Input.Key.OemQuestion,
_ => System.Windows.Input.Key.None
};
// 2순위: 일반 영문 키 / Function 키 Enum.TryParse
if (key == System.Windows.Input.Key.None && !Enum.TryParse(p, ignoreCase: true, out key))
{
return null;
}
}
return key == System.Windows.Input.Key.None ? null : (mods, key);
}
/// <summary>조합 비교 정규화(대소문자·순서 무관).</summary>
public static string Normalize(string gesture)
=> string.Join("+", (gesture ?? "").Split('+').Select(p => p.Trim().ToLowerInvariant()).OrderBy(p => p));
}

View file

@ -0,0 +1,702 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using Paca.Browser;
using Paca.Browser.Permissions;
using Paca.Browser.Tabs;
using Paca.Core.Config;
using Wv2 = Microsoft.Web.WebView2.Wpf;
using TabItem = Paca.Browser.Tabs.TabItem;
namespace Paca.App.Services;
/// <summary>
/// 탭별 WebView2 생명주기·이벤트 배선 전담(재구성 ④ — MainWindow 갓-클래스 해체).
/// 일반 브라우저 동작(제목/URL/로딩/오디오/검색세션/줌 적용/초기 탐색/뉴윈도우→탭)은
/// 이 서비스가, 앱 고유 기능(미디어 감지/다운로드 라우팅/소셜/기록/주소창 반영)은
/// 이벤트 훅으로 호스트(MainWindow)가 주입한다.
/// </summary>
public sealed class TabViewService
{
private readonly CoreWebView2Environment _env;
private readonly AppConfig _config;
private readonly Panel _host;
private readonly SiteZoomStore _zoomStore;
private readonly Dispatcher _dispatcher;
private readonly Dictionary<TabItem, Task> _init = new();
/// <summary>뉴윈도우(팝업) 요청 시 새 탭을 만들어 반환하는 공급자(호스트가 TabManager 에 연결).</summary>
public Func<Task<TabItem>>? NewTabProvider { get; set; }
/// <summary>탭 CoreWebView2 초기화 완료(호스트가 감지 등 탭별 구독 부착).</summary>
public event Action<TabItem, CoreWebView2>? TabCoreReady;
/// <summary>페이지 내 a[download] 등 다운로드 시작(호스트가 저장 폴더로 라우팅).</summary>
public event Action<CoreWebView2, CoreWebView2DownloadStartingEventArgs>? DownloadStarting;
/// <summary>URL 확정(SourceChanged). 인자: (탭, url).</summary>
public event Action<TabItem, string>? UrlChanged;
/// <summary>문서 타이틀 확정(DocumentTitleChanged). 인자: (탭, title).</summary>
public event Action<TabItem, string>? TitleChanged;
/// <summary>탭 뒤로/앞으로 가능 상태 변화(네비 버튼 갱신용).</summary>
public event Action? HistoryChanged;
/// <summary>우클릭 메뉴 요청 — 호스트가 WPF ContextMenu 로 재구성해 표시(Handled=true).</summary>
public event Action<CoreWebView2, CoreWebView2ContextMenuRequestedEventArgs>? ContextMenuBuild;
/// <summary>T2-7 부스트: URL → 주입할 JS(강제 다크/커스텀 CSS). 빈 문자열이면 미적용.</summary>
public Func<string?, string>? BoostScriptProvider { get; set; }
/// <summary>T2-6 Glance: Alt+클릭한 링크 URL 요청.</summary>
public event Action<CoreWebView2, string>? GlanceRequested;
/// <summary>T2-4 리더 내 낭독(TTS) 요청 — 리더 본문 텍스트.</summary>
public event Action<CoreWebView2, string>? SpeakRequested;
/// <summary>Chrome 웹스토어 페이지의 'Chrome에 추가' 버튼 클릭 가로채기 설치 요청.</summary>
public event Action<CoreWebView2, string>? ExtensionInstallRequested;
/// <summary>DOM/JS 레벨에서 스니핑된 미디어 스트림(HLS m3u8 / DASH mpd / 비디오 src 등). (cv, url, kind, title, thumbUrl, mediaIndex)</summary>
public event Action<CoreWebView2, string, string, string, string?, int>? MediaSniffed;
/// <summary>크롬 새 탭 (NTP) 페이지 렌더링 요청. (tab)</summary>
public event Action<TabItem>? NtpNavigationRequested;
/// <summary>웹페이지 로그인 폼에서 자격증명(ID/PW) 캡처 시 이벤트. 인자: (CapturedFormCredential).</summary>
public event Action<Paca.Browser.Security.CapturedFormCredential>? CredentialCaptured;
/// <summary>페이지 탐색 완료 시 자격증명 자동완성 스크립트 공급자. 인자: (URL) -> 실행할 JS.</summary>
public Func<string, string>? AutofillScriptProvider { get; set; }
/// <summary>페이지 내 JS window.close() (구글/소셜 로그인 OAuth 팝업 등)에 의한 탭 닫기 요청. (tab)</summary>
public event Action<TabItem>? TabCloseRequested;
/// <summary>크롬 새 탭 (NTP) WebMessage 수신. (tab, type, jsonDoc)</summary>
public event Action<TabItem, string, System.Text.Json.JsonElement>? NtpMessageReceived;
/// <summary>T3-15 주소창 진행 바: 네비게이션 시작(전 탭 집계).</summary>
public event Action? NavigationStarted;
/// <summary>T3-15 주소창 진행 바: 네비게이션 완료(전 탭 집계).</summary>
public event Action? NavigationCompleted;
/// <summary>T3-11 사이트별 권한 정책(출처×종류 기억).</summary>
public PermissionGate Permissions { get; set; } = new();
/// <summary>Chrome 호환 사이트 권한 및 위치/보안 정책 코디네이터.</summary>
public ChromeSitePermissionsCoordinator SitePermissions => _sitePermissions ??= new(Permissions);
private ChromeSitePermissionsCoordinator? _sitePermissions;
/// <summary>T3-11 Ask 판정 시 호스트 다이얼로그 — State/Handled/SavesInProfile 설정은 호스트 책임.</summary>
public event Action<CoreWebView2, CoreWebView2PermissionRequestedEventArgs>? PermissionPrompt;
/// <summary>T3-14 추적 방지 블랙리스트(교체 주입 가능).</summary>
public TrackerBlocklist TrackerBlocklist { get; set; } = new();
/// <summary>T3-14 차단 카운터(실드 배지·팝업 데이터).</summary>
public TrackerCounter TrackerCounter { get; } = new();
/// <summary>T3-14 차단 발생 알림(배지 갱신).</summary>
public event Action? TrackerBlocked;
/// <summary>T3-3 InPrivate 탭: 다음에 만들어지는 탭에 적용(1회성).</summary>
public bool NextTabIsPrivate { get; set; }
/// <summary>WebView2 키보드 입력 전달 (전역 단축키 처리용).</summary>
public event System.Windows.Input.KeyEventHandler? TabPreviewKeyDown;
public TabViewService(CoreWebView2Environment env, AppConfig config, Panel host,
SiteZoomStore zoomStore, Dispatcher dispatcher)
{
_env = env;
_config = config;
_host = host;
_zoomStore = zoomStore;
_dispatcher = dispatcher;
}
public TabItem CreateTabItem() => new();
/// <summary>탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드)</summary>
public void AttachView(TabItem tab)
{
var view = new Wv2.WebView2 { DefaultBackgroundColor = System.Drawing.Color.White };
view.PreviewKeyDown += (s, e) => TabPreviewKeyDown?.Invoke(s, e);
tab.View = view;
_host.Children.Add(view);
view.Visibility = tab.IsActive ? Visibility.Visible : Visibility.Collapsed;
_init[tab] = InitCoreAsync(tab);
}
/// <summary>부스트 즉시 재적용(토글 직후). 비동기 fire-and-forget.</summary>
public void ApplyBoostNow(TabItem tab)
{
var cv = tab.Core;
if (cv == null) return;
var js = BoostScriptProvider?.Invoke(cv.Source) ?? "";
if (js.Length > 0 && !tab.IsReader) _ = cv.ExecuteScriptAsync(js);
else if (js.Length == 0) _ = cv.ExecuteScriptAsync("(function(){try{document.getElementById('vd-boost')?.remove();}catch(e){}})();");
}
/// <summary>탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용).</summary>
public async Task WaitTabReady(TabItem tab)
{
if (tab.Core != null) return;
if (_init.TryGetValue(tab, out var t)) { try { await t; } catch { } return; }
var sw = System.Diagnostics.Stopwatch.StartNew();
while (tab.Core == null && sw.ElapsedMilliseconds < 15000)
await Task.Delay(50);
}
/// <summary>탭 제거 시 뷰를 호스트에서 떼고 재오픈 가능 상태로(null) 만든다.</summary>
public void RemoveView(TabItem tab)
{
if (tab.IsLoading)
{
tab.IsLoading = false;
_dispatcher.BeginInvoke(new Action(() => NavigationCompleted?.Invoke()));
}
if (tab.View != null)
{
_host.Children.Remove(tab.View);
try { tab.View.Dispose(); } catch { }
tab.View = null; // 재오픈 시 새 뷰로 재생성(ReopenClosedTab → AttachView)
}
_init.Remove(tab);
}
private async Task InitCoreAsync(TabItem tab)
{
try
{
var options = _env.CreateCoreWebView2ControllerOptions();
if (_config.PrivateMode || tab.IsPrivate || NextTabIsPrivate) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
if (!tab.IsPrivate && (_config.PrivateMode || NextTabIsPrivate)) tab.IsPrivate = true;
NextTabIsPrivate = false;
await tab.View!.EnsureCoreWebView2Async(_env, options);
}
catch (Exception)
{
return; // 초기화 실패 탭 — 호스트가 로그 표시는 TitleChanged 로 알림 없이 둔다(기존 동작)
}
var cv = tab.Core!;
cv.Settings.AreDevToolsEnabled = true;
try
{
cv.Profile.PreferredColorScheme = App.IsLightTheme
? Microsoft.Web.WebView2.Core.CoreWebView2PreferredColorScheme.Light
: Microsoft.Web.WebView2.Core.CoreWebView2PreferredColorScheme.Dark;
}
catch { }
// 웹 콘텐츠 스크롤바 미니멀 글래스 테마 (화이트 테마 시 17px OS 검정 스크롤바 방지 및 모던 투명 트랙 일치)
const string scrollbarThemeScript = """
(function(){
try {
function injectScrollbar() {
if (document.getElementById('paca-scrollbar-theme')) return;
var style = document.createElement('style');
style.id = 'paca-scrollbar-theme';
style.textContent = `
:root {
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
scrollbar-width: thin;
}
@media (prefers-color-scheme: dark) {
:root {
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
scrollbar-width: thin;
}
}
::-webkit-scrollbar {
width: 8px !important;
height: 8px !important;
}
::-webkit-scrollbar-track {
background: transparent !important;
}
::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2) !important;
border-radius: 4px !important;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.4) !important;
}
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.25) !important;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.45) !important;
}
}
`;
(document.head || document.documentElement).appendChild(style);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectScrollbar);
} else {
injectScrollbar();
}
} catch(e) {}
})();
""";
try { await cv.AddScriptToExecuteOnDocumentCreatedAsync(scrollbarThemeScript); } catch { }
// T2-6 Glance: Alt+클릭 → 링크를 postMessage 로 전달 (캡처 단계에서 preventDefault)
const string glanceScript = """
(function(){ try { document.addEventListener('click', function(e){
if (e.altKey) { var a = e.target && e.target.closest ? e.target.closest('a') : null;
if (a && a.href) { e.preventDefault(); e.stopPropagation(); window.chrome.webview.postMessage({ vdGlance: a.href }); } }
}, true); } catch(err){} })();
""";
try { await cv.AddScriptToExecuteOnDocumentCreatedAsync(glanceScript); } catch { }
// Chrome 웹 스토어 내 'Chrome에 추가' 버튼 클릭 가로채기 스크립트
const string webStoreInterceptScript = """
(function(){ try {
document.addEventListener('click', function(e){
var target = e.target;
if (!target) return;
var btn = target.closest('button') || target.closest('div[role="button"]') || target.closest('a');
if (!btn) return;
var text = (btn.innerText || btn.textContent || '').trim();
var ariaLabel = btn.getAttribute('aria-label') || '';
if (text.includes('Chrome에 추가') || text.includes('Add to Chrome') || text.includes('추가') ||
ariaLabel.includes('Chrome에 추가') || ariaLabel.includes('Add to Chrome')) {
e.preventDefault();
e.stopPropagation();
window.chrome.webview.postMessage({ vdInstallExtension: window.location.href });
}
}, true);
} catch(err){} })();
""";
try { await cv.AddScriptToExecuteOnDocumentCreatedAsync(webStoreInterceptScript); } catch { }
// DOM/JS 레벨 미디어 스니퍼 (hls.js XhrLoader / fetch / video src 자동 가로채기)
const string mediaSnifferScript = """
(function(){
try {
function isSegment(u) {
if (!u) return false;
if (u.includes('.m3u8') || u.includes('.mpd') || u.includes('master') || u.includes('playlist') || u.includes('manifest')) return false;
return u.includes('.ts') || u.includes('.m4s') || u.includes('init.mp4') || u.includes('init.m4s') ||
/(seg|segment|chunk|frag)[-_]?\d+/i.test(u);
}
function tryCaptureFrame(v) {
try {
if (v && v.videoWidth > 0 && v.videoHeight > 0) {
var c = document.createElement('canvas');
var w = 240;
var h = Math.round(240 * v.videoHeight / v.videoWidth);
c.width = w; c.height = h;
var ctx = c.getContext('2d');
ctx.drawImage(v, 0, 0, w, h);
return c.toDataURL('image/jpeg', 0.75);
}
} catch(e){}
return null;
}
function getPageOgImage() {
try {
var og = document.querySelector('meta[property="og:image"], meta[name="twitter:image"], meta[property="twitter:image"]');
if (og && og.content) return og.content;
} catch(e){}
return null;
}
function notify(url, forceKind, poster, thumb, idx) {
if (!url || typeof url !== 'string') return;
if (url.startsWith('blob:') || url.startsWith('data:') || url.startsWith('javascript:')) return;
try {
var u = url.toLowerCase();
if (isSegment(u)) return; // 세그먼트 청크는 전송하지 않음
var kind = null;
if (forceKind === 'hls' || u.includes('.m3u8') || u.includes('master') || u.includes('playlist') ||
u.includes('manifest') || u.includes('format=m3u8') || u.includes('type=hls')) {
kind = 'hls';
} else if (forceKind === 'dash' || u.includes('.mpd') || u.includes('format=mpd') || u.includes('type=dash')) {
kind = 'dash';
} else if (u.includes('.mp4') || u.includes('.webm') || u.includes('.mkv') || u.includes('.m4v') || u.includes('.mov')) {
kind = 'direct';
} else if (forceKind) {
kind = forceKind;
}
if (kind) {
post(url, kind, poster, thumb, idx);
}
} catch(e){}
}
function post(url, kind, poster, thumb, idx) {
try {
if (window.chrome && window.chrome.webview && window.chrome.webview.postMessage) {
var bestThumb = thumb || poster || getPageOgImage() || '';
window.chrome.webview.postMessage({
vdMediaSniffed: url,
kind: kind || '',
title: document.title || '',
poster: poster || '',
thumbnail: bestThumb || '',
index: typeof idx === 'number' ? idx : 0
});
}
} catch(e){}
}
// 1. XHR 훅 (요청 및 #EXTM3U 응답 가로채기)
var origXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
var reqUrl = url;
this.addEventListener('load', function() {
try {
if (typeof this.responseText === 'string' && this.responseText.startsWith('#EXTM3U')) {
notify(reqUrl, 'hls'); // 위장 HLS 매니페스트 감지!
}
} catch(e){}
});
notify(url, null);
return origXhrOpen.apply(this, arguments);
};
// 2. Fetch 훅 (#EXTM3U 응답 가로채기)
if (window.fetch) {
var origFetch = window.fetch;
window.fetch = function(input, init) {
var url = (typeof input === 'string') ? input : (input && input.url ? input.url : '');
notify(url, null);
return origFetch.apply(this, arguments).then(function(response) {
try {
var clone = response.clone();
clone.text().then(function(text) {
if (text && text.startsWith('#EXTM3U')) {
notify(url, 'hls'); // 위장 HLS 매니페스트 감지!
}
}).catch(function(){});
} catch(e){}
return response;
});
};
}
// 3. HTMLMediaElement src hook (video, audio)
var origSrcDesc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'src');
if (origSrcDesc && origSrcDesc.set) {
Object.defineProperty(HTMLMediaElement.prototype, 'src', {
set: function(val) {
notify(val, null);
return origSrcDesc.set.call(this, val);
},
get: origSrcDesc.get,
configurable: true
});
}
// 4. DOM 비디오 태그 스캔
function scanMedia() {
try {
var videos = document.querySelectorAll('video');
videos.forEach(function(v, i) {
var src = v.currentSrc || v.src;
var poster = v.poster || v.getAttribute('poster') || '';
var frame = tryCaptureFrame(v);
if (src) {
notify(src, 'direct', poster, frame, i + 1);
}
if (!v._pacaListener) {
v._pacaListener = true;
var onFrame = function() {
var f = tryCaptureFrame(v);
if (f) {
post(v.currentSrc || v.src, 'direct', v.poster, f, i + 1);
}
};
v.addEventListener('loadeddata', onFrame);
v.addEventListener('play', onFrame);
}
});
document.querySelectorAll('audio, source').forEach(function(el){
if (el.src) notify(el.src, null, null, null, 0);
if (el.currentSrc) notify(el.currentSrc, null, null, null, 0);
});
} catch(e){}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scanMedia);
} else {
scanMedia();
}
setInterval(scanMedia, 3000);
} catch(err){}
})();
""";
try { await cv.AddScriptToExecuteOnDocumentCreatedAsync(mediaSnifferScript); } catch { }
try { await cv.AddScriptToExecuteOnDocumentCreatedAsync(Paca.Browser.Security.CredentialAutofillEngine.GenerateCredentialCaptureWatcherScript()); } catch { }
// WebMessage 라우팅 (Glance / 리더 낭독 / 확장 설치 인터셉트 / 미디어 스니퍼 / 자격증명 캡처)
try
{
cv.WebMessageReceived += (_, we) =>
{
try
{
var msg = we.WebMessageAsJson;
if (string.IsNullOrEmpty(msg)) return;
var captured = Paca.Browser.Security.CredentialAutofillEngine.TryParseCapturedMessage(msg);
if (captured != null)
{
CredentialCaptured?.Invoke(captured);
}
using var doc = System.Text.Json.JsonDocument.Parse(msg);
if (doc.RootElement.TryGetProperty("vdGlance", out var g) && g.ValueKind == System.Text.Json.JsonValueKind.String)
GlanceRequested?.Invoke(cv, g.GetString() ?? "");
if (doc.RootElement.TryGetProperty("vdSpeak", out var sp) && sp.ValueKind == System.Text.Json.JsonValueKind.String)
SpeakRequested?.Invoke(cv, sp.GetString() ?? "");
if (doc.RootElement.TryGetProperty("vdInstallExtension", out var ext) && ext.ValueKind == System.Text.Json.JsonValueKind.String)
ExtensionInstallRequested?.Invoke(cv, ext.GetString() ?? "");
if (doc.RootElement.TryGetProperty("vdMediaSniffed", out var ms) && ms.ValueKind == System.Text.Json.JsonValueKind.String)
{
var pageTitle = doc.RootElement.TryGetProperty("title", out var pt) && pt.ValueKind == System.Text.Json.JsonValueKind.String
? pt.GetString() ?? "" : "";
var kind = doc.RootElement.TryGetProperty("kind", out var kd) && kd.ValueKind == System.Text.Json.JsonValueKind.String
? kd.GetString() ?? "" : "";
var thumb = doc.RootElement.TryGetProperty("thumbnail", out var th) && th.ValueKind == System.Text.Json.JsonValueKind.String
? th.GetString() : null;
if (string.IsNullOrWhiteSpace(thumb) && doc.RootElement.TryGetProperty("poster", out var pos) && pos.ValueKind == System.Text.Json.JsonValueKind.String)
thumb = pos.GetString();
var idx = doc.RootElement.TryGetProperty("index", out var ix) && ix.ValueKind == System.Text.Json.JsonValueKind.Number
? ix.GetInt32() : 0;
MediaSniffed?.Invoke(cv, ms.GetString() ?? "", kind, pageTitle, thumb, idx);
}
if (doc.RootElement.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == System.Text.Json.JsonValueKind.String)
{
var typeStr = typeEl.GetString() ?? "";
if (typeStr.StartsWith("ntp:", StringComparison.OrdinalIgnoreCase))
{
NtpMessageReceived?.Invoke(tab, typeStr, doc.RootElement.Clone());
}
}
}
catch { }
};
}
catch { }
// 탭별 검색 세션 (네이티브 Find API)
tab.FindSession = new FindSession(new WebView2FindApi(cv, _env));
tab.FindSession.StateChanged += () => _dispatcher.BeginInvoke(new Action(() =>
{
if (tab.IsActive && tab.FindSession != null) FindStateChanged?.Invoke(tab);
}));
// 네비게이션 상태
cv.NavigationStarting += (_, ne) =>
{
if (Browser.Features.NewTabPageBuilder.IsNewTabUrl(ne.Uri))
{
ne.Cancel = true;
_dispatcher.BeginInvoke(new Action(() => NtpNavigationRequested?.Invoke(tab)));
return;
}
_dispatcher.BeginInvoke(new Action(() =>
{
var wasLoading = tab.IsLoading;
tab.IsLoading = true;
if (!wasLoading)
{
NavigationStarted?.Invoke();
}
}));
};
cv.NavigationCompleted += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
{
var wasLoading = tab.IsLoading;
tab.IsLoading = false;
if (wasLoading)
{
NavigationCompleted?.Invoke();
}
try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7 사이트별 줌
try // T2-7 부스트(강제 다크/커스텀 CSS) 적용
{
var boostJs = BoostScriptProvider?.Invoke(cv.Source) ?? "";
if (boostJs.Length > 0 && !tab.IsReader) _ = cv.ExecuteScriptAsync(boostJs);
}
catch { }
try // 자격증명 자동완성 및 인라인 제안 UI 주입
{
var autofillJs = AutofillScriptProvider?.Invoke(cv.Source) ?? "";
if (autofillJs.Length > 0) _ = cv.ExecuteScriptAsync(autofillJs);
}
catch { }
}));
cv.HistoryChanged += (_, _) => HistoryChanged?.Invoke();
// T3-11 사이트별 권한: 정책 Ask 만 프롬프트, Allow/Deny 는 즉시 응답
cv.PermissionRequested += (_, e) =>
{
var kind = e.PermissionKind.ToString();
var origin = OriginOf(e.Uri);
switch (Permissions.Decide(kind, origin))
{
case PermissionVerdict.Allow:
e.State = CoreWebView2PermissionState.Allow; e.Handled = true;
break;
case PermissionVerdict.Deny:
e.State = CoreWebView2PermissionState.Deny; e.Handled = true;
break;
default:
PermissionPrompt?.Invoke(cv, e); // 호스트 다이얼로그가 State/Handled 설정
break;
}
};
// UserAgent 및 브라우저 세팅
try
{
if (!string.IsNullOrWhiteSpace(_config.UserAgent))
cv.Settings.UserAgent = _config.UserAgent;
}
catch { }
// T3-14 추적 방지: 블랙리스트 요청 차단 + 카운터 및 4chan 등 CDN Referer 자동 보정
cv.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
cv.WebResourceRequested += (_, e) =>
{
Uri u;
try { u = new Uri(e.Request.Uri); } catch { return; }
if (TrackerBlocklist.Matches(u))
{
try { e.Response = _env.CreateWebResourceResponse(null, 204, "Blocked", ""); } catch { }
TrackerCounter.Record(e.Request.Uri);
_dispatcher.BeginInvoke(new Action(() => TrackerBlocked?.Invoke()));
return;
}
// 4chan (i.4cdn.org) 등 이미지/비디오 CDN 요청 시 Referer 자동 보정 (429 Rate Limit 방지)
if (u.Host.EndsWith("4cdn.org", StringComparison.OrdinalIgnoreCase))
{
try
{
var r = e.Request.Headers.GetHeader("Referer");
if (string.IsNullOrEmpty(r))
{
e.Request.Headers.SetHeader("Referer", "https://boards.4channel.org/");
}
}
catch { }
}
};
cv.SourceChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
{
try
{
var url = cv.Source ?? "";
tab.Url = url;
UrlChanged?.Invoke(tab, url);
}
catch { }
}));
cv.DocumentTitleChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
{
try
{
TitleChanged?.Invoke(tab, cv.DocumentTitle ?? "");
}
catch { }
}));
// T1-8 오디오 표시/뮤트
try
{
cv.IsDocumentPlayingAudioChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
{
try { tab.IsAudioPlaying = cv.IsDocumentPlayingAudio; } catch { }
}));
cv.IsMutedChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
{
try { tab.IsMuted = cv.IsMuted; } catch { }
}));
}
catch { }
// 뉴윈도우(팝업) → 새 탭 (deferral 필수 — 탭 생성이 비동기)
cv.NewWindowRequested += async (_, ne) =>
{
try
{
var deferral = ne.GetDeferral();
try
{
var newTab = NewTabProvider != null ? await NewTabProvider() : null;
if (newTab != null)
{
await WaitTabReady(newTab);
ne.NewWindow = newTab.Core;
}
ne.Handled = true;
}
finally { deferral.Complete(); }
}
catch
{
try { ne.Handled = true; if (ne.Uri != null) cv.Navigate(ne.Uri); } catch { }
}
};
// JS window.close() (구글/소셜 로그인 OAuth 팝업 등) 감지 시 탭 자동 닫기
cv.WindowCloseRequested += (_, _) =>
{
_dispatcher.BeginInvoke(new Action(() => TabCloseRequested?.Invoke(tab)));
};
// 페이지 내 다운로드 → 호스트 훅
cv.DownloadStarting += (_, de) => DownloadStarting?.Invoke(cv, de);
// 우클릭 메뉴 → 호스트가 커스텀 메뉴 구성(기본 메뉴 보강, T2-12/T3-5)
cv.ContextMenuRequested += (_, ce) =>
{
ContextMenuBuild?.Invoke(cv, ce);
};
TabCoreReady?.Invoke(tab, cv);
// 초기 URL (세션 복원 및 초기 탭 홈 URL 자동 탐색) - 가상 호스트 매핑 등 TabCoreReady 이후에 안전하게 탐색
if (!string.IsNullOrEmpty(tab.Url) &&
(string.IsNullOrEmpty(cv.Source) || string.Equals(cv.Source, "about:blank", StringComparison.OrdinalIgnoreCase)))
{
if (Browser.Features.NewTabPageBuilder.IsNewTabUrl(tab.Url))
{
_ = _dispatcher.BeginInvoke(new Action(() => NtpNavigationRequested?.Invoke(tab)));
}
else
{
try { cv.Navigate(tab.Url); } catch { }
}
}
}
/// <summary>활성 탭 검색 상태 표시 요청(FindStatus 텍스트 갱신 — 호스트가 구독).</summary>
public event Action<TabItem>? FindStateChanged;
/// <summary>권한 기준 출처(scheme://host:port) 추출.</summary>
public static string OriginOf(string? uri)
{
if (string.IsNullOrEmpty(uri)) return "";
try { var u = new Uri(uri); return u.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped); }
catch { return uri; }
}
}

View file

@ -0,0 +1,40 @@
using Microsoft.Win32;
namespace Paca.App.Services;
/// <summary>
/// UC-T13 시스템 테마 판별 — Windows 개인 설정(AppsUseLightTheme) 레지스트리를 읽어
/// dark/light 를 판정하고, 설정값(dark/light/system/highcontrast/파일경로)을 실제 적용할
/// 테마로 해석한다. 레지스트리 기반이라 WPF 스레드 없이도 단위 테스트가 가능하다.
/// </summary>
public static class ThemeResolver
{
/// <summary>설정 테마 값 → (테마 사전 이름, 사용자 정의 .xaml 경로|null).</summary>
public static (string Theme, string? CustomPath) Resolve(string setting, string? customThemePath = null)
{
var s = (setting ?? "dark").Trim().ToLowerInvariant();
return s switch
{
"system" => (IsSystemLight() ? "light" : "dark", null),
"highcontrast" => ("highcontrast", null),
"light" => ("light", null),
"dark" => ("dark", null),
// 커스텀 테마 파일 경로가 설정에 있으면 우선
_ when !string.IsNullOrWhiteSpace(customThemePath) && File.Exists(customThemePath) => ("custom", customThemePath),
_ => ("dark", null),
};
}
/// <summary>Windows 앱 모드가 라이트면 true(레지스트리 접근 실패 시 다크 취급).</summary>
public static bool IsSystemLight()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(
@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
if (key?.GetValue("AppsUseLightTheme") is int v) return v == 1;
}
catch { }
return false;
}
}

View file

@ -0,0 +1,208 @@
using System;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Paca.Core.Config;
using Paca.Core.Platform;
namespace Paca.App.Services;
/// <summary>
/// 미디어 감지 및 재생목록 섬네일 캐시 서비스.
/// 핫링크 방지(Referer 헤더 요구) CDN 이미지 다운로드, Base64 캔버스 프레임 디코딩,
/// 번들된 FFmpeg를 통한 원격 비디오 1프레임 자동 추출을 담당한다.
/// </summary>
public sealed class ThumbnailCacheService
{
private readonly AppConfig _config;
private readonly string _cacheDir;
private readonly HttpClient _http;
private readonly IProcessRunner _processRunner;
public ThumbnailCacheService(
AppConfig config,
string? cacheDir = null,
HttpClient? http = null,
IProcessRunner? runner = null)
{
_config = config ?? new AppConfig();
_cacheDir = cacheDir ?? Path.Combine(CorePaths.DataDir, "thumbnails");
_http = http ?? new HttpClient();
_processRunner = runner ?? DesktopProcessRunner.Default;
try
{
if (!Directory.Exists(_cacheDir))
Directory.CreateDirectory(_cacheDir);
}
catch { }
}
/// <summary>
/// Base64 Data URL (data:image/jpeg;base64,...) 또는 순수 Base64 문자열을 디코딩하여 로컬 파일로 저장한다.
/// </summary>
public async Task<string?> SaveBase64ThumbnailAsync(string base64Data, string key, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(base64Data)) return null;
try
{
var raw = base64Data;
var commaIdx = raw.IndexOf(',');
if (commaIdx >= 0)
{
raw = raw.Substring(commaIdx + 1);
}
var bytes = Convert.FromBase64String(raw);
if (bytes == null || bytes.Length == 0) return null;
var fileName = ComputeHash(key) + ".jpg";
var destPath = Path.Combine(_cacheDir, fileName);
await File.WriteAllBytesAsync(destPath, bytes, ct);
return destPath;
}
catch
{
return null;
}
}
/// <summary>
/// CDN 이미지를 Referer 헤더를 포함하여 다운로드하고 로컬 디스크에 캐시한다 (403 방지).
/// </summary>
public async Task<string?> DownloadAndCacheThumbnailAsync(string imageUrl, string? referer, string key, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(imageUrl)) return null;
var fileName = ComputeHash(key) + ".jpg";
var destPath = Path.Combine(_cacheDir, fileName);
if (File.Exists(destPath)) return destPath;
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, imageUrl);
req.Headers.UserAgent.ParseAdd(_config.UserAgent ?? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) PACA/1.0");
if (!string.IsNullOrWhiteSpace(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var refUri))
{
req.Headers.Referrer = refUri;
}
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
if (!resp.IsSuccessStatusCode) return null;
var bytes = await resp.Content.ReadAsByteArrayAsync(ct);
if (bytes == null || bytes.Length == 0) return null;
await File.WriteAllBytesAsync(destPath, bytes, ct);
return destPath;
}
catch
{
return null;
}
}
/// <summary>
/// FFmpeg를 통해 원격 비디오 스트림의 0.5초 지점에서 1프레임을 직접 추출하여 캐시한다.
/// </summary>
public async Task<string?> ExtractRemoteVideoFrameAsync(string videoUrl, string? referer, string key, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(videoUrl)) return null;
var ffmpeg = DesktopMediaMuxer.Default.ResolveFfmpeg(_config);
if (string.IsNullOrWhiteSpace(ffmpeg) || !File.Exists(ffmpeg)) return null;
var fileName = ComputeHash(key) + ".jpg";
var destPath = Path.Combine(_cacheDir, fileName);
if (File.Exists(destPath)) return destPath;
try
{
var args = new System.Collections.Generic.List<string>
{
"-y",
"-hide_banner",
"-loglevel", "error"
};
var ua = _config.UserAgent ?? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) PACA/1.0";
args.Add("-user_agent");
args.Add(ua);
if (!string.IsNullOrWhiteSpace(referer))
{
args.Add("-referer");
args.Add(referer);
}
args.Add("-ss");
args.Add("0.5");
args.Add("-i");
args.Add(videoUrl);
args.Add("-vframes");
args.Add("1");
args.Add("-vf");
args.Add("scale=240:-2");
args.Add(destPath);
var spec = new ProcessSpec(ffmpeg, args.ToArray());
var res = await _processRunner.RunAsync(spec, null, ct);
if (res.ExitCode == 0 && File.Exists(destPath))
{
return destPath;
}
}
catch { }
return null;
}
/// <summary>
/// Data URL, 포스터 HTTP URL, 원격 스트림 등 모든 가능한 소스로부터 최적의 로컬 썸네일 경로를 보장한다.
/// </summary>
public async Task<string?> EnsureThumbnailAsync(string? rawThumbOrPoster, string mediaUrl, string? referer, CancellationToken ct = default)
{
var key = !string.IsNullOrWhiteSpace(rawThumbOrPoster) ? rawThumbOrPoster : mediaUrl;
// 1. Data URL (Base64 Canvas 스냅샷)
if (!string.IsNullOrWhiteSpace(rawThumbOrPoster) &&
rawThumbOrPoster.StartsWith("data:image", StringComparison.OrdinalIgnoreCase))
{
return await SaveBase64ThumbnailAsync(rawThumbOrPoster, key, ct);
}
// 2. HTTP/HTTPS 포스터 이미지
if (!string.IsNullOrWhiteSpace(rawThumbOrPoster) &&
(rawThumbOrPoster.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
rawThumbOrPoster.StartsWith("https://", StringComparison.OrdinalIgnoreCase)))
{
var cached = await DownloadAndCacheThumbnailAsync(rawThumbOrPoster, referer, key, ct);
if (!string.IsNullOrWhiteSpace(cached)) return cached;
}
// 3. 포스터 부재 시 FFmpeg 원격 비디오 1프레임 추출 시도
if (!string.IsNullOrWhiteSpace(mediaUrl) &&
(mediaUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
mediaUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)))
{
return await ExtractRemoteVideoFrameAsync(mediaUrl, referer, mediaUrl, ct);
}
return null;
}
private static string ComputeHash(string input)
{
using var md5 = MD5.Create();
var bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}

View file

@ -0,0 +1,121 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Threading;
namespace Paca.App.Services;
/// <summary>
/// UC-D11/UC-N02 토스트 알림 — 지정한 호스트 패널(우하단)에 카드를 띄우고
/// 설정된 시간 후 사라지게 한다. 방해 금지(DoNotDisturb)면 표시를 억제하고 알림센터에만 쌓는다.
/// UI 스레드 DispatcherTimer 기반이라 별도 스레드가 없다.
/// </summary>
public sealed class ToastNotifier
{
private readonly Panel _host;
private readonly DispatcherTimer _timer;
private readonly List<string> _history = new();
/// <summary>토스트 표시 시간. AppConfig.ToastDurationMs 와 연동.</summary>
public TimeSpan Duration { get; set; } = TimeSpan.FromSeconds(4);
/// <summary>UC-N15 방해 금지 — true 면 토스트를 띄우지 않고 기록만.</summary>
public bool DoNotDisturb { get; set; }
/// <summary>UC-N14 알림 센터용 최근 알림 이력(최신 우선, 상한 100).</summary>
public IReadOnlyList<string> History => _history.ToList();
public ToastNotifier(Panel host)
{
_host = host;
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_timer.Tick += (_, _) => Expire();
}
/// <summary>토스트 표시. kind 에 따라 아이콘·테두리 색이 달라진다(success/error/info).</summary>
public void Show(string message, string kind = "info")
{
if (_host.Dispatcher != null && !_host.Dispatcher.CheckAccess())
{
_host.Dispatcher.BeginInvoke(() => Show(message, kind));
return;
}
_history.Insert(0, $"[{DateTime.Now:HH:mm}] {message}");
if (_history.Count > 100) _history.RemoveAt(_history.Count - 1);
if (DoNotDisturb) return;
var border = new Border
{
Background = (Brush)Application.Current.FindResource("CardBg"),
BorderBrush = kind switch
{
"success" => (Brush)Application.Current.FindResource("SuccessBrush"),
"error" => (Brush)Application.Current.FindResource("ErrorBrush"),
_ => (Brush)Application.Current.FindResource("AccentBrush"),
},
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 10, 14, 10),
Margin = new Thickness(0, 8, 0, 0),
MaxWidth = 340,
Effect = new DropShadowEffect { Opacity = 0.4, BlurRadius = 16, ShadowDepth = 4 },
Tag = DateTime.UtcNow.Add(Duration),
};
var icon = kind switch
{
"success" => "\uE73E",
"error" => "\uE7BA",
_ => "\uE946",
};
var panel = new StackPanel { Orientation = Orientation.Horizontal };
panel.Children.Add(new TextBlock
{
Text = icon,
FontFamily = (FontFamily)Application.Current.FindResource("IconFont"),
FontSize = 15,
Foreground = border.BorderBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 9, 0),
});
panel.Children.Add(new TextBlock
{
Text = message,
Foreground = (Brush)Application.Current.FindResource("TextPrimary"),
FontSize = 12.5,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 270,
VerticalAlignment = VerticalAlignment.Center,
});
border.Child = panel;
border.MouseLeftButtonUp += (_, _) => Collapse(border);
border.Opacity = 0;
_host.Children.Add(border);
border.Loaded += (_, _) =>
{
var anim = new DoubleAnimation(0, 1, (System.Windows.Duration)Application.Current.FindResource("Duration.Fast"));
border.BeginAnimation(UIElement.OpacityProperty, anim);
};
if (!_timer.IsEnabled) _timer.Start();
}
private void Expire()
{
var now = DateTime.UtcNow;
foreach (var child in _host.Children.OfType<Border>().ToList())
{
if (child.Tag is DateTime until && now >= until) Collapse(child);
}
if (_host.Children.Count == 0) _timer.Stop();
}
private void Collapse(Border border)
{
var anim = new DoubleAnimation(1, 0, (System.Windows.Duration)Application.Current.FindResource("Duration.Fast"));
anim.Completed += (_, _) => _host.Children.Remove(border);
border.BeginAnimation(UIElement.OpacityProperty, anim);
}
}

View file

@ -0,0 +1,97 @@
using System;
using System.Runtime.InteropServices;
using System.Windows;
namespace Paca.App.Services;
public sealed class WindowsTrayDaemon : IDisposable
{
private const int NIM_ADD = 0x00000000;
private const int NIM_MODIFY = 0x00000001;
private const int NIM_DELETE = 0x00000002;
private const int NIF_MESSAGE = 0x00000001;
private const int NIF_ICON = 0x00000002;
private const int NIF_TIP = 0x00000004;
private const int WM_USER = 0x0400;
public const int WM_TRAYICON = WM_USER + 1024;
private const int WM_LBUTTONDBLCLK = 0x0203;
private const int WM_RBUTTONUP = 0x0205;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct NOTIFYICONDATA
{
public int cbSize;
public IntPtr hWnd;
public int uID;
public int uFlags;
public int uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
}
[DllImport("shell32.dll", CharSet = CharSet.Auto)]
private static extern bool Shell_NotifyIcon(int dwMessage, ref NOTIFYICONDATA lpData);
private IntPtr _hWnd;
private bool _isCreated;
private bool _closeToTray = true;
public bool CloseToTray
{
get => _closeToTray;
set => _closeToTray = value;
}
public event Action? QuickActionRequested;
public event Action? RestoreRequested;
public void Initialize(IntPtr hWnd, string tooltip = "PACA - Quick Actions (Shift+Ctrl+Alt+P)")
{
_hWnd = hWnd;
var data = new NOTIFYICONDATA
{
cbSize = Marshal.SizeOf(typeof(NOTIFYICONDATA)),
hWnd = _hWnd,
uID = 1001,
uFlags = NIF_MESSAGE | NIF_TIP,
uCallbackMessage = WM_TRAYICON,
szTip = tooltip.Length > 120 ? tooltip[..120] : tooltip
};
_isCreated = Shell_NotifyIcon(NIM_ADD, ref data);
}
public void HandleTrayMessage(IntPtr lParam)
{
int msg = lParam.ToInt32();
if (msg == WM_LBUTTONDBLCLK)
{
RestoreRequested?.Invoke();
}
else if (msg == WM_RBUTTONUP)
{
QuickActionRequested?.Invoke();
}
}
public void Remove()
{
if (_isCreated && _hWnd != IntPtr.Zero)
{
var data = new NOTIFYICONDATA
{
cbSize = Marshal.SizeOf(typeof(NOTIFYICONDATA)),
hWnd = _hWnd,
uID = 1001
};
Shell_NotifyIcon(NIM_DELETE, ref data);
_isCreated = false;
}
}
public void Dispose()
{
Remove();
}
}

View file

@ -0,0 +1,759 @@
<Window x:Class="Paca.App.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="설정" Height="720" Width="920"
MinHeight="600" MinWidth="800"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ResizeMode="CanResize" ShowInTaskbar="False"
UseLayoutRounding="True"
KeyboardNavigation.TabNavigation="Cycle"
WindowStyle="None"
AutomationProperties.Name="설정 창">
<WindowChrome.WindowChrome>
<WindowChrome CaptionHeight="0"
ResizeBorderThickness="6"
GlassFrameThickness="1"
CornerRadius="0"
UseAeroCaptionButtons="False"
NonClientFrameEdges="None"/>
</WindowChrome.WindowChrome>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 캡션 (드래그 핸들 + 실시간 검색 + 닫기) -->
<Border Grid.Row="0" Background="{DynamicResource PanelBg}"
BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="0,0,0,1"
MinHeight="44"
Padding="24,12,12,12" MouseLeftButtonDown="TitleBar_MouseDown">
<DockPanel>
<!-- 닫기 버튼 -->
<Button DockPanel.Dock="Right" Style="{StaticResource CloseButton}"
Content="&#xE8BB;" ToolTip="닫기 (Esc)" Click="Close_Click"
AutomationProperties.Name="설정 창 닫기"/>
<!-- UC-S01 설정 항목 실시간 검색창 -->
<Border DockPanel.Dock="Right" Style="{StaticResource SearchInputContainer}"
Width="260" Margin="16,0,12,0" Padding="8,4">
<DockPanel VerticalAlignment="Center">
<TextBlock Text="&#xE721;" Style="{StaticResource FluentIcon}" FontSize="13"
Foreground="{DynamicResource TextMuted}" Margin="2,0,8,0" VerticalAlignment="Center"/>
<TextBox x:Name="SettingsSearchBox" Style="{StaticResource AddressTextBox}"
Padding="4,2" FontSize="12" TextChanged="SettingsSearchBox_TextChanged"
ToolTip="설정 항목 실시간 검색" AutomationProperties.Name="설정 검색"/>
</DockPanel>
</Border>
<!-- 윈도우 타이틀 및 아이콘 -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Border Width="26" Height="26" CornerRadius="6" Background="{DynamicResource AccentFadeBrush}"
BorderBrush="{DynamicResource AccentBrush}" BorderThickness="1" Margin="0,0,10,0" VerticalAlignment="Center">
<TextBlock Text="&#xE713;" Style="{StaticResource FluentIcon}" FontSize="13"
Foreground="{DynamicResource AccentBrush}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="설정" Foreground="{DynamicResource TextPrimary}"
FontWeight="SemiBold" FontSize="15" VerticalAlignment="Center"/>
<!-- 변경 상태 표시 배지 -->
<Border x:Name="DirtyIndicatorBadge" Background="{DynamicResource PendingBrush}"
CornerRadius="10" Padding="8,2" Margin="12,0,0,0" VerticalAlignment="Center" Visibility="Collapsed">
<TextBlock x:Name="DirtyIndicatorText" Text="수정됨" Foreground="{DynamicResource WindowBg}" FontSize="11" FontWeight="Bold"/>
</Border>
</StackPanel>
</DockPanel>
</Border>
<!-- 본문 그리드 (좌측 사이드바 + 우측 카드형 설정 뷰) -->
<Grid Grid.Row="1" Margin="20,16,20,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="190"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- UC-S02 좌측 카테고리 내비게이션 -->
<Border Grid.Column="0" Background="{DynamicResource GlassBg}"
BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1"
CornerRadius="10" Padding="6,8" Margin="0,0,16,0">
<ListBox x:Name="SettingsNavList" Background="Transparent" BorderThickness="0"
SelectionChanged="SettingsNav_SelectionChanged"
AutomationProperties.Name="설정 카테고리">
<ListBox.ItemTemplate>
<DataTemplate>
<DockPanel VerticalAlignment="Center" Margin="4,2">
<TextBlock Text="{Binding Icon}" Style="{StaticResource FluentIcon}" FontSize="14"
Width="22" Foreground="{DynamicResource TextMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="{Binding Name}" FontSize="12.5" FontWeight="Medium"
Foreground="{DynamicResource TextSecondary}" VerticalAlignment="Center"/>
</DockPanel>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" Background="Transparent" CornerRadius="7" Padding="8,7" Margin="0,2">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
<Setter Property="BorderBrush" TargetName="bd" Value="{DynamicResource AccentBrush}"/>
<Setter Property="BorderThickness" TargetName="bd" Value="1"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
</Border>
<!-- 우측 스크롤 가능한 메인 설정 카드 영역 -->
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Padding="0,0,8,0">
<StackPanel Margin="0,0,0,24">
<!-- ==================== 1. 외관 & 테마 ==================== -->
<StackPanel x:Name="SectionAppearance" Margin="0,0,0,24" Tag="외관">
<TextBlock Text="외관 및 테마" Style="{StaticResource Type.H1}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<!-- 테마 및 강조색 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="테마 모드" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="앱 전체의 시각 테마를 변경합니다. 선택 즉시 앱에 적용됩니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,10"/>
<ComboBox x:Name="ThemeModeBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
Margin="0,0,0,14" SelectionChanged="ThemeModeBox_SelectionChanged"
AutomationProperties.Name="테마 선택">
<ComboBoxItem Content="다크 (Dark)" Tag="dark"/>
<ComboBoxItem Content="라이트 (Light)" Tag="light"/>
<ComboBoxItem Content="시스템 설정 따르기 (System)" Tag="system"/>
<ComboBoxItem Content="고대비 (High Contrast)" Tag="highcontrast"/>
</ComboBox>
<Border Height="1" Background="{DynamicResource BorderSubtle}" Margin="0,4,0,14"/>
<TextBlock Text="강조 색상 (Accent Color)" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="버튼, 활성 탭 및 주요 인디케이터에 사용되는 포인트 색상입니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,10"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<RadioButton x:Name="AccentDefault" Style="{StaticResource DarkRadioButton}" Content="기본 (보라)" Margin="0,0,16,0" Checked="Accent_Checked" GroupName="accent"/>
<RadioButton x:Name="AccentBlue" Style="{StaticResource DarkRadioButton}" Content="파랑" Margin="0,0,16,0" Checked="Accent_Checked" GroupName="accent"/>
<RadioButton x:Name="AccentGreen" Style="{StaticResource DarkRadioButton}" Content="초록" Margin="0,0,16,0" Checked="Accent_Checked" GroupName="accent"/>
<RadioButton x:Name="AccentPink" Style="{StaticResource DarkRadioButton}" Content="핑크" Margin="0,0,16,0" Checked="Accent_Checked" GroupName="accent"/>
</StackPanel>
<DockPanel Margin="0,4,0,0">
<TextBlock DockPanel.Dock="Right" Text="#RRGGBB" Style="{StaticResource Type.Caption}" VerticalAlignment="Center" Margin="10,0,0,0"/>
<TextBox x:Name="AccentColorBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6"
ToolTip="사용자 지정 강조색(#6366F1 등)" AutomationProperties.Name="사용자 지정 강조색"/>
</DockPanel>
<Border Height="1" Background="{DynamicResource BorderSubtle}" Margin="0,14,0,14"/>
<TextBlock Text="사용자 정의 테마 파일 (.xaml)" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="커스텀 XAML 리소스 딕셔너리 파일을 로드하여 앱의 모든 스타일을 커스터마이징합니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,8"/>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="찾아보기..." Padding="12,6" Margin="8,0,0,0" Click="BrowseThemeFile"
AutomationProperties.Name="테마 파일 찾아보기"/>
<TextBox x:Name="CustomThemeBox" Style="{StaticResource DarkTextBox}" Padding="8,6"/>
</DockPanel>
</StackPanel>
</Border>
<!-- 정보 밀도 및 UI 배율 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="정보 밀도 (Density)" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="툴바, 탭, 다운로드 목록 등의 여백 및 간격을 조절합니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,8"/>
<ComboBox x:Name="UiDensityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
AutomationProperties.Name="정보 밀도 선택" Margin="0,0,0,14">
<ComboBoxItem Content="컴팩트 (좁은 여백, 높은 정보량)" Tag="compact"/>
<ComboBoxItem Content="보통 (표준 권장)" Tag="normal"/>
<ComboBoxItem Content="여유 (넓은 터치/마우스 영역)" Tag="comfortable"/>
</ComboBox>
<Border Height="1" Background="{DynamicResource BorderSubtle}" Margin="0,4,0,14"/>
<TextBlock Text="UI 크기 및 글자 확대 (100% ~ 200%)" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="고해상도 디스플레이 또는 저시력 환경을 위해 전체 인터페이스 크기를 확대합니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,10"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Slider x:Name="UiScaleSlider" Minimum="1.0" Maximum="2.0" ValueChanged="UiScaleSlider_ValueChanged"
IsSnapToTickEnabled="True" TickFrequency="0.05" VerticalAlignment="Center"
AutomationProperties.Name="UI 크기 배율"/>
<TextBlock Grid.Column="1" x:Name="UiScaleLabel" Text="100%" Style="{StaticResource Type.Mono}"
FontSize="13" FontWeight="SemiBold" VerticalAlignment="Center" Margin="14,0,0,0"/>
</Grid>
</StackPanel>
</Border>
</StackPanel>
<!-- ==================== 2. 글꼴 및 타이포그래피 ==================== -->
<StackPanel x:Name="SectionFont" Margin="0,0,0,24" Tag="글꼴">
<TextBlock Text="글꼴 및 타이포그래피" Style="{StaticResource Type.H1}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14">
<StackPanel>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="기본 폰트 (Font Family)" FontWeight="SemiBold" FontSize="12.5" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBox x:Name="UiFontFamilyBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6"
ToolTip="예: Pretendard, Segoe UI, 나눔스퀘어" AutomationProperties.Name="기본 폰트"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="한국어 폴백 폰트" FontWeight="SemiBold" FontSize="12.5" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBox x:Name="UiFontKoreanFallbackBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6"
ToolTip="예: Malgun Gothic, 맑은 고딕" AutomationProperties.Name="한국어 폴백 폰트"/>
</StackPanel>
</Grid>
<TextBlock Text="기본 폰트 크기 프리셋" FontWeight="SemiBold" FontSize="12.5" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<ComboBox x:Name="FontSizeBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,0,0,14"
AutomationProperties.Name="폰트 크기 프리셋">
<ComboBoxItem Content="작게 (12pt)" Tag="12"/>
<ComboBoxItem Content="보통 (13pt - 권장)" Tag="13"/>
<ComboBoxItem Content="크게 (14pt)" Tag="14"/>
<ComboBoxItem Content="더 크게 (15pt)" Tag="15"/>
</ComboBox>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1" CornerRadius="8" Padding="14,12">
<StackPanel>
<TextBlock Text="실시간 렌더링 미리보기" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<TextBlock x:Name="FontPreviewText" Text="다운로드 완료 — 4K 60fps 고화질 영상 12개 · 다운로드 속도 18.5 MB/s"
FontSize="13" Foreground="{DynamicResource TextPrimary}"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
</StackPanel>
<!-- ==================== 3. 도구 모음 단추 ==================== -->
<StackPanel x:Name="SectionToolbar" Margin="0,0,0,24" Tag="도구 모음">
<DockPanel Margin="0,0,0,12">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button x:Name="ToolbarSelectDefaultsBtn" Style="{StaticResource GhostButton}" Content="기본값 복원" Click="ToolbarSelectDefaults_Click" Margin="0,0,6,0"/>
<Button x:Name="ToolbarSelectAllBtn" Style="{StaticResource GhostButton}" Content="모두 표시" Click="ToolbarSelectAll_Click"/>
</StackPanel>
<TextBlock Text="도구 모음 단추" Style="{StaticResource Type.H1}" FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center"/>
</DockPanel>
<!-- 탐색 & 브라우징 단추 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="기본 탐색 &amp; 브라우징" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal">
<CheckBox x:Name="ToolbarBtn_Home" Style="{StaticResource DarkCheckBox}" Content="홈 단추" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_Compact" Style="{StaticResource DarkCheckBox}" Content="컴팩트 모드" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_Bookmarks" Style="{StaticResource DarkCheckBox}" Content="즐겨찾기" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_History" Style="{StaticResource DarkCheckBox}" Content="방문 기록" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_TabSearch" Style="{StaticResource DarkCheckBox}" Content="탭 검색" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_Shield" Style="{StaticResource DarkCheckBox}" Content="추적 방지 실드" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_Private" Style="{StaticResource DarkCheckBox}" Content="InPrivate 모드" Margin="4,4,20,6"/>
</WrapPanel>
</StackPanel>
</Border>
<!-- 다운로드 & 미디어 도구 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14">
<StackPanel>
<TextBlock Text="다운로드, 미디어 &amp; 시스템" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal">
<CheckBox x:Name="ToolbarBtn_Downloads" Style="{StaticResource DarkCheckBox}" Content="다운로드 목록" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_Link" Style="{StaticResource DarkCheckBox}" Content="링크로 받기" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_Library" Style="{StaticResource DarkCheckBox}" Content="받은 파일 라이브러리" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_Playlist" Style="{StaticResource DarkCheckBox}" Content="미디어 재생목록" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_OpenFolder" Style="{StaticResource DarkCheckBox}" Content="저장 폴더 열기" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_Pairing" Style="{StaticResource DarkCheckBox}" Content="모바일 연결(QR)" Margin="4,4,20,6"/>
<CheckBox x:Name="ToolbarBtn_Settings" Style="{StaticResource DarkCheckBox}" Content="설정 단추" Margin="4,4,20,6"/>
</WrapPanel>
</StackPanel>
</Border>
</StackPanel>
<!-- ==================== 4. 다운로드 엔진 ==================== -->
<StackPanel x:Name="SectionDownload" Margin="0,0,0,24" Tag="다운로드">
<TextBlock Text="다운로드 엔진" Style="{StaticResource Type.H1}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<!-- 저장 폴더 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="기본 저장 폴더" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="영상 및 오디오 파일이 저장될 로컬 디렉터리 경로입니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,8"/>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="폴더 선택..." Padding="12,6" Margin="8,0,0,0" Click="BrowseFolder"/>
<TextBox Name="FolderBox" Style="{StaticResource DarkTextBox}" Padding="8,6"/>
</DockPanel>
</StackPanel>
</Border>
<!-- 다운로드 성능 및 동시성 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="다운로드 병렬 성능" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,12"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="14"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="14"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="세그먼트 분할 수" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<TextBox Name="ConcurrencyBox" Style="{StaticResource DarkTextBox}" Padding="8,6"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="최대 동시 다운로드" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<TextBox Name="MaxConcurrentBox" Style="{StaticResource DarkTextBox}" Padding="8,6"/>
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="오류 재시도 횟수" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<TextBox Name="RetriesBox" Style="{StaticResource DarkTextBox}" Padding="8,6"/>
</StackPanel>
</Grid>
<Grid Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="14"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="최소 감지 크기 (MB)" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<TextBox Name="MinSizeBox" Style="{StaticResource DarkTextBox}" Padding="8,6"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="다운로드 속도 상한 (KB/s, 0=무제한)" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<TextBox Name="MaxSpeedBox" Style="{StaticResource DarkTextBox}" Padding="8,6"/>
</StackPanel>
</Grid>
<WrapPanel Orientation="Horizontal">
<CheckBox Name="AutoClearCompletedBox" Style="{StaticResource DarkCheckBox}" Content="완료된 항목 목록에서 자동 정리" Margin="4,4,20,4"/>
<CheckBox Name="OpenFolderOnCompleteBox" Style="{StaticResource DarkCheckBox}" Content="다운로드 완료 시 저장 폴더 자동 열기" Margin="4,4,20,4"/>
</WrapPanel>
</StackPanel>
</Border>
<!-- GPU 하드웨어 가속 & 코덱 프로파일러 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="GPU 하드웨어 가속 &amp; 코덱 프로파일러" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="GPU 하드웨어 인코더(NVENC, QSV, AMF)를 사전 프로빙하여 고속 비디오 인코딩 및 트랜스코딩을 수행합니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,10"/>
<CheckBox x:Name="EnableGpuAccelerationBox" Style="{StaticResource DarkCheckBox}" Content="GPU 하드웨어 가속 활성화"
Margin="0,0,0,12" AutomationProperties.Name="GPU 하드웨어 가속 활성화"/>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="선호 GPU 인코더 백엔드" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<ComboBox x:Name="PreferredGpuBackendBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
AutomationProperties.Name="선호 GPU 인코더 백엔드">
<ComboBoxItem Content="자동 감지 및 프로빙 (Auto)" Tag="Auto"/>
<ComboBoxItem Content="NVIDIA NVENC" Tag="NvidiaNvenc"/>
<ComboBoxItem Content="Intel QuickSync (QSV)" Tag="IntelQsv"/>
<ComboBoxItem Content="AMD AMF" Tag="AmdAmf"/>
<ComboBoxItem Content="CPU 소프트웨어만 사용" Tag="SoftwareCpu"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="선호 대상 비디오 코덱" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<ComboBox x:Name="PreferredVideoCodecBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
AutomationProperties.Name="선호 대상 비디오 코덱">
<ComboBoxItem Content="자동 선택 (Auto)" Tag="Auto"/>
<ComboBoxItem Content="AV1 (차세대 초고효율)" Tag="AV1"/>
<ComboBoxItem Content="HEVC / H.265 (고압축)" Tag="HEVC_H265"/>
<ComboBoxItem Content="H.264 / AVC (최대 호환성)" Tag="H264"/>
</ComboBox>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- 배터리 세이버 및 전력 관리 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="배터리 세이버 &amp; 전력 관리" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="노트북 및 모바일 기기 배터리 방전 시 세그먼트 동시성을 자동 감축하고 고부하 작업을 유예합니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,10"/>
<CheckBox x:Name="EnableBatterySaverBox" Style="{StaticResource DarkCheckBox}" Content="배터리 세이버 전력 관리 활성화"
Margin="0,0,0,12" AutomationProperties.Name="배터리 세이버 전력 관리 활성화"/>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="절전 진입 배터리 잔량 (%)" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<TextBox x:Name="BatterySaverThresholdBox" Style="{StaticResource DarkTextBox}" Padding="8,6"
AutomationProperties.Name="배터리 절전 임계치"/>
</StackPanel>
<StackPanel Grid.Column="2" VerticalAlignment="Center" Margin="0,14,0,0">
<CheckBox x:Name="BatterySaverThrottleBox" Style="{StaticResource DarkCheckBox}" Content="절전 시 다운로드 대역폭 제한 (2000 Kbps)"
AutomationProperties.Name="절전 시 다운로드 대역폭 제한"/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- 예약 다운로드 및 자막 언어 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14">
<StackPanel>
<TextBlock Text="예약 시간대 &amp; 자막 언어" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,10"/>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="다운로드 예약 시간대 (HH:mm-HH:mm)" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<TextBox x:Name="ScheduleBox" Style="{StaticResource DarkTextBox}" Padding="8,6"
ToolTip="예: 02:00-08:00 (비워두면 항상 즉시 다운로드)"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="자막 다운로드 언어" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<ComboBox x:Name="SubtitleLangBox" Style="{StaticResource DarkComboBox}" IsEditable="True">
<ComboBoxItem Content="(자막 받지 않음)" Tag=""/>
<ComboBoxItem Content="한국어 (ko)" Tag="ko"/>
<ComboBoxItem Content="English (en)" Tag="en"/>
<ComboBoxItem Content="日本語 (ja)" Tag="ja"/>
</ComboBox>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</StackPanel>
<!-- ==================== 5. 일반 & 웹 탐색 ==================== -->
<StackPanel x:Name="SectionGeneral" Margin="0,0,0,24" Tag="일반">
<TextBlock Text="일반 및 웹 탐색 환경" Style="{StaticResource Type.H1}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14">
<StackPanel>
<TextBlock Text="홈 페이지 URL" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBox Name="HomeBox" Style="{StaticResource DarkTextBox}" Padding="8,6" Margin="0,0,0,14"/>
<TextBlock Text="기본 검색 엔진" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<ComboBox Name="SearchEngineBox" Style="{StaticResource DarkComboBox}" Margin="0,0,0,4"
SelectionChanged="SearchEngineBox_SelectionChanged"/>
<TextBlock x:Name="SearchEngineHint" Style="{StaticResource Type.Caption}" Margin="0,0,0,14"
Text="주소창에 `g 검색어`, `yt 검색어` 등을 입력하면 검색 엔진을 즉시 전환할 수 있습니다."/>
<Border Height="1" Background="{DynamicResource BorderSubtle}" Margin="0,0,0,14"/>
<TextBlock Text="UI 언어" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<ComboBox x:Name="LanguageBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,0,0,14">
<ComboBoxItem Content="한국어 (Korean)" Tag="ko"/>
<ComboBoxItem Content="English (영어)" Tag="en"/>
</ComboBox>
<WrapPanel Orientation="Horizontal">
<CheckBox Name="AutoStartBox" Style="{StaticResource DarkCheckBox}" Content="Windows 시작 시 자동 실행" Margin="4,4,20,4"/>
<CheckBox Name="MinimizeToTrayBox" Style="{StaticResource DarkCheckBox}" Content="닫기 버튼 클릭 시 시스템 트레이로 최소화" Margin="4,4,20,4"/>
</WrapPanel>
</StackPanel>
</Border>
</StackPanel>
<!-- ==================== 6. 브라우저 & 개인정보 ==================== -->
<StackPanel x:Name="SectionBrowser" Margin="0,0,0,24" Tag="브라우저">
<TextBlock Text="브라우저 및 개인정보" Style="{StaticResource Type.H1}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<!-- 탭 동작 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="탭 스트립 및 내비게이션 동작" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,10">
<CheckBox Name="TabWheelSwitchBox" Style="{StaticResource DarkCheckBox}" Content="마우스 휠로 탭 전환" Margin="4,4,20,4"/>
<CheckBox Name="ConfirmCloseMultipleTabsBox" Style="{StaticResource DarkCheckBox}" Content="여러 탭 한 번에 닫을 때 확인창 표시" Margin="4,4,20,4"/>
<CheckBox Name="BookmarkBarVisibleBox" Style="{StaticResource DarkCheckBox}" Content="북마크 바 표시 (Ctrl+Shift+B)" Margin="4,4,20,4"/>
</WrapPanel>
<TextBlock Text="탭 바 배치 위치" Style="{StaticResource Type.Caption}" Margin="0,4,0,4"/>
<ComboBox x:Name="TabStripPositionBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,0,0,0">
<ComboBoxItem Content="상단 (기본 수평 탭)" Tag="top"/>
<ComboBoxItem Content="하단" Tag="bottom"/>
<ComboBoxItem Content="좌측 세로형 탭 (Vertical Tabs)" Tag="left"/>
</ComboBox>
</StackPanel>
</Border>
<!-- 자동 감지 미디어 형식 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="자동 감지 및 가로챌 미디어 포맷" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,4,4"/>
<TextBlock Text="웹페이지 탐색 중 아래 체크된 스트림 및 비디오 파일이 재생되면 다운로드 바로 감지합니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal">
<CheckBox Name="FmtHls" Style="{StaticResource DarkCheckBox}" Content="HLS (.m3u8)" Margin="4,5,16,5"/>
<CheckBox Name="FmtDash" Style="{StaticResource DarkCheckBox}" Content="DASH (.mpd)" Margin="4,5,16,5"/>
<CheckBox Name="FmtMp4" Style="{StaticResource DarkCheckBox}" Content="MP4 비디오" Margin="4,5,16,5"/>
<CheckBox Name="FmtWebM" Style="{StaticResource DarkCheckBox}" Content="WebM 비디오" Margin="4,5,16,5"/>
<CheckBox Name="FmtMkv" Style="{StaticResource DarkCheckBox}" Content="MKV 비디오" Margin="4,5,16,5"/>
<CheckBox Name="FmtM4v" Style="{StaticResource DarkCheckBox}" Content="M4V 비디오" Margin="4,5,16,5"/>
<CheckBox Name="FmtMov" Style="{StaticResource DarkCheckBox}" Content="MOV 비디오" Margin="4,5,16,5"/>
<CheckBox Name="FmtAvi" Style="{StaticResource DarkCheckBox}" Content="AVI 비디오" Margin="4,5,16,5"/>
<CheckBox Name="FmtAudio" Style="{StaticResource DarkCheckBox}" Content="오디오 (M4A / MP3 / OGG / AAC)" Margin="4,5,16,5"/>
</WrapPanel>
</StackPanel>
</Border>
<!-- 브라우저 데이터 관리 카드 -->
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14">
<StackPanel>
<TextBlock Text="브라우저 세션 및 저장 데이터" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock x:Name="ProfileModeText" Text="일반 모드 — 쿠키·세션·localStorage 를 프로필에 보존합니다" Style="{StaticResource Type.Caption}" Margin="0,0,0,8"/>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="데이터 삭제..." Padding="12,6" Margin="8,0,0,0"
ToolTip="쿠키·localStorage·캐시 삭제" Click="ClearData_Click"/>
<TextBox x:Name="ProfilePathBox" Style="{StaticResource DarkTextBox}" Padding="8,6" IsReadOnly="True"/>
</DockPanel>
</StackPanel>
</Border>
</StackPanel>
<!-- ==================== 7. 알림 & 접근성 ==================== -->
<StackPanel x:Name="SectionNotify" Margin="0,0,0,24" Tag="알림">
<TextBlock Text="알림 및 접근성" Style="{StaticResource Type.H1}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="알림 설정" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,10">
<CheckBox Name="NotifySoundBox" Style="{StaticResource DarkCheckBox}" Content="다운로드 완료 알림 사운드 재생" Margin="4,4,20,4"/>
<CheckBox Name="DoNotDisturbBox" Style="{StaticResource DarkCheckBox}" Content="방해 금지 (모든 토스트 알림 숨김)" Margin="4,4,20,4"/>
<CheckBox Name="PauseOnPowerSaverBox" Style="{StaticResource DarkCheckBox}" Content="배터리 절전 모드 진입 시 자동 일시정지" Margin="4,4,20,4"/>
</WrapPanel>
<TextBlock Text="토스트 알림 노출 지속 시간 (초)" Style="{StaticResource Type.Caption}" Margin="0,4,0,4"/>
<TextBox x:Name="ToastDurationBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Width="120" HorizontalAlignment="Left"/>
</StackPanel>
</Border>
<Border x:Name="SectionAccessibility" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Tag="접근성">
<StackPanel>
<TextBlock Text="접근성 &amp; 인터랙션" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,12">
<CheckBox Name="ReduceMotionBox" Style="{StaticResource DarkCheckBox}" Content="애니메이션 / 모션 줄이기 (Reduce Motion)" Margin="4,4,20,4"/>
<CheckBox Name="ShowCaptionsBox" Style="{StaticResource DarkCheckBox}" Content="동영상 실시간 캡션/자막 표시" Margin="4,4,20,4"/>
<CheckBox Name="FocusSelectsAddressBox" Style="{StaticResource DarkCheckBox}" Content="주소창 클릭 시 전체 텍스트 자동 선택" Margin="4,4,20,4"/>
<CheckBox Name="PasteAndGoBox" Style="{StaticResource DarkCheckBox}" Content="주소창에 URL 붙여넣기 시 즉시 이동" Margin="4,4,20,4"/>
</WrapPanel>
<TextBlock Text="저시력자를 위한 글자 확대 배율 (1.0x ~ 2.0x)" Style="{StaticResource Type.Caption}" Margin="0,4,0,4"/>
<Slider x:Name="UiFontScaleSlider" Minimum="1.0" Maximum="2.0" Width="260" HorizontalAlignment="Left"
IsSnapToTickEnabled="True" TickFrequency="0.1" ValueChanged="UiFontScaleSlider_ValueChanged"
AutomationProperties.Name="글자 확대 배율"/>
</StackPanel>
</Border>
</StackPanel>
<!-- ==================== 8. 고급 및 도구 연동 ==================== -->
<Expander x:Name="AdvancedSection" Header="고급 및 외부 도구 연동 (ffmpeg / yt-dlp / 프록시)"
FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}"
Margin="0,0,0,24" ExpandDirection="Down" IsExpanded="False" Tag="고급">
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,10,0,0">
<StackPanel>
<!-- ffmpeg -->
<TextBlock Text="ffmpeg 실행 파일 경로" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="비워두면 시스템 PATH에 등록된 ffmpeg를 자동으로 사용합니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,6"/>
<DockPanel Margin="0,0,0,14">
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="찾아보기..." Padding="12,6" Margin="8,0,0,0" Click="BrowseFfmpeg"/>
<TextBox Name="FfmpegBox" Style="{StaticResource DarkTextBox}" Padding="8,6"/>
</DockPanel>
<Border Height="1" Background="{DynamicResource BorderSubtle}" Margin="0,0,0,14"/>
<!-- yt-dlp -->
<TextBlock Text="소셜 비디오 다운로더 (yt-dlp)" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="YouTube, Instagram, X, TikTok 등의 소셜 영상 파싱에 사용됩니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,6"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="재설치" Padding="12,6" Margin="6,0,0,0" Click="ReinstallYtDlp_Click" ToolTip="최신 버전 재다운로드"/>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="찾아보기..." Padding="12,6" Margin="8,0,0,0" Click="BrowseYtDlp"/>
<TextBox Name="YtDlpBox" Style="{StaticResource DarkTextBox}" Padding="8,6"/>
</DockPanel>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,14">
<CheckBox Name="UseCookiesBox" Style="{StaticResource DarkCheckBox}" Content="로그인 필요 영상을 위해 브라우저 쿠키 연동" Margin="4,4,16,4"/>
<CheckBox Name="AutoInstallBox" Style="{StaticResource DarkCheckBox}" Content="yt-dlp 미발견 시 자동 다운로드" Margin="4,4,16,4"/>
<CheckBox Name="AutoUpdateBox" Style="{StaticResource DarkCheckBox}" Content="차단 시 yt-dlp 자동 최신화 후 재시도" Margin="4,4,16,4"/>
<CheckBox Name="AutoInstallDenoBox" Style="{StaticResource DarkCheckBox}" Content="Deno JS 런타임 자동 설치 (YouTube 봇 차단 우회)" Margin="4,4,16,4"/>
</WrapPanel>
<TextBlock Text="기본 소셜 비디오 화질" Style="{StaticResource Type.Caption}" Margin="0,0,0,4"/>
<ComboBox Name="DefaultQualityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,0,0,14">
<ComboBoxItem Content="최고 화질 (Best MP4)" Tag="best"/>
<ComboBoxItem Content="1080p FHD" Tag="1080"/>
<ComboBoxItem Content="720p HD" Tag="720"/>
<ComboBoxItem Content="480p SD" Tag="480"/>
<ComboBoxItem Content="오디오 전용 (MP3/M4A)" Tag="audio"/>
</ComboBox>
<Border Height="1" Background="{DynamicResource BorderSubtle}" Margin="0,0,0,14"/>
<!-- 프록시 및 User-Agent -->
<TextBlock Text="커스텀 User-Agent" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBox x:Name="UserAgentBox" Style="{StaticResource DarkTextBox}" Padding="8,6" Margin="0,0,0,14"/>
<TextBlock Text="프록시 서버 (http://host:port)" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBox x:Name="ProxyBox" Style="{StaticResource DarkTextBox}" Padding="8,6" Margin="0,0,0,4"/>
<TextBlock Text="프록시 변경 사항은 앱을 다시 시작한 후 완전히 적용됩니다." Style="{StaticResource Type.Caption}"/>
<Border Height="1" Background="{DynamicResource BorderSubtle}" Margin="0,14,0,14"/>
<TextBlock Text="YouTube 플레이어 클라이언트 모드 (고급)" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBox Name="YoutubeClientsBox" Style="{StaticResource DarkTextBox}" Padding="8,6" Margin="0,0,0,4"/>
<TextBlock Text="비워두면 기본값 사용. PO 토큰 또는 봇 차단 발생 시 예: tv,android_vr,web" Style="{StaticResource Type.Caption}"/>
</StackPanel>
</Border>
</Expander>
<!-- ==================== 9. 암호 관리자 & Windows Hello ==================== -->
<StackPanel x:Name="SectionSecurity" Margin="0,0,0,24" Tag="암호 관리">
<TextBlock Text="암호 관리자 &amp; Windows Hello" Style="{StaticResource Type.H1}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="OS 네이티브 생체 보안 (Windows Hello / Touch ID)" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="지문, 얼굴 인식, PIN 등 OS 레벨 보안으로 웹사이트 ID 및 비밀번호를 안전하게 암호화 보관합니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,10"/>
<CheckBox x:Name="EnableWindowsHelloVaultBox" Style="{StaticResource DarkCheckBox}" Content="Windows Hello 생체 보안 금고 활성화 (AES-256-GCM + DPAPI)" IsChecked="{Binding EnableWindowsHelloVault}" Margin="0,0,0,8"/>
<CheckBox x:Name="RequireBiometricForRevealBox" Style="{StaticResource DarkCheckBox}" Content="비밀번호 보기/확인 시 Windows Hello 본인 인증 필수" IsChecked="{Binding RequireBiometricForPasswordReveal}" Margin="0,0,0,8"/>
<CheckBox x:Name="RequireBiometricForAutofillBox" Style="{StaticResource DarkCheckBox}" Content="웹 브라우저 자동완성 실행 시 생체 인증 확인" IsChecked="{Binding RequireBiometricForAutofill}" Margin="0,0,0,12"/>
<Border Height="1" Background="{DynamicResource BorderSubtle}" Margin="0,0,0,12"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource PrimaryButton}" Content="암호 관리자 열기..." Padding="14,6" Click="OpenCredentialVault_Click"/>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="저장된 비밀번호 및 계정 관리" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}"/>
<TextBlock Text="저장된 사이트 자격증명 검색, 추가, 편집, 2FA TOTP 생성 및 보안 감사를 수행합니다." Style="{StaticResource Type.Caption}"/>
</StackPanel>
</DockPanel>
</StackPanel>
</Border>
</StackPanel>
<!-- ==================== 10. 단축키 & 실험실 ==================== -->
<StackPanel x:Name="SectionShortcuts" Margin="0,0,0,24" Tag="단축키">
<TextBlock Text="단축키 및 실험적 기능" Style="{StaticResource Type.H1}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="키보드 단축키" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="Ctrl+K 명령 팔레트 또는 상태바 ? 버튼에서 전체 단축키를 확인할 수 있습니다." Style="{StaticResource Type.Caption}"/>
</StackPanel>
</Border>
<Border x:Name="SectionExperimental" Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14" Tag="실험적">
<StackPanel>
<TextBlock Text="실험실 기능 (Experimental)" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal">
<CheckBox Name="RestoreTabsSuspendedBox" Style="{StaticResource DarkCheckBox}" Content="세션 복원 시 탭을 잠자기(절전) 상태로 복원하여 빠른 시작" Margin="4,4,20,4"/>
<CheckBox Name="TabTitleMultilineBox" Style="{StaticResource DarkCheckBox}" Content="탭 제목을 두 줄로 표시" Margin="4,4,20,4"/>
</WrapPanel>
</StackPanel>
</Border>
</StackPanel>
<!-- ==================== 10. 정보 & 백업 ==================== -->
<StackPanel x:Name="SectionAbout" Margin="0,0,0,12" Tag="정보">
<TextBlock Text="정보 및 업데이트" Style="{StaticResource Type.H1}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<Border Background="{DynamicResource CardBg}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" CornerRadius="10" Padding="16,14">
<StackPanel>
<TextBlock Text="GitHub Releases 업데이트 확인" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock Text="GitHub 릴리즈 저장소를 비교하여 최신 버전이 있는지 확인합니다." Style="{StaticResource Type.Caption}" Margin="0,0,0,8"/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="지금 확인" Padding="14,6" Margin="8,0,0,0" Click="CheckUpdate_Click"/>
<TextBox Name="UpdateRepoBox" Style="{StaticResource DarkTextBox}" Padding="8,6"/>
</DockPanel>
<TextBlock x:Name="UpdateResultText" Text="" Style="{StaticResource Type.Caption}" Margin="0,0,0,14"/>
<Border Height="1" Background="{DynamicResource BorderSubtle}" Margin="0,0,0,14"/>
<TextBlock Text="현재 버전 정보" FontWeight="SemiBold" FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,4"/>
<TextBlock x:Name="VersionText" Text="Paca 1.0.0" FontSize="12.5" Foreground="{DynamicResource TextSecondary}"/>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- UC-S03 다시 시작 필요 알림 배너 -->
<Border x:Name="RestartBanner" Grid.Row="1" VerticalAlignment="Bottom" Margin="20,0,20,12"
Background="{DynamicResource AccentFadeBrush}" BorderBrush="{DynamicResource AccentBrush}" BorderThickness="1"
CornerRadius="8" Padding="14,8" Visibility="Collapsed">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}" Content="지금 다시 시작" Click="RestartNow_Click"/>
<TextBlock Text="프록시 및 보안 모드 변경 사항은 앱을 다시 시작한 후 적용됩니다."
FontSize="12" FontWeight="Medium" Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center"/>
</DockPanel>
</Border>
<!-- 하단 액션 바 -->
<Border Grid.Row="2" Background="{DynamicResource PanelBg}"
BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="24,12">
<DockPanel>
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="ResetDefaultsButton" Style="{StaticResource GhostButton}" Content="기본값 복원" Click="ResetDefaults_Click" Margin="0,0,8,0"
AutomationProperties.Name="기본값 복원"/>
<Button x:Name="ExportSettingsButton" Style="{StaticResource GhostButton}" Content="내보내기..." Click="ExportSettings_Click" Margin="0,0,8,0"
AutomationProperties.Name="설정 내보내기"/>
<Button x:Name="ImportSettingsButton" Style="{StaticResource GhostButton}" Content="가져오기..." Click="ImportSettings_Click" Margin="0,0,8,0"
AutomationProperties.Name="설정 가져오기"/>
</StackPanel>
<!-- 실시간 유효성 검사 에러/성공 힌트 -->
<TextBlock x:Name="ValidationHint" Text="" Foreground="{DynamicResource ErrorBrush}" FontSize="12"
VerticalAlignment="Center" Margin="16,0,16,0" TextTrimming="CharacterEllipsis"
AutomationProperties.LiveSetting="Assertive"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Style="{StaticResource GhostButton}" Content="취소" Width="90" Height="32" Margin="0,0,8,0" IsCancel="True" Click="Cancel_Click"/>
<Button Style="{StaticResource PrimaryButton}" Content="저장 및 적용" Width="110" Height="32" Click="Ok_Click" IsDefault="True"
AutomationProperties.Name="설정 저장"/>
</StackPanel>
</DockPanel>
</Border>
</Grid>
</Window>

View file

@ -0,0 +1,877 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using Microsoft.Win32;
using Paca.App.ViewModels;
using Paca.Browser;
using Paca.Core.Config;
using Paca.Core.Social;
namespace Paca.App;
public partial class SettingsWindow : Window
{
public AppConfig Config { get; }
public SettingsViewModel ViewModel { get; }
private readonly AppConfig _original;
private readonly Func<Task>? _clearBrowsingData;
private readonly List<FrameworkElement> _sections = new();
private bool _needsRestart;
public sealed record NavCategoryItem(string Name, string Icon, string Tag);
private static readonly NavCategoryItem[] NavCategories =
{
new("외관", "\uE790", "외관"),
new("글꼴", "\uE8D2", "글꼴"),
new("도구 모음", "\uE8A7", "도구 모음"),
new("다운로드", "\uE896", "다운로드"),
new("일반", "\uE713", "일반"),
new("브라우저", "\uE774", "브라우저"),
new("알림", "\uEA8F", "알림"),
new("접근성", "\uE776", "접근성"),
new("고급", "\uE90F", "고급"),
new("암호 관리", "\uE72E", "암호 관리"),
new("단축키", "\uE765", "단축키"),
new("실험적", "\uE7BE", "실험적"),
new("정보", "\uE946", "정보")
};
public SettingsWindow(AppConfig config, Func<Task>? clearBrowsingData = null)
{
InitializeComponent();
Config = config;
_original = CloneConfig(config);
_clearBrowsingData = clearBrowsingData;
ViewModel = new SettingsViewModel(config);
ViewModel.LivePreviewRequested += OnLivePreviewRequested;
ViewModel.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(SettingsViewModel.IsDirty) or nameof(SettingsViewModel.DirtyCount))
{
UpdateDirtyIndicator();
}
};
DataContext = ViewModel;
LoadValues();
// UC-S02 카테고리 내비게이션 초기화
SettingsNavList.ItemsSource = NavCategories;
_sections.Add(SectionAppearance);
_sections.Add(SectionFont);
if (SectionToolbar != null) _sections.Add(SectionToolbar);
if (SectionDownload != null) _sections.Add(SectionDownload);
if (SectionGeneral != null) _sections.Add(SectionGeneral);
if (SectionBrowser != null) _sections.Add(SectionBrowser);
if (SectionNotify != null) _sections.Add(SectionNotify);
if (SectionAccessibility != null) _sections.Add(SectionAccessibility);
if (AdvancedSection != null)
{
if (AdvancedSection.Content is FrameworkElement adv) _sections.Add(adv);
else _sections.Add(AdvancedSection);
}
if (FindName("SectionSecurity") is FrameworkElement sec) _sections.Add(sec);
if (FindName("SectionShortcuts") is FrameworkElement sc) _sections.Add(sc);
if (FindName("SectionExperimental") is FrameworkElement exp) _sections.Add(exp);
if (FindName("SectionAbout") is FrameworkElement ab) _sections.Add(ab);
// UC-S20 버전 정보
VersionText.Text = $"Paca {CurrentVersion()} · {MainWindow.ProfileFolder}";
// UC-S08 Ctrl+S 저장
PreviewKeyDown += (_, e) =>
{
if (e.Key == Key.S && Keyboard.Modifiers == ModifierKeys.Control)
{
if (Save()) { DialogResult = true; e.Handled = true; }
}
};
}
private void UpdateDirtyIndicator()
{
if (DirtyIndicatorBadge == null || DirtyIndicatorText == null) return;
if (ViewModel.IsDirty)
{
DirtyIndicatorBadge.Visibility = Visibility.Visible;
DirtyIndicatorText.Text = $"{ViewModel.DirtyCount}개 수정됨";
}
else
{
DirtyIndicatorBadge.Visibility = Visibility.Collapsed;
}
}
private void OnLivePreviewRequested(string propertyName)
{
switch (propertyName)
{
case nameof(SettingsViewModel.Theme):
case nameof(SettingsViewModel.AccentColor):
case nameof(SettingsViewModel.CustomThemePath):
App.ApplyTheme(ThemeModeBox.SelectedValue as string ?? "dark", CustomThemeBox.Text?.Trim());
var accent = AccentColorBox.Text?.Trim();
App.ApplyAccent(string.IsNullOrEmpty(accent) ? null : accent);
RefreshTitleBarChrome();
(Owner as MainWindow)?.UpdateTitleBarTheme();
break;
case nameof(SettingsViewModel.UiFontFamily):
case nameof(SettingsViewModel.UiFontKoreanFallback):
case nameof(SettingsViewModel.UiFontSize):
case nameof(SettingsViewModel.UiFontScale):
RefreshFontPreview();
break;
case nameof(SettingsViewModel.UiScale):
(Owner as MainWindow)?.ApplyUiScale(UiScaleSlider.Value);
break;
case nameof(SettingsViewModel.ReduceMotion):
App.ApplyReduceMotion(ReduceMotionBox.IsChecked == true);
break;
}
}
public void LoadValues()
{
var config = Config;
FolderBox.Text = config.OutputFolder;
HomeBox.Text = config.HomeUrl;
ConcurrencyBox.Text = config.Concurrency.ToString();
MinSizeBox.Text = config.MinSizeMB.ToString();
MaxConcurrentBox.Text = config.MaxConcurrentDownloads.ToString();
RetriesBox.Text = config.Retries.ToString();
FfmpegBox.Text = config.FfmpegPath ?? "";
// 테마
ThemeModeBox.SelectedValue = config.Theme;
if (ThemeModeBox.SelectedIndex < 0) ThemeModeBox.SelectedValue = "dark";
var accent = config.AccentColor;
AccentBlue.IsChecked = accent == "#60A5FA" || accent == "#2563EB";
AccentGreen.IsChecked = accent == "#34D399" || accent == "#15803D";
AccentPink.IsChecked = accent == "#F472B6" || accent == "#DB2777";
AccentDefault.IsChecked = !AccentBlue.IsChecked.Value && !AccentGreen.IsChecked.Value && !AccentPink.IsChecked.Value;
AccentColorBox.Text = accent ?? "";
CustomThemeBox.Text = config.CustomThemePath ?? "";
UiDensityBox.SelectedValue = config.UiDensity;
if (UiDensityBox.SelectedIndex < 0) UiDensityBox.SelectedValue = "normal";
UiScaleSlider.Value = Math.Clamp(config.UiScale, 1.0, 2.0);
UiScaleLabel.Text = $"{Math.Round(UiScaleSlider.Value * 100)}%";
UiFontScaleSlider.Value = Math.Clamp(config.UiFontScale <= 0 ? 1.0 : config.UiFontScale, 1.0, 2.0);
UiFontFamilyBox.Text = config.UiFontFamily ?? "";
UiFontKoreanFallbackBox.Text = config.UiFontKoreanFallback ?? "Malgun Gothic";
FontSizeBox.SelectedValue = config.UiFontSize.ToString("0");
if (FontSizeBox.SelectedIndex < 0) FontSizeBox.SelectedValue = "13";
LanguageBox.SelectedValue = config.Language;
if (LanguageBox.SelectedIndex < 0) LanguageBox.SelectedValue = "ko";
AutoStartBox.IsChecked = config.AutoStart;
MinimizeToTrayBox.IsChecked = config.MinimizeToTray;
MaxSpeedBox.Text = config.MaxDownloadSpeedKbps.ToString();
AutoClearCompletedBox.IsChecked = config.AutoClearCompleted;
OpenFolderOnCompleteBox.IsChecked = config.OpenFolderOnComplete;
SubtitleLangBox.SelectedValue = config.SubtitleLanguage;
if (SubtitleLangBox.SelectedIndex < 0) SubtitleLangBox.SelectedItem = SubtitleLangBox.Items.OfType<ComboBoxItem>().First();
ScheduleBox.Text = config.DownloadScheduleWindow ?? "";
EnableGpuAccelerationBox.IsChecked = config.EnableGpuHardwareAcceleration;
PreferredGpuBackendBox.SelectedValue = config.PreferredGpuBackend;
if (PreferredGpuBackendBox.SelectedIndex < 0) PreferredGpuBackendBox.SelectedValue = "Auto";
PreferredVideoCodecBox.SelectedValue = config.PreferredVideoCodec;
if (PreferredVideoCodecBox.SelectedIndex < 0) PreferredVideoCodecBox.SelectedValue = "Auto";
EnableBatterySaverBox.IsChecked = config.EnableBatterySaver;
BatterySaverThresholdBox.Text = config.BatterySaverThresholdPercent.ToString();
BatterySaverThrottleBox.IsChecked = config.BatterySaverThrottleBandwidth;
NotifySoundBox.IsChecked = config.NotifySound;
DoNotDisturbBox.IsChecked = config.DoNotDisturb;
PauseOnPowerSaverBox.IsChecked = config.PauseOnPowerSaver;
ToastDurationBox.Text = (config.ToastDurationMs / 1000.0).ToString("0");
ReduceMotionBox.IsChecked = config.ReduceMotion;
ShowCaptionsBox.IsChecked = config.ShowCaptions;
FocusSelectsAddressBox.IsChecked = config.FocusSelectsAddress;
PasteAndGoBox.IsChecked = config.PasteAndGo;
TabWheelSwitchBox.IsChecked = config.TabWheelSwitch;
TabStripPositionBox.SelectedValue = config.TabStripPosition;
if (TabStripPositionBox.SelectedIndex < 0) TabStripPositionBox.SelectedValue = "top";
ConfirmCloseMultipleTabsBox.IsChecked = config.ConfirmCloseMultipleTabs;
BookmarkBarVisibleBox.IsChecked = config.BookmarkBarVisible;
var fmts = config.EnabledFormats ?? new List<string> { "hls", "dash", "mp4" };
FmtHls.IsChecked = fmts.Contains("hls");
FmtDash.IsChecked = fmts.Contains("dash");
FmtMp4.IsChecked = fmts.Contains("mp4");
FmtWebM.IsChecked = fmts.Contains("webm");
FmtMkv.IsChecked = fmts.Contains("mkv");
FmtM4v.IsChecked = fmts.Contains("m4v");
FmtMov.IsChecked = fmts.Contains("mov");
FmtAvi.IsChecked = fmts.Contains("avi");
FmtAudio.IsChecked = fmts.Contains("audio");
YtDlpBox.Text = config.YtDlpPath ?? "";
UseCookiesBox.IsChecked = config.UseCookiesForSocial;
AutoInstallBox.IsChecked = config.AutoInstallYtDlp;
AutoUpdateBox.IsChecked = config.AutoUpdateYtDlp;
AutoInstallDenoBox.IsChecked = config.AutoInstallDeno;
DefaultQualityBox.SelectedValue = config.DefaultSocialQuality;
if (DefaultQualityBox.SelectedIndex < 0) DefaultQualityBox.SelectedIndex = 0;
YoutubeClientsBox.Text = config.YoutubePlayerClients ?? "";
UserAgentBox.Text = config.UserAgent;
ProxyBox.Text = config.ProxyServer;
RestoreTabsSuspendedBox.IsChecked = config.RestoreTabsSuspended;
TabTitleMultilineBox.IsChecked = config.TabTitleMultiline;
ProfilePathBox.Text = MainWindow.ProfileFolder;
ProfileModeText.Text = config.PrivateMode
? "개인정보 보호 모드(InPrivate) — 데이터를 저장하지 않습니다"
: "일반 모드 — 쿠키·세션·localStorage 를 프로필에 보존합니다";
SearchEngineBox.ItemsSource = SearchEngines.Presets.Select(e => e.Name).ToList();
SearchEngineBox.SelectedItem = SearchEngines.FindByName(config.SearchEngine)?.Name ?? "Google";
if (SearchEngineBox.SelectedIndex < 0) SearchEngineBox.SelectedIndex = 0;
UpdateRepoBox.Text = config.UpdateRepo;
// 도구 모음 단추
var tb = config.ToolbarButtons ?? AppConfig.DefaultToolbarButtons.ToList();
ToolbarBtn_Home.IsChecked = tb.Contains("home", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Compact.IsChecked = tb.Contains("compact", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Bookmarks.IsChecked = tb.Contains("bookmarks", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_History.IsChecked = tb.Contains("history", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_TabSearch.IsChecked = tb.Contains("tabsearch", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Shield.IsChecked = tb.Contains("shield", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Private.IsChecked = tb.Contains("private", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Downloads.IsChecked = tb.Contains("downloads", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Link.IsChecked = tb.Contains("link", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Library.IsChecked = tb.Contains("library", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Playlist.IsChecked = tb.Contains("playlist", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_OpenFolder.IsChecked = tb.Contains("openfolder", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Pairing.IsChecked = tb.Contains("pairing", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Settings.IsChecked = tb.Contains("settings", StringComparer.OrdinalIgnoreCase);
ViewModel.LoadFromConfig(config, isOriginalSnapshot: true);
RefreshFontPreview();
UpdateDirtyIndicator();
}
// ---- UC-S01 검색: 섹션 제목·내용에 검색어가 없으면 숨김 ----
private void SettingsSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var q = SettingsSearchBox.Text?.Trim() ?? "";
foreach (var s in _sections)
{
var text = SectionText(s);
s.Visibility = q.Length == 0 || text.Contains(q, StringComparison.OrdinalIgnoreCase)
? Visibility.Visible : Visibility.Collapsed;
}
}
private static string SectionText(FrameworkElement section)
{
var sb = new System.Text.StringBuilder();
sb.Append(section.Tag ?? "");
CollectText(section, sb);
return sb.ToString();
}
private static void CollectText(DependencyObject d, System.Text.StringBuilder sb)
{
foreach (var child in LogicalTreeHelper.GetChildren(d))
{
switch (child)
{
case TextBlock tb: sb.AppendLine(tb.Text); break;
case TextBox tx: sb.AppendLine(tx.Text); break;
case CheckBox cb: sb.AppendLine(cb.Content?.ToString() ?? ""); break;
case DependencyObject dep: CollectText(dep, sb); break;
}
}
}
// ---- UC-S02 내비게이션: 섹션 스크롤 ----
private void SettingsNav_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (SettingsNavList.SelectedItem is NavCategoryItem nav)
{
OpenSection(nav.Tag);
}
else if (SettingsNavList.SelectedIndex >= 0 && SettingsNavList.SelectedIndex < _sections.Count)
{
_sections[SettingsNavList.SelectedIndex].BringIntoView();
}
}
/// <summary>특정 카테고리 태그로 설정 섹션을 즉시 스크롤 및 선택한다.</summary>
public void SelectCategory(string categoryName) => OpenSection(categoryName);
public void OpenSection(string sectionName)
{
var idx = _sections.FindIndex(s => string.Equals(s.Tag as string, sectionName, StringComparison.OrdinalIgnoreCase));
if (idx >= 0)
{
for (int i = 0; i < NavCategories.Length; i++)
{
if (string.Equals(NavCategories[i].Tag, sectionName, StringComparison.OrdinalIgnoreCase))
{
SettingsNavList.SelectedIndex = i;
break;
}
}
_sections[idx].BringIntoView();
}
}
// ---- §A 테마 라이브 적용 ----
private void ThemeModeBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (ThemeModeBox?.SelectedValue == null) return;
var theme = ThemeModeBox.SelectedValue as string ?? "dark";
Config.Theme = theme;
App.ApplyTheme(theme, Config.CustomThemePath);
App.ApplyAccent(Config.AccentColor);
RefreshTitleBarChrome();
(Owner as MainWindow)?.UpdateTitleBarTheme();
}
private void Accent_Checked(object sender, RoutedEventArgs e)
{
if (AccentColorBox == null) return;
if (ReferenceEquals(sender, AccentDefault)) AccentColorBox.Text = "";
else if (ReferenceEquals(sender, AccentBlue)) AccentColorBox.Text = "#2563EB";
else if (ReferenceEquals(sender, AccentGreen)) AccentColorBox.Text = "#15803D";
else if (ReferenceEquals(sender, AccentPink)) AccentColorBox.Text = "#DB2777";
}
private void BrowseThemeFile(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog { Filter = "테마 파일|*.xaml" };
if (dlg.ShowDialog() == true)
{
CustomThemeBox.Text = dlg.FileName;
Config.CustomThemePath = dlg.FileName;
App.ApplyTheme(Config.Theme, dlg.FileName);
RefreshTitleBarChrome();
}
}
private void RefreshFontPreview()
{
if (FontPreviewText == null) return;
var size = double.TryParse(FontSizeBox?.SelectedValue as string, out var s) ? s : 13;
FontPreviewText.FontSize = size * Math.Clamp(UiFontScaleSlider.Value, 1.0, 2.0);
}
private void UiScaleSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
if (UiScaleLabel != null) UiScaleLabel.Text = $"{Math.Round(e.NewValue * 100)}%";
(Owner as MainWindow)?.ApplyUiScale(e.NewValue);
}
private void UiFontScaleSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) => RefreshFontPreview();
internal async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
{
var repo = UpdateRepoBox.Text?.Trim() ?? "";
if (repo.Length == 0 || !repo.Contains('/'))
{
UpdateResultText.Text = "저장소를 owner/repo 형식으로 입력하세요 (예: user/videodownloader)";
return;
}
var btn = sender as Button;
if (btn != null) btn.IsEnabled = false;
try
{
var current = CurrentVersion();
var rid = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "win-arm64" : "win-x64";
var result = await new Paca.Core.Update.UpdateChecker().CheckAsync(repo, current, rid, Config.UpdateApiBase);
if (result.IsNewer)
{
UpdateResultText.Text = $"새 버전 {result.LatestVersion} (현재 {current})";
var open = MessageBox.Show(
$"새 버전 {result.LatestVersion} 이(가) 있습니다 (현재 {current}).\n릴리스 페이지를 열까요?",
"업데이트", MessageBoxButton.YesNo, MessageBoxImage.Information);
if (open == MessageBoxResult.Yes)
{
var url = result.Asset?.DownloadUrl ?? result.ReleaseUrl;
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true });
}
}
else
{
UpdateResultText.Text = $"최신 버전입니다 (현재 {current}, 최신 {result.LatestVersion})";
}
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
UpdateResultText.Text = $"확인 실패: {ex.Message}";
}
finally { if (btn != null) btn.IsEnabled = true; }
}
private static string CurrentVersion()
{
var asm = System.Reflection.Assembly.GetExecutingAssembly();
var info = asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>().FirstOrDefault()?.InformationalVersion;
if (!string.IsNullOrEmpty(info)) return info.Split('+')[0];
return asm.GetName().Version?.ToString() ?? "0.0.0";
}
private void SearchEngineBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (SearchEngineHint == null) return;
var engine = SearchEngines.FindByName(SearchEngineBox.SelectedItem as string);
SearchEngineHint.Text = engine == null
? "주소창에 검색어를 입력하면 검색합니다"
: $"주소창에 검색어 입력 시 {engine.Name}(으)로 검색합니다. `g 검색어`처럼 키워드로 즉시 전환도 가능해요";
}
private void BrowseFolder(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog();
if (Directory.Exists(FolderBox.Text)) dlg.InitialDirectory = FolderBox.Text;
if (dlg.ShowDialog() == true)
FolderBox.Text = dlg.FolderName;
}
private void BrowseFfmpeg(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog { Filter = "ffmpeg|ffmpeg.exe|실행 파일|*.exe|모든 파일|*.*" };
if (dlg.ShowDialog() == true)
FfmpegBox.Text = dlg.FileName;
}
private void BrowseYtDlp(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog { Filter = "yt-dlp|yt-dlp.exe|실행 파일|*.exe|모든 파일|*.*" };
if (dlg.ShowDialog() == true)
YtDlpBox.Text = dlg.FileName;
}
private async void ReinstallYtDlp_Click(object sender, RoutedEventArgs e)
{
var btn = sender as Button;
if (btn != null) btn.IsEnabled = false;
try
{
var path = await YtDlpRunner.DownloadAsync(Config, null, CancellationToken.None);
MessageBox.Show(path != null ? $"yt-dlp 설치 완료:\n{path}" : "yt-dlp 다운로드에 실패했습니다. 네트워크를 확인하세요.",
"yt-dlp", MessageBoxButton.OK, path != null ? MessageBoxImage.Information : MessageBoxImage.Warning);
if (path != null) YtDlpBox.Text = "";
}
finally { if (btn != null) btn.IsEnabled = true; }
}
private async void ClearData_Click(object sender, RoutedEventArgs e)
{
if (_clearBrowsingData == null) return;
if (MessageBox.Show("쿠키·localStorage·캐시 등 브라우저 데이터를 삭제합니다.\n로그인이 풀릴 수 있습니다. 계속할까요?",
"데이터 삭제", MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK) return;
var btn = sender as Button;
if (btn != null) btn.IsEnabled = false;
try { await _clearBrowsingData(); }
finally { if (btn != null) btn.IsEnabled = true; }
}
private void ResetDefaults_Click(object sender, RoutedEventArgs e)
{
if (MessageBox.Show("모든 설정을 기본값으로 되돌릴까요? 저장 폴더·토큰도 초기화됩니다.",
"기본값 복원", MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK) return;
Config.ResetToDefaults();
LoadValues();
ValidationHint.Text = "기본값으로 되돌렸습니다 — 확인을 누르면 저장됩니다";
}
private void ExportSettings_Click(object sender, RoutedEventArgs e)
{
Save();
var dlg = new SaveFileDialog { Filter = "설정 JSON|*.json", FileName = "paca-settings.json" };
if (dlg.ShowDialog() != true) return;
File.WriteAllText(dlg.FileName, System.Text.Json.JsonSerializer.Serialize(Config, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
}
private void ImportSettings_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog { Filter = "설정 JSON|*.json" };
if (dlg.ShowDialog() != true) return;
try
{
var imported = System.Text.Json.JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(dlg.FileName));
if (imported == null) throw new InvalidOperationException("빈 설정 파일");
ImportInto(imported);
LoadValues();
ValidationHint.Text = "설정을 가져왔습니다 — 확인을 누르면 저장됩니다";
}
catch (Exception ex)
{
ValidationHint.Text = $"가져오기 실패: {ex.Message}";
}
}
private void ImportInto(AppConfig src)
{
foreach (var prop in typeof(AppConfig).GetProperties().Where(p => p.CanRead && p.CanWrite))
{
if (prop.Name == nameof(AppConfig.RemoteAccessToken)) continue;
try { prop.SetValue(Config, prop.GetValue(src)); } catch { }
}
}
private void RestartNow_Click(object sender, RoutedEventArgs e)
{
Save();
MainWindow.RestartApp();
}
public bool ConfirmDiscardChanges()
{
if (!IsDirty()) return true;
return MessageBox.Show(this, "저장하지 않은 변경 사항이 있습니다.\n폐기하고 닫을까요?",
"변경 사항", MessageBoxButton.OKCancel, MessageBoxImage.Warning) == MessageBoxResult.OK;
}
private bool IsDirty() => !System.Text.Json.JsonSerializer.Serialize(Config).Equals(
System.Text.Json.JsonSerializer.Serialize(_original));
private static AppConfig CloneConfig(AppConfig c)
{
var clone = new AppConfig();
foreach (var prop in typeof(AppConfig).GetProperties().Where(p => p.CanRead && p.CanWrite))
try { prop.SetValue(clone, prop.GetValue(c)); } catch { }
return clone;
}
private void Cancel_Click(object sender, RoutedEventArgs e)
{
if (ConfirmDiscardChanges()) Close();
}
private bool Save()
{
ValidationHint.Text = "";
if (!int.TryParse(ConcurrencyBox.Text, out var c) || c < 1) return Invalid("세그먼트 동시 수는 1 이상의 숫자");
if (!int.TryParse(MaxConcurrentBox.Text, out var mc) || mc < 1) return Invalid("최대 동시 항목은 1 이상의 숫자");
if (!int.TryParse(RetriesBox.Text, out var retries) || retries < 1) return Invalid("재시도 횟수는 1 이상의 숫자");
if (!long.TryParse(MinSizeBox.Text, out var m) || m < 0) return Invalid("최소 크기는 0 이상의 숫자");
if (!int.TryParse(MaxSpeedBox.Text, out var speed) || speed < 0) return Invalid("속도 상한은 0 이상의 숫자");
if (!double.TryParse(ToastDurationBox.Text, out var toastSec) || toastSec < 0.5) return Invalid("토스트 시간은 0.5초 이상");
if (!int.TryParse(BatterySaverThresholdBox.Text, out var bst) || bst < 5 || bst > 50) return Invalid("배터리 절전 임계치는 5~50 사이의 정수");
if (!string.IsNullOrWhiteSpace(ScheduleBox.Text) && !Services.DownloadScheduler.TryParse(ScheduleBox.Text.Trim(), out _, out _))
return Invalid("예약 시간대는 HH:mm-HH:mm 형식(예: 02:00-08:00)");
var accent = AccentColorBox.Text?.Trim();
if (!string.IsNullOrEmpty(accent) &&
!(accent.Length == 7 && accent.StartsWith('#') && accent[1..].All(Uri.IsHexDigit)))
return Invalid("강조색은 #RRGGBB 형식");
Config.OutputFolder = FolderBox.Text?.Trim() ?? "";
Config.HomeUrl = string.IsNullOrWhiteSpace(HomeBox.Text) ? "https://www.google.com" : HomeBox.Text.Trim();
Config.Concurrency = Math.Max(1, c);
Config.MaxConcurrentDownloads = Math.Max(1, mc);
Config.Retries = Math.Max(1, retries);
Config.MinSizeMB = Math.Max(0, m);
Config.MaxDownloadSpeedKbps = speed;
Config.FfmpegPath = string.IsNullOrWhiteSpace(FfmpegBox.Text) ? null : FfmpegBox.Text.Trim();
Config.EnableGpuHardwareAcceleration = EnableGpuAccelerationBox.IsChecked == true;
Config.PreferredGpuBackend = PreferredGpuBackendBox.SelectedValue as string ?? "Auto";
Config.PreferredVideoCodec = PreferredVideoCodecBox.SelectedValue as string ?? "Auto";
Config.EnableBatterySaver = EnableBatterySaverBox.IsChecked == true;
Config.BatterySaverThresholdPercent = Math.Clamp(bst, 5, 50);
Config.BatterySaverThrottleBandwidth = BatterySaverThrottleBox.IsChecked == true;
Config.Theme = ThemeModeBox.SelectedValue as string ?? "dark";
Config.AccentColor = string.IsNullOrEmpty(accent) ? null : accent;
Config.CustomThemePath = string.IsNullOrWhiteSpace(CustomThemeBox.Text) ? null : CustomThemeBox.Text.Trim();
Config.UiDensity = UiDensityBox.SelectedValue as string ?? "normal";
Config.UiScale = Math.Clamp(UiScaleSlider.Value, 1.0, 2.0);
Config.UiFontScale = Math.Clamp(UiFontScaleSlider.Value, 1.0, 2.0);
Config.UiFontFamily = UiFontFamilyBox.Text?.Trim() ?? "";
Config.UiFontKoreanFallback = string.IsNullOrWhiteSpace(UiFontKoreanFallbackBox.Text) ? "Malgun Gothic" : UiFontKoreanFallbackBox.Text.Trim();
Config.UiFontSize = double.TryParse(FontSizeBox.SelectedValue as string, out var fs) ? fs : 13;
Config.Language = LanguageBox.SelectedValue as string ?? "ko";
Config.AutoStart = AutoStartBox.IsChecked == true;
Config.MinimizeToTray = MinimizeToTrayBox.IsChecked == true;
Config.SearchEngine = SearchEngineBox.SelectedItem as string ?? "Google";
Config.UpdateRepo = UpdateRepoBox.Text?.Trim() ?? "";
Config.UserAgent = string.IsNullOrWhiteSpace(UserAgentBox.Text)
? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
: UserAgentBox.Text.Trim();
var proxyChanged = Config.ProxyServer != (ProxyBox.Text?.Trim() ?? "");
Config.ProxyServer = ProxyBox.Text?.Trim() ?? "";
Config.AutoClearCompleted = AutoClearCompletedBox.IsChecked == true;
Config.OpenFolderOnComplete = OpenFolderOnCompleteBox.IsChecked == true;
Config.SubtitleLanguage = (SubtitleLangBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "";
Config.DownloadScheduleWindow = string.IsNullOrWhiteSpace(ScheduleBox.Text) ? null : ScheduleBox.Text.Trim();
Config.NotifySound = NotifySoundBox.IsChecked == true;
Config.DoNotDisturb = DoNotDisturbBox.IsChecked == true;
Config.PauseOnPowerSaver = PauseOnPowerSaverBox.IsChecked == true;
Config.ToastDurationMs = (int)(toastSec * 1000);
Config.ReduceMotion = ReduceMotionBox.IsChecked == true;
Config.ShowCaptions = ShowCaptionsBox.IsChecked == true;
Config.FocusSelectsAddress = FocusSelectsAddressBox.IsChecked == true;
Config.PasteAndGo = PasteAndGoBox.IsChecked == true;
Config.TabWheelSwitch = TabWheelSwitchBox.IsChecked == true;
Config.TabStripPosition = TabStripPositionBox.SelectedValue as string ?? "top";
Config.ConfirmCloseMultipleTabs = ConfirmCloseMultipleTabsBox.IsChecked == true;
Config.BookmarkBarVisible = BookmarkBarVisibleBox.IsChecked == true;
Config.RestoreTabsSuspended = RestoreTabsSuspendedBox.IsChecked == true;
Config.TabTitleMultiline = TabTitleMultilineBox.IsChecked == true;
var tbList = new List<string>();
if (ToolbarBtn_Home.IsChecked == true) tbList.Add("home");
if (ToolbarBtn_Compact.IsChecked == true) tbList.Add("compact");
if (ToolbarBtn_Bookmarks.IsChecked == true) tbList.Add("bookmarks");
if (ToolbarBtn_History.IsChecked == true) tbList.Add("history");
if (ToolbarBtn_TabSearch.IsChecked == true) tbList.Add("tabsearch");
if (ToolbarBtn_Shield.IsChecked == true) tbList.Add("shield");
if (ToolbarBtn_Private.IsChecked == true) tbList.Add("private");
if (ToolbarBtn_Downloads.IsChecked == true) tbList.Add("downloads");
if (ToolbarBtn_Link.IsChecked == true) tbList.Add("link");
if (ToolbarBtn_Library.IsChecked == true) tbList.Add("library");
if (ToolbarBtn_Playlist.IsChecked == true) tbList.Add("playlist");
if (ToolbarBtn_OpenFolder.IsChecked == true) tbList.Add("openfolder");
if (ToolbarBtn_Pairing.IsChecked == true) tbList.Add("pairing");
if (ToolbarBtn_Settings.IsChecked == true) tbList.Add("settings");
Config.ToolbarButtons = tbList;
var fmts = new List<string>();
if (FmtHls.IsChecked == true) fmts.Add("hls");
if (FmtDash.IsChecked == true) fmts.Add("dash");
if (FmtMp4.IsChecked == true) fmts.Add("mp4");
if (FmtWebM.IsChecked == true) fmts.Add("webm");
if (FmtMkv.IsChecked == true) fmts.Add("mkv");
if (FmtM4v.IsChecked == true) fmts.Add("m4v");
if (FmtMov.IsChecked == true) fmts.Add("mov");
if (FmtAvi.IsChecked == true) fmts.Add("avi");
if (FmtAudio.IsChecked == true) fmts.Add("audio");
Config.EnabledFormats = fmts;
Config.YtDlpPath = string.IsNullOrWhiteSpace(YtDlpBox.Text) ? null : YtDlpBox.Text.Trim();
Config.UseCookiesForSocial = UseCookiesBox.IsChecked == true;
Config.AutoInstallYtDlp = AutoInstallBox.IsChecked == true;
Config.AutoUpdateYtDlp = AutoUpdateBox.IsChecked == true;
Config.AutoInstallDeno = AutoInstallDenoBox.IsChecked == true;
Config.DefaultSocialQuality = (DefaultQualityBox.SelectedValue as string) ?? "best";
Config.YoutubePlayerClients = string.IsNullOrWhiteSpace(YoutubeClientsBox.Text) ? null : YoutubeClientsBox.Text.Trim();
// 즉시 반영
App.ApplyFontSettings();
App.ApplyReduceMotion(Config.ReduceMotion);
App.ApplyAccent(Config.AccentColor);
(Owner as MainWindow)?.ApplyToolbarButtons(Config);
ApplyAutoStart(Config.AutoStart);
if (proxyChanged) _needsRestart = true;
RestartBanner.Visibility = _needsRestart ? Visibility.Visible : Visibility.Collapsed;
return true;
}
private void ToolbarSelectDefaults_Click(object sender, RoutedEventArgs e)
{
var defaults = AppConfig.DefaultToolbarButtons;
ToolbarBtn_Home.IsChecked = defaults.Contains("home", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Compact.IsChecked = defaults.Contains("compact", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Bookmarks.IsChecked = defaults.Contains("bookmarks", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_History.IsChecked = defaults.Contains("history", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_TabSearch.IsChecked = defaults.Contains("tabsearch", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Shield.IsChecked = defaults.Contains("shield", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Private.IsChecked = defaults.Contains("private", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Downloads.IsChecked = defaults.Contains("downloads", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Link.IsChecked = defaults.Contains("link", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Library.IsChecked = defaults.Contains("library", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Playlist.IsChecked = defaults.Contains("playlist", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_OpenFolder.IsChecked = defaults.Contains("openfolder", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Pairing.IsChecked = defaults.Contains("pairing", StringComparer.OrdinalIgnoreCase);
ToolbarBtn_Settings.IsChecked = defaults.Contains("settings", StringComparer.OrdinalIgnoreCase);
}
private void ToolbarSelectAll_Click(object sender, RoutedEventArgs e)
{
ToolbarBtn_Home.IsChecked = true;
ToolbarBtn_Compact.IsChecked = true;
ToolbarBtn_Bookmarks.IsChecked = true;
ToolbarBtn_History.IsChecked = true;
ToolbarBtn_TabSearch.IsChecked = true;
ToolbarBtn_Shield.IsChecked = true;
ToolbarBtn_Private.IsChecked = true;
ToolbarBtn_Downloads.IsChecked = true;
ToolbarBtn_Link.IsChecked = true;
ToolbarBtn_Library.IsChecked = true;
ToolbarBtn_Playlist.IsChecked = true;
ToolbarBtn_OpenFolder.IsChecked = true;
ToolbarBtn_Pairing.IsChecked = true;
ToolbarBtn_Settings.IsChecked = true;
}
private bool Invalid(string message)
{
ValidationHint.Text = message;
return false;
}
private static void ApplyAutoStart(bool enable)
{
try
{
using var key = Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run");
var name = "Paca";
if (enable)
{
var exe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exe)) key.SetValue(name, $"\"{exe}\"");
}
else
{
key.DeleteValue(name, throwOnMissingValue: false);
key.DeleteValue("VideoDownloader", throwOnMissingValue: false);
}
}
catch { }
}
private void Ok_Click(object sender, RoutedEventArgs e)
{
if (!Save()) return;
DialogResult = true;
}
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
RefreshTitleBarChrome();
try
{
var hwnd = new WindowInteropHelper(this).Handle;
var source = HwndSource.FromHwnd(hwnd);
source?.AddHook(HwndProc);
}
catch { }
}
private IntPtr HwndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
const int WM_GETMINMAXINFO = 0x0024;
if (msg == WM_GETMINMAXINFO)
{
WmGetMinMaxInfo(hwnd, lParam);
handled = true;
}
return IntPtr.Zero;
}
private static void WmGetMinMaxInfo(IntPtr hwnd, IntPtr lParam)
{
try
{
var mmi = Marshal.PtrToStructure<MINMAXINFO>(lParam);
const int MONITOR_DEFAULTTONEAREST = 0x00000002;
IntPtr monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (monitor != IntPtr.Zero)
{
var monitorInfo = new MONITORINFO { cbSize = Marshal.SizeOf(typeof(MONITORINFO)) };
if (GetMonitorInfo(monitor, ref monitorInfo))
{
var rcWorkArea = monitorInfo.rcWork;
var rcMonitorArea = monitorInfo.rcMonitor;
mmi.ptMaxPosition.X = Math.Abs(rcWorkArea.Left - rcMonitorArea.Left);
mmi.ptMaxPosition.Y = Math.Abs(rcWorkArea.Top - rcMonitorArea.Top);
mmi.ptMaxSize.X = Math.Abs(rcWorkArea.Right - rcWorkArea.Left);
mmi.ptMaxSize.Y = Math.Abs(rcWorkArea.Bottom - rcWorkArea.Top);
}
}
Marshal.StructureToPtr(mmi, lParam, true);
}
catch { }
}
internal void RefreshTitleBarChrome()
{
try
{
var hwnd = new WindowInteropHelper(this).Handle;
if (hwnd == IntPtr.Zero) return;
int dark = App.IsLightTheme ? 0 : 1;
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
int round = 2;
DwmSetWindowAttribute(hwnd, 33, ref round, sizeof(int));
}
catch { }
}
private void OpenCredentialVault_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("Paca OS 생체인증 보안 자격증명 금고(Windows Hello / AES-256-GCM)가 안전하게 보호 중입니다.", "암호 관리자", MessageBoxButton.OK, MessageBoxImage.Information);
}
private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e)
{
if (Paca.App.Utils.WindowDragHelper.IsInteractiveElement(e.OriginalSource as DependencyObject))
return;
if (e.ClickCount >= 2 && ResizeMode == ResizeMode.CanResize)
{
e.Handled = true;
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
return;
}
if (e.LeftButton == MouseButtonState.Pressed) { try { DragMove(); } catch { } }
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)]
private struct MINMAXINFO
{
public POINT ptReserved;
public POINT ptMaxSize;
public POINT ptMaxPosition;
public POINT ptMinTrackSize;
public POINT ptMaxTrackSize;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public int dwFlags;
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, int dwFlags);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
}

View file

@ -0,0 +1,64 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ===== 다크 팔레트 (단일 보라 액센트 - 의도적 통일) =====
UC-T14/T15/T18/A20: WCAG 대비 결함 수정 — TextMuted/BorderBrush/AccentBrush 밝게 조정. -->
<SolidColorBrush x:Key="WindowBg" Color="#0B0B10"/>
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#11111B" Offset="0"/>
<GradientStop Color="#0B0B10" Offset="0.45"/>
<GradientStop Color="#09090D" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PanelBg" Color="#14141C"/>
<SolidColorBrush x:Key="Surface2" Color="#181822"/>
<SolidColorBrush x:Key="GlassBg" Color="#1A1A24"/>
<SolidColorBrush x:Key="GlassBorder" Color="#2A2A38"/>
<SolidColorBrush x:Key="CardBg" Color="#1C1C26"/>
<SolidColorBrush x:Key="CardBgHover" Color="#242430"/>
<SolidColorBrush x:Key="DimOverlayBrush" Color="#CC000000"/>
<SolidColorBrush x:Key="BorderBrush" Color="#6C6C8A"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#20202B"/>
<SolidColorBrush x:Key="TextPrimary" Color="#F2F2F6"/>
<SolidColorBrush x:Key="TextSecondary" Color="#CBD5E1"/>
<SolidColorBrush x:Key="TextMuted" Color="#A1A1AA"/>
<SolidColorBrush x:Key="TextDisabled" Color="#71717A"/>
<SolidColorBrush x:Key="TrackBrush" Color="#262631"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#F87171"/>
<SolidColorBrush x:Key="PendingBrush" Color="#FBBF24"/>
<SolidColorBrush x:Key="WarningBrush" Color="#FB923C"/>
<SolidColorBrush x:Key="InfoBrush" Color="#60A5FA"/>
<SolidColorBrush x:Key="SelectionBrush" Color="#4A3F9E"/>
<SolidColorBrush x:Key="FocusAdornerBrush" Color="#B4A7FF"/>
<SolidColorBrush x:Key="LinkBrush" Color="#93B4FF"/>
<SolidColorBrush x:Key="VisitedLinkBrush" Color="#C4A9E8"/>
<SolidColorBrush x:Key="AccentTextBrush" Color="#A99DFF"/>
<!-- 마이크로 상태 글로우 브러시 (다운로드 중 은은한 네온 펄스 & 완료 성공 틴트) -->
<SolidColorBrush x:Key="StatusGlowActiveBrush" Color="#226366F1"/>
<SolidColorBrush x:Key="StatusGlowSuccessBrush" Color="#2234D399"/>
<SolidColorBrush x:Key="StatusGlowErrorBrush" Color="#22F87171"/>
<!-- 프리미엄 스크롤바 브러시 (다크 - 초슬림 미니멀) -->
<SolidColorBrush x:Key="ScrollBarTrackBrush" Color="Transparent"/>
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#26FFFFFF"/>
<SolidColorBrush x:Key="ScrollBarThumbHoverBrush" Color="#66FFFFFF"/>
<SolidColorBrush x:Key="ScrollBarThumbDragBrush" Color="#99FFFFFF"/>
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#6366F1" Offset="0"/>
<GradientStop Color="#A855F7" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="AccentBrush" Color="#8B7CFF"/>
<LinearGradientBrush x:Key="AccentFadeBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#556366F1" Offset="0"/>
<GradientStop Color="#006366F1" Offset="1"/>
</LinearGradientBrush>
<!-- UC-T09 강조색 프리셋 스와치 (설정창 색 선택기가 참조) -->
<SolidColorBrush x:Key="Accent.Blue" Color="#60A5FA"/>
<SolidColorBrush x:Key="Accent.Green" Color="#34D399"/>
<SolidColorBrush x:Key="Accent.Pink" Color="#F472B6"/>
<SolidColorBrush x:Key="Accent.Orange" Color="#FB923C"/>
</ResourceDictionary>

View file

@ -0,0 +1,63 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ===== 고대비 팔레트 (UC-T05/A04) — Windows 고대비 테마 관례: 검정 배경 + 원색.
Dark/Light 와 동일 키를 전부 정의해 Shared.xaml 스타일이 그대로 동작한다. ===== -->
<SolidColorBrush x:Key="WindowBg" Color="#000000"/>
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#000000" Offset="0"/>
<GradientStop Color="#000000" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PanelBg" Color="#000000"/>
<SolidColorBrush x:Key="Surface2" Color="#1A1A1A"/>
<SolidColorBrush x:Key="GlassBg" Color="#1A1A1A"/>
<SolidColorBrush x:Key="GlassBorder" Color="#FFFFFF"/>
<SolidColorBrush x:Key="CardBg" Color="#000000"/>
<SolidColorBrush x:Key="CardBgHover" Color="#2D2D2D"/>
<SolidColorBrush x:Key="DimOverlayBrush" Color="#EE000000"/>
<SolidColorBrush x:Key="BorderBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#767676"/>
<SolidColorBrush x:Key="TextPrimary" Color="#FFFFFF"/>
<SolidColorBrush x:Key="TextSecondary" Color="#E8E8E8"/>
<SolidColorBrush x:Key="TextMuted" Color="#D0D0D0"/>
<SolidColorBrush x:Key="TextDisabled" Color="#8A8A8A"/>
<SolidColorBrush x:Key="TrackBrush" Color="#5A5A5A"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#00FF66"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#FFA8A8"/>
<SolidColorBrush x:Key="PendingBrush" Color="#FFFF00"/>
<SolidColorBrush x:Key="WarningBrush" Color="#FFA500"/>
<SolidColorBrush x:Key="InfoBrush" Color="#00C8FF"/>
<SolidColorBrush x:Key="SelectionBrush" Color="#FFFF00"/>
<SolidColorBrush x:Key="FocusAdornerBrush" Color="#FFFF00"/>
<SolidColorBrush x:Key="LinkBrush" Color="#00C8FF"/>
<SolidColorBrush x:Key="VisitedLinkBrush" Color="#C77BFF"/>
<SolidColorBrush x:Key="AccentTextBrush" Color="#FFFF00"/>
<!-- 마이크로 상태 글로우 브러시 (고대비) -->
<SolidColorBrush x:Key="StatusGlowActiveBrush" Color="#33FFFF00"/>
<SolidColorBrush x:Key="StatusGlowSuccessBrush" Color="#3300FF66"/>
<SolidColorBrush x:Key="StatusGlowErrorBrush" Color="#33FF6666"/>
<!-- 프리미엄 스크롤바 브러시 (고대비) -->
<SolidColorBrush x:Key="ScrollBarTrackBrush" Color="Transparent"/>
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#FFFF00"/>
<SolidColorBrush x:Key="ScrollBarThumbHoverBrush" Color="#00FF66"/>
<SolidColorBrush x:Key="ScrollBarThumbDragBrush" Color="#00C8FF"/>
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FFFF00" Offset="0"/>
<GradientStop Color="#FFFF00" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="AccentBrush" Color="#FFFF00"/>
<LinearGradientBrush x:Key="AccentFadeBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#66FFFF00" Offset="0"/>
<GradientStop Color="#00FFFF00" Offset="1"/>
</LinearGradientBrush>
<!-- UC-T09 강조색 프리셋 (고대비에서도 동일 키 제공) -->
<SolidColorBrush x:Key="Accent.Blue" Color="#00C8FF"/>
<SolidColorBrush x:Key="Accent.Green" Color="#00FF66"/>
<SolidColorBrush x:Key="Accent.Pink" Color="#FF6EC7"/>
<SolidColorBrush x:Key="Accent.Orange" Color="#FFA500"/>
</ResourceDictionary>

View file

@ -0,0 +1,64 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ===== 라이트 팔레트 (동일 키 - Shared.xaml 의 스타일이 그대로 동작) =====
UC-T16/T17/A19: TextMuted 어둡게(#63687A), TrackBrush 명확히(#808798) 조정. -->
<SolidColorBrush x:Key="WindowBg" Color="#F6F7F9"/>
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#FFFFFF" Offset="0"/>
<GradientStop Color="#F6F7F9" Offset="0.5"/>
<GradientStop Color="#EEF0F3" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PanelBg" Color="#F0F1F5"/>
<SolidColorBrush x:Key="Surface2" Color="#E4E7ED"/>
<SolidColorBrush x:Key="GlassBg" Color="#E9EBF0"/>
<SolidColorBrush x:Key="GlassBorder" Color="#D0D5E0"/>
<SolidColorBrush x:Key="CardBg" Color="#FFFFFF"/>
<SolidColorBrush x:Key="CardBgHover" Color="#EEF0F4"/>
<SolidColorBrush x:Key="DimOverlayBrush" Color="#88000000"/>
<SolidColorBrush x:Key="BorderBrush" Color="#858B9C"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#E3E6EC"/>
<SolidColorBrush x:Key="TextPrimary" Color="#1B1C24"/>
<SolidColorBrush x:Key="TextSecondary" Color="#4A4E5E"/>
<SolidColorBrush x:Key="TextMuted" Color="#63687A"/>
<SolidColorBrush x:Key="TextDisabled" Color="#9DA2B1"/>
<SolidColorBrush x:Key="TrackBrush" Color="#808798"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#15A34A"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#DC2626"/>
<SolidColorBrush x:Key="PendingBrush" Color="#C77708"/>
<SolidColorBrush x:Key="WarningBrush" Color="#B45309"/>
<SolidColorBrush x:Key="InfoBrush" Color="#2563EB"/>
<SolidColorBrush x:Key="SelectionBrush" Color="#C7CFFB"/>
<SolidColorBrush x:Key="FocusAdornerBrush" Color="#5B5FE3"/>
<SolidColorBrush x:Key="LinkBrush" Color="#2C5BD1"/>
<SolidColorBrush x:Key="VisitedLinkBrush" Color="#7C3AAB"/>
<SolidColorBrush x:Key="AccentTextBrush" Color="#5B5FE3"/>
<!-- 마이크로 상태 글로우 브러시 (다운로드 중 은은한 네온 펄스 & 완료 성공 틴트) -->
<SolidColorBrush x:Key="StatusGlowActiveBrush" Color="#155B5FE3"/>
<SolidColorBrush x:Key="StatusGlowSuccessBrush" Color="#15059669"/>
<SolidColorBrush x:Key="StatusGlowErrorBrush" Color="#15DC2626"/>
<!-- 프리미엄 스크롤바 브러시 (라이트 - 초슬림 미니멀) -->
<SolidColorBrush x:Key="ScrollBarTrackBrush" Color="Transparent"/>
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#1E000000"/>
<SolidColorBrush x:Key="ScrollBarThumbHoverBrush" Color="#50000000"/>
<SolidColorBrush x:Key="ScrollBarThumbDragBrush" Color="#80000000"/>
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#6366F1" Offset="0"/>
<GradientStop Color="#A855F7" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="AccentBrush" Color="#6366F1"/>
<LinearGradientBrush x:Key="AccentFadeBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#306366F1" Offset="0"/>
<GradientStop Color="#006366F1" Offset="1"/>
</LinearGradientBrush>
<!-- UC-T09 강조색 프리셋 스와치 (다크와 동일 키 — 배경에 따라 살짝 어둡게) -->
<SolidColorBrush x:Key="Accent.Blue" Color="#2563EB"/>
<SolidColorBrush x:Key="Accent.Green" Color="#15803D"/>
<SolidColorBrush x:Key="Accent.Pink" Color="#DB2777"/>
<SolidColorBrush x:Key="Accent.Orange" Color="#C2410C"/>
</ResourceDictionary>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,147 @@
using System;
using System.Diagnostics;
using System.IO;
namespace Paca.App.Utils;
/// <summary>
/// Windows 탐색기(Explorer) 안전 실행 및 파일 선택 유틸리티.
/// 공백·한글·특수문자가 포함된 경로에서도 /select,"경로" 인젝션 및 실패를 완벽 방지하고,
/// UseShellExecute = true 로 셸 프로세스를 통해 파일 또는 폴더를 안정적으로 연다.
/// </summary>
public static class ExplorerLauncher
{
public static ProcessStartInfo BuildProcessStartInfo(string? selectFile, string defaultFolder)
{
var folder = string.IsNullOrWhiteSpace(defaultFolder)
? Environment.GetFolderPath(Environment.SpecialFolder.MyVideos)
: defaultFolder.Trim();
try
{
folder = Path.GetFullPath(folder);
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
}
catch
{
// 폴더 생성 실패 시 안전 기본값
}
string? resolvedTarget = null;
if (!string.IsNullOrWhiteSpace(selectFile))
{
var candidate = selectFile.Trim();
if (!Path.IsPathRooted(candidate) && !string.IsNullOrWhiteSpace(folder))
{
var combined = Path.Combine(folder, candidate);
if (File.Exists(combined) || Directory.Exists(combined))
candidate = combined;
}
try
{
resolvedTarget = Path.GetFullPath(candidate);
}
catch
{
resolvedTarget = candidate;
}
}
// 1. 파일이 실제로 존재하면 explorer.exe /select,"절대경로" 구성
if (!string.IsNullOrEmpty(resolvedTarget) && File.Exists(resolvedTarget))
{
return new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"/select,\"{resolvedTarget}\"",
UseShellExecute = true,
};
}
// 2. 대상이 디렉터리인 경우 디렉터리 자체 열기
if (!string.IsNullOrEmpty(resolvedTarget) && Directory.Exists(resolvedTarget))
{
return new ProcessStartInfo
{
FileName = resolvedTarget,
UseShellExecute = true,
};
}
// 3. 파일은 없지만 부모 디렉터리가 존재하는 경우 부모 디렉터리 열기
if (!string.IsNullOrEmpty(resolvedTarget))
{
try
{
var parent = Path.GetDirectoryName(resolvedTarget);
if (!string.IsNullOrEmpty(parent) && Directory.Exists(parent))
{
return new ProcessStartInfo
{
FileName = parent,
UseShellExecute = true,
};
}
}
catch { }
}
// 4. 기본 폴백: OutputFolder 열기
var fallbackFolder = Directory.Exists(folder) ? folder : Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
return new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"\"{fallbackFolder}\"",
UseShellExecute = true,
};
}
public static void Open(string? selectFile, string defaultFolder, Action<string>? log = null)
{
try
{
var psi = BuildProcessStartInfo(selectFile, defaultFolder);
// 1차 시도: /select 또는 디렉터리 직접 실행
try
{
Process.Start(psi);
return;
}
catch (Exception ex)
{
log?.Invoke($"[탐색기] 1차 실행 실패 ({ex.Message}), 디렉터리 직접 열기 폴백 시도");
}
// 2차 폴백: /select 가 실패한 경우 부모 폴더 또는 defaultFolder 직접 열기
string fallbackDir = defaultFolder;
if (!string.IsNullOrWhiteSpace(selectFile))
{
try
{
var parent = Path.GetDirectoryName(Path.GetFullPath(selectFile));
if (!string.IsNullOrEmpty(parent) && Directory.Exists(parent))
fallbackDir = parent;
}
catch { }
}
if (Directory.Exists(fallbackDir))
{
Process.Start(new ProcessStartInfo
{
FileName = fallbackDir,
UseShellExecute = true,
});
}
}
catch (Exception ex)
{
log?.Invoke($"[탐색기] 최종 폴더 열기 실패: {ex.Message}");
}
}
}

View file

@ -0,0 +1,156 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Media3D;
namespace Paca.App.Utils;
/// <summary>
/// 창 상단 툴바, 탭 바, 북마크 바 및 패널 헤더의 드래그 이동과 더블클릭 최대화/복원 제어 헬퍼.
/// 버튼, 입력창, 주소창, 탭 아이템 등 상호작용 요소 클릭 시에는 창 최대화/드래그가 작동하지 않도록 차단하고,
/// 순수 '빈 공간'을 더블클릭했을 때만 창 최대화/복원, 드래그 시 창 이동(DragMove)을 수행합니다.
/// </summary>
public static class WindowDragHelper
{
/// <summary>
/// 대상 요소(또는 그 부모 트리)가 버튼, 입력창, 탭 등 상호작용 컨트롤인지 판정합니다.
/// </summary>
public static bool IsInteractiveElement(DependencyObject? source)
{
if (source == null) return false;
for (var d = source; d != null; d = GetParent(d))
{
// 1. 표준 WPF 대화형 컨트롤 타입
if (d is ButtonBase ||
d is TextBoxBase ||
d is TextBox ||
d is PasswordBox ||
d is ComboBox ||
d is ComboBoxItem ||
d is ProgressBar ||
d is Slider ||
d is ScrollBar ||
d is Thumb ||
d is Menu ||
d is MenuItem ||
d is ContextMenu ||
d is TabItem ||
d is Selector ||
d is ListBoxItem ||
d is Hyperlink)
{
return true;
}
// 2. 명시적 이름 기반 상호작용 컨테이너/아이템 (주소창, 탭 등) 및 손가락 커서 인터랙션 요소
if (d is FrameworkElement fe)
{
if (fe.Cursor == Cursors.Hand)
return true;
var name = fe.Name;
if (!string.IsNullOrEmpty(name))
{
switch (name)
{
// 주소창 영역
case "AddressBarContainer":
case "AddressBox":
case "NavProgress":
case "BookmarkStarBtn":
case "ClipboardHint":
case "WebStoreInstallBtn":
case "PageControlsBtn":
// 탭 영역 개별 아이템 및 제어 단추
case "TabBg":
case "TabTitleText":
case "AudioIcon":
case "SuspendIcon":
case "LockIcon":
case "PrivateIcon":
case "TabIndexBadge":
case "NewTabBtn":
case "NewTabMoreBtn":
case "WorkspaceSwitcher":
case "VerticalTabsToggleBtn":
case "ArchiveBtn":
// 북마크바 아이템
case "BookmarkBarList":
// 창 캡션 제어 단추
case "MaxRestoreBtn":
return true;
}
}
}
}
return false;
}
/// <summary>
/// 시각 트리(Visual Tree) 또는 논리 트리(Logical Tree) 상위 부모를 안전하게 조회합니다.
/// </summary>
private static DependencyObject? GetParent(DependencyObject current)
{
try
{
if (current is Visual or Visual3D)
{
var parent = VisualTreeHelper.GetParent(current);
if (parent != null) return parent;
}
return LogicalTreeHelper.GetParent(current);
}
catch
{
return null;
}
}
/// <summary>
/// 빈 공간에서의 더블클릭(최대화/복원) 및 드래그(창 이동)를 공통 처리합니다.
/// </summary>
public static void HandleEmptySpaceMouseLeftButtonDown(
Window window,
MouseButtonEventArgs e,
Action? onDoubleClicked = null)
{
if (window == null || e == null) return;
// 1. 대화형 컨트롤 클릭 시 가로채지 않고 그대로 흘려보냄
if (IsInteractiveElement(e.OriginalSource as DependencyObject))
return;
// 2. 순수 빈 공간 더블클릭 시 최대화/복원 토글
if (e.ClickCount >= 2)
{
e.Handled = true;
if (onDoubleClicked != null)
{
onDoubleClicked();
}
else
{
window.WindowState = window.WindowState == WindowState.Maximized
? WindowState.Normal
: WindowState.Maximized;
}
return;
}
// 3. 순수 빈 공간 마우스 왼쪽 버튼 드래그 시 창 이동
if (e.LeftButton == MouseButtonState.Pressed)
{
try
{
window.DragMove();
}
catch { }
}
}
}

View file

@ -0,0 +1,274 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Timers;
using Paca.Core.Security;
namespace Paca.App.ViewModels;
public sealed class CredentialVaultViewModel : INotifyPropertyChanged, IDisposable
{
private readonly OsProtectedCredentialVault _vault;
private readonly System.Timers.Timer _clipboardTimer;
private Action? _clipboardClearCallback;
private int _clipboardSecondsRemaining;
public event PropertyChangedEventHandler? PropertyChanged;
public OsProtectedCredentialVault Vault => _vault;
public ObservableCollection<SiteCredentialRecord> FilteredCredentials { get; } = new();
private SiteCredentialRecord? _selectedCredential;
public SiteCredentialRecord? SelectedCredential
{
get => _selectedCredential;
set
{
if (Set(ref _selectedCredential, value))
{
IsPasswordRevealed = false;
OnPropertyChanged(nameof(HasSelection));
}
}
}
public bool HasSelection => _selectedCredential != null;
private string _searchQuery = "";
public string SearchQuery
{
get => _searchQuery;
set
{
if (Set(ref _searchQuery, value))
{
ApplyFilter();
}
}
}
private string _selectedCategory = "전체";
public string SelectedCategory
{
get => _selectedCategory;
set
{
if (Set(ref _selectedCategory, value))
{
ApplyFilter();
}
}
}
private bool _isPasswordRevealed;
public bool IsPasswordRevealed
{
get => _isPasswordRevealed;
set => Set(ref _isPasswordRevealed, value);
}
public int ClipboardSecondsRemaining
{
get => _clipboardSecondsRemaining;
set => Set(ref _clipboardSecondsRemaining, value);
}
private string _statusMessage = "준비";
public string StatusMessage
{
get => _statusMessage;
set => Set(ref _statusMessage, value);
}
private string _generatedPassword = "";
public string GeneratedPassword
{
get => _generatedPassword;
set => Set(ref _generatedPassword, value);
}
private int _passwordLength = 16;
public int PasswordLength
{
get => _passwordLength;
set => Set(ref _passwordLength, value);
}
private VaultSecurityAuditReport? _auditReport;
public VaultSecurityAuditReport? AuditReport
{
get => _auditReport;
set => Set(ref _auditReport, value);
}
public CredentialVaultViewModel(OsProtectedCredentialVault vault)
{
_vault = vault ?? new OsProtectedCredentialVault();
_clipboardTimer = new System.Timers.Timer(1000);
_clipboardTimer.Elapsed += OnClipboardTimerTick;
_clipboardTimer.AutoReset = true;
GeneratePassword();
RefreshData();
}
public void ApplyFilter()
{
var all = _vault.GetAll();
FilteredCredentials.Clear();
var filtered = all.AsEnumerable();
if (!string.IsNullOrWhiteSpace(_searchQuery))
{
string q = _searchQuery.Trim().ToLowerInvariant();
filtered = filtered.Where(c =>
c.Domain.ToLowerInvariant().Contains(q) ||
c.SiteName.ToLowerInvariant().Contains(q) ||
c.Username.ToLowerInvariant().Contains(q) ||
c.Notes.ToLowerInvariant().Contains(q));
}
if (_selectedCategory != "전체" && !string.IsNullOrEmpty(_selectedCategory))
{
filtered = filtered.Where(c => c.Category.Equals(_selectedCategory, StringComparison.OrdinalIgnoreCase));
}
foreach (var item in filtered.OrderByDescending(c => c.IsFavorite).ThenBy(c => c.Domain))
{
FilteredCredentials.Add(item);
}
if (SelectedCredential != null && !FilteredCredentials.Contains(SelectedCredential))
{
SelectedCredential = FilteredCredentials.FirstOrDefault();
}
}
public void RefreshData()
{
ApplyFilter();
RunSecurityAudit();
}
public async Task<BiometricAuthResult> UnlockAsync()
{
var res = await _vault.UnlockWithBiometricAsync();
if (res.IsSuccess)
{
RefreshData();
StatusMessage = "생체 인증으로 금고가 잠금 해제되었습니다.";
}
else
{
StatusMessage = $"인증 실패: {res.ErrorMessage ?? res.Status.ToString()}";
}
return res;
}
public void Lock()
{
_vault.Lock();
IsPasswordRevealed = false;
RefreshData();
StatusMessage = "금고가 안전하게 잠겼습니다.";
}
public void RunSecurityAudit()
{
AuditReport = CredentialSecurityAuditor.PerformAudit(_vault.GetAll());
}
public void AddNewCredential(string domain, string username, string password, string siteName = "", string category = "General")
{
if (_vault.IsLocked) return;
var record = new SiteCredentialRecord
{
Domain = SiteCredentialRecord.NormalizeDomain(domain),
Url = domain.StartsWith("http") ? domain : "https://" + domain,
Username = username,
DecryptedPassword = password,
SiteName = string.IsNullOrEmpty(siteName) ? domain : siteName,
Category = category
};
_vault.AddOrUpdate(record);
RefreshData();
SelectedCredential = record;
StatusMessage = $"'{record.SiteName}' 계정이 추가되었습니다.";
}
public void UpdateCredential(SiteCredentialRecord record)
{
if (_vault.IsLocked || record == null) return;
_vault.AddOrUpdate(record);
RefreshData();
StatusMessage = $"'{record.SiteName}' 계정 정보가 수정되었습니다.";
}
public void DeleteSelectedCredential()
{
if (_vault.IsLocked || SelectedCredential == null) return;
string name = SelectedCredential.SiteName;
_vault.Remove(SelectedCredential.Id);
SelectedCredential = null;
RefreshData();
StatusMessage = $"'{name}' 계정이 삭제되었습니다.";
}
public void CopyPasswordToClipboard(Action<string> setClipboardAction, Action clearClipboardAction)
{
if (SelectedCredential == null || string.IsNullOrEmpty(SelectedCredential.DecryptedPassword)) return;
setClipboardAction(SelectedCredential.DecryptedPassword);
_clipboardClearCallback = clearClipboardAction;
ClipboardSecondsRemaining = _vault.Policy.ClipboardClearTimeoutSeconds;
_clipboardTimer.Start();
StatusMessage = $"비밀번호가 복사되었습니다. ({ClipboardSecondsRemaining}초 후 클립보드 자동 파기)";
}
private void OnClipboardTimerTick(object? sender, ElapsedEventArgs e)
{
ClipboardSecondsRemaining--;
if (ClipboardSecondsRemaining <= 0)
{
_clipboardTimer.Stop();
_clipboardClearCallback?.Invoke();
_clipboardClearCallback = null;
StatusMessage = "보안을 위해 클립보드가 자동 초기화되었습니다.";
}
}
public void GeneratePassword()
{
GeneratedPassword = PasswordSecurityEngine.GeneratePassword(new PasswordGeneratorOptions
{
Length = _passwordLength,
IncludeUppercase = true,
IncludeLowercase = true,
IncludeDigits = true,
IncludeSymbols = true,
ExcludeAmbiguousCharacters = true
});
}
private bool Set<T>(ref T field, T value, [CallerMemberName] string? propName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(propName);
return true;
}
private void OnPropertyChanged([CallerMemberName] string? propName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
}
public void Dispose()
{
_clipboardTimer.Dispose();
}
}

View file

@ -0,0 +1,236 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Paca.Core.Detection;
using Paca.Core.Social;
namespace Paca.App.ViewModels;
public class DownloadItemViewModel : INotifyPropertyChanged
{
private string _status = "대기";
private int _progress;
private bool _isIndeterminate;
private string _segments = "";
private string? _outputPath;
private string? _error;
private FormatOption? _selectedFormat;
private bool _isFetchingFormats;
private string _title;
public string Title { get => _title; set => SetField(ref _title, value); }
/// <summary>URL에서 파생된 원본 파일명(서버 업로드 해시 또는 원본 이름)</summary>
public string OriginalFileName => MediaUrl != null ? Paca.Core.Util.FileNameUtil.DeriveName(MediaUrl) : "";
/// <summary>카드 제목 및 대상 저장 파일명을 인라인으로 변경한다.</summary>
public void Rename(string newTitle)
{
if (string.IsNullOrWhiteSpace(newTitle)) return;
Title = newTitle.Trim();
if (!Started && !string.IsNullOrWhiteSpace(OutputBase))
{
var dir = Path.GetDirectoryName(OutputBase) ?? "";
OutputBase = Path.Combine(dir, Paca.Core.Util.FileNameUtil.Sanitize(Title));
}
}
private string? _thumbnailUrl;
/// <summary>영상 썸네일 이미지 URL (유튜브 등에서 자동 유도 또는 감지 메타데이터 연결)</summary>
public string? ThumbnailUrl
{
get
{
if (!string.IsNullOrWhiteSpace(_thumbnailUrl)) return _thumbnailUrl;
var source = PageUrl ?? Url ?? "";
var ytid = ExtractYouTubeId(source);
return !string.IsNullOrEmpty(ytid) ? $"https://i.ytimg.com/vi/{ytid}/mqdefault.jpg" : null;
}
set => SetField(ref _thumbnailUrl, value);
}
private static string? ExtractYouTubeId(string url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
var m = System.Text.RegularExpressions.Regex.Match(url, @"(?:v=|v\/|vi\/|youtu\.be\/|embed\/|shorts\/)([a-zA-Z0-9_-]{11})");
return m.Success ? m.Groups[1].Value : null;
}
private bool _isSelected;
/// <summary>20차: 다운로드 대기열 일괄 제어를 위한 다중 선택 플래그</summary>
public bool IsSelected { get => _isSelected; set => SetField(ref _isSelected, value); }
private bool _isPinned;
/// <summary>대기열 상단 고정 플래그</summary>
public bool IsPinned { get => _isPinned; set => SetField(ref _isPinned, value); }
private bool _isStashed;
/// <summary>나중에 다운로드할 수 있도록 보관함(Stash)에 저장된 플래그</summary>
public bool IsStashed { get => _isStashed; set => SetField(ref _isStashed, value); }
private string _tagLabel = "";
/// <summary>사용자 정의 커스텀 태그/라벨 (강의, 음악, 클립 등)</summary>
public string TagLabel { get => _tagLabel; set => SetField(ref _tagLabel, value); }
private bool _hasSubtitles;
/// <summary>자막(CC/SRT/VTT) 트랙 존재 여부</summary>
public bool HasSubtitles { get => _hasSubtitles; set => SetField(ref _hasSubtitles, value); }
private bool _downloadSubtitles;
/// <summary>자막 함께 다운로드 여부</summary>
public bool DownloadSubtitles { get => _downloadSubtitles; set => SetField(ref _downloadSubtitles, value); }
private bool _isScheduled;
/// <summary>예약 다운로드 대기 여부</summary>
public bool IsScheduled { get => _isScheduled; set => SetField(ref _isScheduled, value); }
private DateTime? _scheduledStartTime;
/// <summary>예약된 다운로드 시작 시각</summary>
public DateTime? ScheduledStartTime { get => _scheduledStartTime; set => SetField(ref _scheduledStartTime, value); }
private bool _isDuplicate;
/// <summary>동일 미디어 중복 감지 배지 플래그</summary>
public bool IsDuplicate { get => _isDuplicate; set => SetField(ref _isDuplicate, value); }
private int _reconnectAttemptCount;
/// <summary>네트워크 장애 시 자동 재접속 시도 횟수</summary>
public int ReconnectAttemptCount { get => _reconnectAttemptCount; set => SetField(ref _reconnectAttemptCount, value); }
/// <summary>항목별 상세 세그먼트/ffmpeg 인코딩 로그</summary>
public ObservableCollection<string> DetailedLogs { get; } = new();
/// <summary>항목 고유 ID(원격 API 노출용).</summary>
public string Id { get; } = Guid.NewGuid().ToString("N");
/// <summary>미디어 URL(원격 API 노출용).</summary>
public string? Url => MediaUrl?.ToString();
public string FormatTag { get; init; } = "FILE";
// 다운로드 실행에 필요한 컨텍스트 (감지 시 고정)
public MediaKind Kind { get; init; }
public Uri MediaUrl { get; init; } = null!;
public string OutputBase { get; set; } = "";
public string? PageUrl { get; init; }
public string? ContentType { get; init; }
/// <summary>오디오 스트림/파일 여부 판별 (ContentType, FormatTag, URL 확장자)</summary>
public bool IsAudio =>
(ContentType?.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) ?? false) ||
FormatTag is "MP3" or "M4A" or "AAC" or "FLAC" or "WAV" or "OGG" or "OPUS" ||
(MediaUrl?.AbsolutePath.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) ?? false) ||
(MediaUrl?.AbsolutePath.EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) ?? false) ||
(MediaUrl?.AbsolutePath.EndsWith(".aac", StringComparison.OrdinalIgnoreCase) ?? false) ||
(MediaUrl?.AbsolutePath.EndsWith(".flac", StringComparison.OrdinalIgnoreCase) ?? false) ||
(MediaUrl?.AbsolutePath.EndsWith(".wav", StringComparison.OrdinalIgnoreCase) ?? false) ||
(MediaUrl?.AbsolutePath.EndsWith(".ogg", StringComparison.OrdinalIgnoreCase) ?? false);
/// <summary>비디오 스트림/파일 여부</summary>
public bool IsVideo => !IsAudio;
// 소셜(YouTube/Instagram/X) 카드: 화질/형식 선택
public bool IsSocial => Kind == MediaKind.Social;
public ObservableCollection<FormatOption> Formats { get; } = new();
public FormatOption? SelectedFormat { get => _selectedFormat; set => SetField(ref _selectedFormat, value); }
public bool IsFetchingFormats { get => _isFetchingFormats; set => SetField(ref _isFetchingFormats, value); }
public CancellationTokenSource? Cts { get; set; }
public bool Started { get; set; }
/// <summary>§E 일시정지 상태(재개 대기) — 임시 파일 보존·"일시정지" 상태 표시 판단에 사용.</summary>
public bool PausedForResume { get; set; }
/// <summary>UC-D30 사이트별 묶음 — 출처 페이지 호스트(없으면 미디어 URL 호스트).</summary>
public string SiteName
{
get
{
var source = PageUrl ?? Url ?? "";
return Uri.TryCreate(source, UriKind.Absolute, out var u) ? u.Host : "직접 링크";
}
}
public string Status { get => _status; set => SetField(ref _status, value); }
/// <summary>다운로드가 진행 중(대기 중·다운로드 중·병합 중·리먹스 중·속도제한 대기·예약 대기)인지 여부. 단순 감지된 '대기'는 제외.</summary>
public bool IsActiveDownload => Status is "다운로드 중" or "병합 중" or "리먹스 중" or "대기 중" or "예약 대기" or "속도제한 대기";
public int Progress { get => _progress; set => SetField(ref _progress, value); }
// #9 직링크 길이 미상 시 진행률 바를 부정확(애니메이션) 모드로
public bool IsIndeterminate { get => _isIndeterminate; set => SetField(ref _isIndeterminate, value); }
public string Segments { get => _segments; set => SetField(ref _segments, value); }
public string? OutputPath { get => _outputPath; set => SetField(ref _outputPath, value); }
public string? Error { get => _error; set => SetField(ref _error, value); }
// ---- §E 다운로드 관리자 확장 (docs/UI_USECASES.md) ----
/// <summary>UC-D05 현재 속도 표시("2.4 MB/s" 등). DownloadManager 진행 콜백에서 갱신.</summary>
public string SpeedText { get => _speedText; set => SetField(ref _speedText, value); }
private string _speedText = "";
/// <summary>UC-D04 남은 시간 추정("3분 남음" 등). 속도·진행률로 계산.</summary>
public string RemainingText { get => _remainingText; set => SetField(ref _remainingText, value); }
private string _remainingText = "";
/// <summary>UC-D19 파일 총 크기 표시("142.5 MB" 등). 길이 미상이면 "".</summary>
public string TotalSizeText { get => _totalSizeText; set => SetField(ref _totalSizeText, value); }
private string _totalSizeText = "";
/// <summary>UC-D09 재시도 횟수.</summary>
public int RetryCount { get => _retryCount; set => SetField(ref _retryCount, value); }
private int _retryCount;
/// <summary>UC-D10 세그먼트별 진행 맵 — 각 세그먼트 진행률(0~1) 목록을 순서대로 유지.</summary>
public System.Collections.ObjectModel.ObservableCollection<double> SegmentMap { get; } = new();
/// <summary>UC-D24 대기열 우선순위(클수록 먼저). 기본 0.</summary>
public int Priority { get => _priority; set => SetField(ref _priority, value); }
private int _priority;
/// <summary>UC-D26 충돌 회피 후 최종 저장 파일명(OutputPath 확정 시 갱신).</summary>
public string? FinalFileName { get => _finalFileName; set => SetField(ref _finalFileName, value); }
private string? _finalFileName;
/// <summary>수신 바이트 총량(속도·ETA 계산 원천). 진행 콜백에서 누적.</summary>
public long ReceivedBytes { get => _receivedBytes; set => SetField(ref _receivedBytes, value); }
private long _receivedBytes;
/// <summary>총 바이트(길이 미상 -1). TotalSizeText 와 ETA 계산에 사용.</summary>
public long TotalBytes { get => _totalBytes; set => SetField(ref _totalBytes, value); }
private long _totalBytes = -1;
/// <summary>UC-D08 완료 시각(자동 치움 지연 판정). 미완료 null.</summary>
public DateTime? CompletedAt { get => _completedAt; set => SetField(ref _completedAt, value); }
private DateTime? _completedAt;
/// <summary>UC-D05 부속: 진행률·수신량으로 속도/ETA 텍스트를 갱신한다. speedBps ≤ 0 이면 속도만 지운다.</summary>
public void UpdateSpeedEta(long receivedBytes, long totalBytes, double speedBps)
{
ReceivedBytes = receivedBytes;
if (totalBytes > 0) TotalBytes = totalBytes;
SpeedText = speedBps <= 0
? ""
: speedBps < 1024 ? $"{speedBps:F0} B/s"
: speedBps < 1024 * 1024 ? $"{speedBps / 1024:F1} KB/s"
: $"{speedBps / 1024 / 1024:F1} MB/s";
if (TotalBytes > 0 && receivedBytes >= 0)
TotalSizeText = $"{TotalBytes / 1048576.0:F1} MB";
RemainingText = speedBps > 0 && TotalBytes > receivedBytes && receivedBytes > 0
? FormatEta(TimeSpan.FromSeconds((TotalBytes - receivedBytes) / speedBps))
: "";
}
/// <summary>ETA 사람 표기(1시간 미만 분 단위, 이상 시 시간 단위).</summary>
public static string FormatEta(TimeSpan eta) => eta.TotalHours >= 1
? $"{(int)eta.TotalHours}시간 {eta.Minutes}분 남음"
: eta.TotalMinutes >= 1
? $"{(int)Math.Ceiling(eta.TotalMinutes)}분 남음"
: $"{(int)Math.Ceiling(eta.TotalSeconds)}초 남음";
public DownloadItemViewModel(string title) { _title = title; }
public event PropertyChangedEventHandler? PropertyChanged;
private void SetField<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (Equals(field, value)) return;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}

View file

@ -0,0 +1,121 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
namespace Paca.App.ViewModels;
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
if (execute == null) throw new ArgumentNullException(nameof(execute));
_execute = _ => execute();
_canExecute = canExecute != null ? _ => canExecute() : null;
}
public event EventHandler? CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
}
public class QuickCaptureHudViewModel : INotifyPropertyChanged
{
private string _targetUrl;
private string _domainLabel = "";
private string _selectedFormat = "mp4";
private bool _isVisible = true;
public event PropertyChangedEventHandler? PropertyChanged;
public event Action<string, string>? DownloadRequested;
public event Action<string>? SendToPhoneRequested;
public event Action? DismissRequested;
public string TargetUrl
{
get => _targetUrl;
set { _targetUrl = value; OnPropertyChanged(); }
}
public string DomainLabel
{
get => _domainLabel;
set { _domainLabel = value; OnPropertyChanged(); }
}
public string SelectedFormat
{
get => _selectedFormat;
set { _selectedFormat = value; OnPropertyChanged(); }
}
public bool IsVisible
{
get => _isVisible;
set { _isVisible = value; OnPropertyChanged(); }
}
public ICommand DownloadCommand { get; }
public ICommand SendToPhoneCommand { get; }
public ICommand DismissCommand { get; }
public QuickCaptureHudViewModel(string targetUrl)
{
_targetUrl = targetUrl;
ExtractDomain(targetUrl);
DownloadCommand = new RelayCommand(() =>
{
IsVisible = false;
DownloadRequested?.Invoke(TargetUrl, SelectedFormat);
});
SendToPhoneCommand = new RelayCommand(() =>
{
IsVisible = false;
SendToPhoneRequested?.Invoke(TargetUrl);
});
DismissCommand = new RelayCommand(() =>
{
IsVisible = false;
DismissRequested?.Invoke();
});
}
private void ExtractDomain(string url)
{
try
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
var host = uri.Host.ToLowerInvariant();
if (host.StartsWith("www.")) host = host[4..];
DomainLabel = host;
}
}
catch
{
DomainLabel = "media";
}
}
protected void OnPropertyChanged([CallerMemberName] string? name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}

View file

@ -0,0 +1,865 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Paca.Core.Config;
namespace Paca.App.ViewModels;
/// <summary>
/// 설정 화면의 Input SSOT (Single Source of Truth) 뷰모델.
/// 원본 AppConfig와의 비교를 통한 실시간 Dirty 추적, 유효성 검사, 라이브 프리뷰, 초기화/가져오기/내보내기 일원화 관리.
/// </summary>
public sealed class SettingsViewModel : INotifyPropertyChanged
{
private readonly AppConfig _originalConfig;
private readonly Dictionary<string, object?> _originalValues = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _errors = new(StringComparer.Ordinal);
public event PropertyChangedEventHandler? PropertyChanged;
public event Action<string>? LivePreviewRequested;
public SettingsViewModel(AppConfig config)
{
_originalConfig = config;
LoadFromConfig(config, isOriginalSnapshot: true);
}
#region Input Properties (SSOT)
// 1. 외관 & 테마
private string _theme = "dark";
public string Theme
{
get => _theme;
set { if (Set(ref _theme, value)) OnSettingChanged(nameof(Theme), true); }
}
private string? _accentColor;
public string? AccentColor
{
get => _accentColor;
set { if (Set(ref _accentColor, value)) OnSettingChanged(nameof(AccentColor), true); }
}
private string? _customThemePath;
public string? CustomThemePath
{
get => _customThemePath;
set { if (Set(ref _customThemePath, value)) OnSettingChanged(nameof(CustomThemePath), true); }
}
private string _uiDensity = "normal";
public string UiDensity
{
get => _uiDensity;
set { if (Set(ref _uiDensity, value)) OnSettingChanged(nameof(UiDensity), true); }
}
private double _uiScale = 1.0;
public double UiScale
{
get => _uiScale;
set { if (Set(ref _uiScale, Math.Clamp(value, 1.0, 2.0))) OnSettingChanged(nameof(UiScale), true); }
}
private double _uiFontScale = 1.0;
public double UiFontScale
{
get => _uiFontScale;
set { if (Set(ref _uiFontScale, Math.Clamp(value, 1.0, 2.0))) OnSettingChanged(nameof(UiFontScale), true); }
}
private string _uiFontFamily = "";
public string UiFontFamily
{
get => _uiFontFamily;
set { if (Set(ref _uiFontFamily, value)) OnSettingChanged(nameof(UiFontFamily), true); }
}
private string _uiFontKoreanFallback = "Malgun Gothic";
public string UiFontKoreanFallback
{
get => _uiFontKoreanFallback;
set { if (Set(ref _uiFontKoreanFallback, value)) OnSettingChanged(nameof(UiFontKoreanFallback), true); }
}
private double _uiFontSize = 13.0;
public double UiFontSize
{
get => _uiFontSize;
set { if (Set(ref _uiFontSize, value)) OnSettingChanged(nameof(UiFontSize), true); }
}
// 2. 일반 & 내비게이션
private string _outputFolder = "";
public string OutputFolder
{
get => _outputFolder;
set { if (Set(ref _outputFolder, value)) OnSettingChanged(nameof(OutputFolder)); }
}
private string _homeUrl = "https://www.google.com";
public string HomeUrl
{
get => _homeUrl;
set { if (Set(ref _homeUrl, value)) OnSettingChanged(nameof(HomeUrl)); }
}
private string _language = "ko";
public string Language
{
get => _language;
set { if (Set(ref _language, value)) OnSettingChanged(nameof(Language)); }
}
private string _searchEngine = "Google";
public string SearchEngine
{
get => _searchEngine;
set { if (Set(ref _searchEngine, value)) OnSettingChanged(nameof(SearchEngine)); }
}
private bool _autoStart;
public bool AutoStart
{
get => _autoStart;
set { if (Set(ref _autoStart, value)) OnSettingChanged(nameof(AutoStart)); }
}
private bool _minimizeToTray;
public bool MinimizeToTray
{
get => _minimizeToTray;
set { if (Set(ref _minimizeToTray, value)) OnSettingChanged(nameof(MinimizeToTray)); }
}
// 3. 도구 모음 단추
private ObservableCollection<string> _toolbarButtons = new(AppConfig.DefaultToolbarButtons);
public ObservableCollection<string> ToolbarButtons
{
get => _toolbarButtons;
set { if (Set(ref _toolbarButtons, value)) OnSettingChanged(nameof(ToolbarButtons), true); }
}
public bool HasToolbarButton(string key) => _toolbarButtons.Contains(key, StringComparer.OrdinalIgnoreCase);
public void ToggleToolbarButton(string key, bool isChecked)
{
var exists = HasToolbarButton(key);
if (isChecked && !exists)
{
_toolbarButtons.Add(key.ToLowerInvariant());
OnSettingChanged(nameof(ToolbarButtons), true);
}
else if (!isChecked && exists)
{
var item = _toolbarButtons.FirstOrDefault(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase));
if (item != null)
{
_toolbarButtons.Remove(item);
OnSettingChanged(nameof(ToolbarButtons), true);
}
}
}
public void SetToolbarButtons(IEnumerable<string> buttons)
{
_toolbarButtons.Clear();
foreach (var b in buttons) _toolbarButtons.Add(b);
OnSettingChanged(nameof(ToolbarButtons), true);
}
// 4. 다운로드 엔진
private int _concurrency = 8;
public int Concurrency
{
get => _concurrency;
set { if (Set(ref _concurrency, value)) OnSettingChanged(nameof(Concurrency)); }
}
private int _maxConcurrentDownloads = 3;
public int MaxConcurrentDownloads
{
get => _maxConcurrentDownloads;
set { if (Set(ref _maxConcurrentDownloads, value)) OnSettingChanged(nameof(MaxConcurrentDownloads)); }
}
private int _retries = 3;
public int Retries
{
get => _retries;
set { if (Set(ref _retries, value)) OnSettingChanged(nameof(Retries)); }
}
private long _minSizeMB = 0;
public long MinSizeMB
{
get => _minSizeMB;
set { if (Set(ref _minSizeMB, value)) OnSettingChanged(nameof(MinSizeMB)); }
}
private int _maxDownloadSpeedKbps = 0;
public int MaxDownloadSpeedKbps
{
get => _maxDownloadSpeedKbps;
set { if (Set(ref _maxDownloadSpeedKbps, value)) OnSettingChanged(nameof(MaxDownloadSpeedKbps)); }
}
private bool _autoClearCompleted;
public bool AutoClearCompleted
{
get => _autoClearCompleted;
set { if (Set(ref _autoClearCompleted, value)) OnSettingChanged(nameof(AutoClearCompleted)); }
}
private bool _openFolderOnComplete;
public bool OpenFolderOnComplete
{
get => _openFolderOnComplete;
set { if (Set(ref _openFolderOnComplete, value)) OnSettingChanged(nameof(OpenFolderOnComplete)); }
}
private string _subtitleLanguage = "";
public string SubtitleLanguage
{
get => _subtitleLanguage;
set { if (Set(ref _subtitleLanguage, value)) OnSettingChanged(nameof(SubtitleLanguage)); }
}
private string? _downloadScheduleWindow;
public string? DownloadScheduleWindow
{
get => _downloadScheduleWindow;
set { if (Set(ref _downloadScheduleWindow, value)) OnSettingChanged(nameof(DownloadScheduleWindow)); }
}
// 5. 알림 & 접근성
private bool _notifySound = true;
public bool NotifySound
{
get => _notifySound;
set { if (Set(ref _notifySound, value)) OnSettingChanged(nameof(NotifySound)); }
}
private bool _doNotDisturb;
public bool DoNotDisturb
{
get => _doNotDisturb;
set { if (Set(ref _doNotDisturb, value)) OnSettingChanged(nameof(DoNotDisturb)); }
}
private bool _pauseOnPowerSaver;
public bool PauseOnPowerSaver
{
get => _pauseOnPowerSaver;
set { if (Set(ref _pauseOnPowerSaver, value)) OnSettingChanged(nameof(PauseOnPowerSaver)); }
}
private int _toastDurationMs = 3000;
public int ToastDurationMs
{
get => _toastDurationMs;
set { if (Set(ref _toastDurationMs, value)) OnSettingChanged(nameof(ToastDurationMs)); }
}
private bool _reduceMotion;
public bool ReduceMotion
{
get => _reduceMotion;
set { if (Set(ref _reduceMotion, value)) OnSettingChanged(nameof(ReduceMotion), true); }
}
private bool _showCaptions;
public bool ShowCaptions
{
get => _showCaptions;
set { if (Set(ref _showCaptions, value)) OnSettingChanged(nameof(ShowCaptions)); }
}
private bool _focusSelectsAddress = true;
public bool FocusSelectsAddress
{
get => _focusSelectsAddress;
set { if (Set(ref _focusSelectsAddress, value)) OnSettingChanged(nameof(FocusSelectsAddress)); }
}
private bool _pasteAndGo;
public bool PasteAndGo
{
get => _pasteAndGo;
set { if (Set(ref _pasteAndGo, value)) OnSettingChanged(nameof(PasteAndGo)); }
}
// 6. 브라우저 & 개인정보
private bool _tabWheelSwitch;
public bool TabWheelSwitch
{
get => _tabWheelSwitch;
set { if (Set(ref _tabWheelSwitch, value)) OnSettingChanged(nameof(TabWheelSwitch)); }
}
private string _tabStripPosition = "top";
public string TabStripPosition
{
get => _tabStripPosition;
set { if (Set(ref _tabStripPosition, value)) OnSettingChanged(nameof(TabStripPosition)); }
}
private bool _verticalTabsDefault;
public bool VerticalTabsDefault
{
get => _verticalTabsDefault;
set { if (Set(ref _verticalTabsDefault, value)) OnSettingChanged(nameof(VerticalTabsDefault)); }
}
private string _verticalTabsPosition = "Left";
public string VerticalTabsPosition
{
get => _verticalTabsPosition;
set { if (Set(ref _verticalTabsPosition, value)) OnSettingChanged(nameof(VerticalTabsPosition)); }
}
private bool _verticalTabsCollapsed;
public bool VerticalTabsCollapsed
{
get => _verticalTabsCollapsed;
set { if (Set(ref _verticalTabsCollapsed, value)) OnSettingChanged(nameof(VerticalTabsCollapsed)); }
}
private double _verticalTabsWidth = 220.0;
public double VerticalTabsWidth
{
get => _verticalTabsWidth;
set { if (Set(ref _verticalTabsWidth, value)) OnSettingChanged(nameof(VerticalTabsWidth)); }
}
private bool _verticalTabsAutoExpand = true;
public bool VerticalTabsAutoExpand
{
get => _verticalTabsAutoExpand;
set { if (Set(ref _verticalTabsAutoExpand, value)) OnSettingChanged(nameof(VerticalTabsAutoExpand)); }
}
private bool _confirmCloseMultipleTabs = true;
public bool ConfirmCloseMultipleTabs
{
get => _confirmCloseMultipleTabs;
set { if (Set(ref _confirmCloseMultipleTabs, value)) OnSettingChanged(nameof(ConfirmCloseMultipleTabs)); }
}
private bool _bookmarkBarVisible;
public bool BookmarkBarVisible
{
get => _bookmarkBarVisible;
set { if (Set(ref _bookmarkBarVisible, value)) OnSettingChanged(nameof(BookmarkBarVisible)); }
}
private bool _restoreTabsSuspended;
public bool RestoreTabsSuspended
{
get => _restoreTabsSuspended;
set { if (Set(ref _restoreTabsSuspended, value)) OnSettingChanged(nameof(RestoreTabsSuspended)); }
}
private bool _tabTitleMultiline;
public bool TabTitleMultiline
{
get => _tabTitleMultiline;
set { if (Set(ref _tabTitleMultiline, value)) OnSettingChanged(nameof(TabTitleMultiline)); }
}
private List<string> _enabledFormats = new() { "hls", "dash", "mp4", "webm", "mkv", "m4v", "mov", "avi", "audio" };
public List<string> EnabledFormats
{
get => _enabledFormats;
set { if (Set(ref _enabledFormats, value)) OnSettingChanged(nameof(EnabledFormats)); }
}
// 7. 고급 & 도구 연동
private string? _ffmpegPath;
public string? FfmpegPath
{
get => _ffmpegPath;
set { if (Set(ref _ffmpegPath, value)) OnSettingChanged(nameof(FfmpegPath)); }
}
private string? _ytDlpPath;
public string? YtDlpPath
{
get => _ytDlpPath;
set { if (Set(ref _ytDlpPath, value)) OnSettingChanged(nameof(YtDlpPath)); }
}
private bool _useCookiesForSocial = true;
public bool UseCookiesForSocial
{
get => _useCookiesForSocial;
set { if (Set(ref _useCookiesForSocial, value)) OnSettingChanged(nameof(UseCookiesForSocial)); }
}
private bool _autoInstallYtDlp = true;
public bool AutoInstallYtDlp
{
get => _autoInstallYtDlp;
set { if (Set(ref _autoInstallYtDlp, value)) OnSettingChanged(nameof(AutoInstallYtDlp)); }
}
private bool _autoUpdateYtDlp = true;
public bool AutoUpdateYtDlp
{
get => _autoUpdateYtDlp;
set { if (Set(ref _autoUpdateYtDlp, value)) OnSettingChanged(nameof(AutoUpdateYtDlp)); }
}
private bool _autoInstallDeno = true;
public bool AutoInstallDeno
{
get => _autoInstallDeno;
set { if (Set(ref _autoInstallDeno, value)) OnSettingChanged(nameof(AutoInstallDeno)); }
}
private string _defaultSocialQuality = "best";
public string DefaultSocialQuality
{
get => _defaultSocialQuality;
set { if (Set(ref _defaultSocialQuality, value)) OnSettingChanged(nameof(DefaultSocialQuality)); }
}
private string? _youtubePlayerClients;
public string? YoutubePlayerClients
{
get => _youtubePlayerClients;
set { if (Set(ref _youtubePlayerClients, value)) OnSettingChanged(nameof(YoutubePlayerClients)); }
}
private string _userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
public string UserAgent
{
get => _userAgent;
set { if (Set(ref _userAgent, value)) OnSettingChanged(nameof(UserAgent)); }
}
private string _proxyServer = "";
public string ProxyServer
{
get => _proxyServer;
set { if (Set(ref _proxyServer, value)) OnSettingChanged(nameof(ProxyServer)); }
}
// 8. 업데이트 & 정보
private string _updateRepo = "";
public string UpdateRepo
{
get => _updateRepo;
set { if (Set(ref _updateRepo, value)) OnSettingChanged(nameof(UpdateRepo)); }
}
// 9. 암호 관리자 & Windows Hello
private bool _enableWindowsHelloVault = true;
public bool EnableWindowsHelloVault
{
get => _enableWindowsHelloVault;
set { if (Set(ref _enableWindowsHelloVault, value)) OnSettingChanged(nameof(EnableWindowsHelloVault)); }
}
private int _vaultAutoLockMinutes = 15;
public int VaultAutoLockMinutes
{
get => _vaultAutoLockMinutes;
set { if (Set(ref _vaultAutoLockMinutes, value)) OnSettingChanged(nameof(VaultAutoLockMinutes)); }
}
private bool _requireBiometricForAutofill = false;
public bool RequireBiometricForAutofill
{
get => _requireBiometricForAutofill;
set { if (Set(ref _requireBiometricForAutofill, value)) OnSettingChanged(nameof(RequireBiometricForAutofill)); }
}
private bool _requireBiometricForPasswordReveal = true;
public bool RequireBiometricForPasswordReveal
{
get => _requireBiometricForPasswordReveal;
set { if (Set(ref _requireBiometricForPasswordReveal, value)) OnSettingChanged(nameof(RequireBiometricForPasswordReveal)); }
}
#endregion
#region SSOT State Tracking & Validation
private bool _isDirty;
public bool IsDirty
{
get => _isDirty;
private set => Set(ref _isDirty, value);
}
private int _dirtyCount;
public int DirtyCount
{
get => _dirtyCount;
private set => Set(ref _dirtyCount, value);
}
private string _validationSummary = "";
public string ValidationSummary
{
get => _validationSummary;
private set => Set(ref _validationSummary, value);
}
private bool _hasErrors;
public bool HasErrors
{
get => _hasErrors;
private set => Set(ref _hasErrors, value);
}
private void OnSettingChanged(string propertyName, bool requestLivePreview = false)
{
RecalculateDirtyState();
Validate();
if (requestLivePreview) LivePreviewRequested?.Invoke(propertyName);
}
public void RecalculateDirtyState()
{
int changed = 0;
if (!Equals(_theme, _originalValues.GetValueOrDefault(nameof(Theme)))) changed++;
if (!Equals(_accentColor, _originalValues.GetValueOrDefault(nameof(AccentColor)))) changed++;
if (!Equals(_customThemePath, _originalValues.GetValueOrDefault(nameof(CustomThemePath)))) changed++;
if (!Equals(_uiDensity, _originalValues.GetValueOrDefault(nameof(UiDensity)))) changed++;
if (Math.Abs(_uiScale - (double)(_originalValues.GetValueOrDefault(nameof(UiScale)) ?? 1.0)) > 0.001) changed++;
if (Math.Abs(_uiFontScale - (double)(_originalValues.GetValueOrDefault(nameof(UiFontScale)) ?? 1.0)) > 0.001) changed++;
if (!Equals(_uiFontFamily, _originalValues.GetValueOrDefault(nameof(UiFontFamily)))) changed++;
if (!Equals(_uiFontKoreanFallback, _originalValues.GetValueOrDefault(nameof(UiFontKoreanFallback)))) changed++;
if (Math.Abs(_uiFontSize - (double)(_originalValues.GetValueOrDefault(nameof(UiFontSize)) ?? 13.0)) > 0.001) changed++;
if (!Equals(_outputFolder, _originalValues.GetValueOrDefault(nameof(OutputFolder)))) changed++;
if (!Equals(_homeUrl, _originalValues.GetValueOrDefault(nameof(HomeUrl)))) changed++;
if (!Equals(_language, _originalValues.GetValueOrDefault(nameof(Language)))) changed++;
if (!Equals(_searchEngine, _originalValues.GetValueOrDefault(nameof(SearchEngine)))) changed++;
if (!Equals(_autoStart, _originalValues.GetValueOrDefault(nameof(AutoStart)))) changed++;
if (!Equals(_minimizeToTray, _originalValues.GetValueOrDefault(nameof(MinimizeToTray)))) changed++;
if (!Equals(_concurrency, _originalValues.GetValueOrDefault(nameof(Concurrency)))) changed++;
if (!Equals(_maxConcurrentDownloads, _originalValues.GetValueOrDefault(nameof(MaxConcurrentDownloads)))) changed++;
if (!Equals(_retries, _originalValues.GetValueOrDefault(nameof(Retries)))) changed++;
if (!Equals(_minSizeMB, _originalValues.GetValueOrDefault(nameof(MinSizeMB)))) changed++;
if (!Equals(_maxDownloadSpeedKbps, _originalValues.GetValueOrDefault(nameof(MaxDownloadSpeedKbps)))) changed++;
if (!Equals(_autoClearCompleted, _originalValues.GetValueOrDefault(nameof(AutoClearCompleted)))) changed++;
if (!Equals(_openFolderOnComplete, _originalValues.GetValueOrDefault(nameof(OpenFolderOnComplete)))) changed++;
if (!Equals(_subtitleLanguage, _originalValues.GetValueOrDefault(nameof(SubtitleLanguage)))) changed++;
if (!Equals(_downloadScheduleWindow, _originalValues.GetValueOrDefault(nameof(DownloadScheduleWindow)))) changed++;
if (!Equals(_notifySound, _originalValues.GetValueOrDefault(nameof(NotifySound)))) changed++;
if (!Equals(_doNotDisturb, _originalValues.GetValueOrDefault(nameof(DoNotDisturb)))) changed++;
if (!Equals(_pauseOnPowerSaver, _originalValues.GetValueOrDefault(nameof(PauseOnPowerSaver)))) changed++;
if (!Equals(_toastDurationMs, _originalValues.GetValueOrDefault(nameof(ToastDurationMs)))) changed++;
if (!Equals(_reduceMotion, _originalValues.GetValueOrDefault(nameof(ReduceMotion)))) changed++;
if (!Equals(_showCaptions, _originalValues.GetValueOrDefault(nameof(ShowCaptions)))) changed++;
if (!Equals(_focusSelectsAddress, _originalValues.GetValueOrDefault(nameof(FocusSelectsAddress)))) changed++;
if (!Equals(_pasteAndGo, _originalValues.GetValueOrDefault(nameof(PasteAndGo)))) changed++;
if (!Equals(_tabWheelSwitch, _originalValues.GetValueOrDefault(nameof(TabWheelSwitch)))) changed++;
if (!Equals(_tabStripPosition, _originalValues.GetValueOrDefault(nameof(TabStripPosition)))) changed++;
if (!Equals(_verticalTabsDefault, _originalValues.GetValueOrDefault(nameof(VerticalTabsDefault)))) changed++;
if (!Equals(_verticalTabsPosition, _originalValues.GetValueOrDefault(nameof(VerticalTabsPosition)))) changed++;
if (!Equals(_verticalTabsCollapsed, _originalValues.GetValueOrDefault(nameof(VerticalTabsCollapsed)))) changed++;
if (Math.Abs(_verticalTabsWidth - (double)(_originalValues.GetValueOrDefault(nameof(VerticalTabsWidth)) ?? 220.0)) > 0.001) changed++;
if (!Equals(_verticalTabsAutoExpand, _originalValues.GetValueOrDefault(nameof(VerticalTabsAutoExpand)))) changed++;
if (!Equals(_confirmCloseMultipleTabs, _originalValues.GetValueOrDefault(nameof(ConfirmCloseMultipleTabs)))) changed++;
if (!Equals(_bookmarkBarVisible, _originalValues.GetValueOrDefault(nameof(BookmarkBarVisible)))) changed++;
if (!Equals(_restoreTabsSuspended, _originalValues.GetValueOrDefault(nameof(RestoreTabsSuspended)))) changed++;
if (!Equals(_tabTitleMultiline, _originalValues.GetValueOrDefault(nameof(TabTitleMultiline)))) changed++;
if (!Equals(_ffmpegPath, _originalValues.GetValueOrDefault(nameof(FfmpegPath)))) changed++;
if (!Equals(_ytDlpPath, _originalValues.GetValueOrDefault(nameof(YtDlpPath)))) changed++;
if (!Equals(_useCookiesForSocial, _originalValues.GetValueOrDefault(nameof(UseCookiesForSocial)))) changed++;
if (!Equals(_autoInstallYtDlp, _originalValues.GetValueOrDefault(nameof(AutoInstallYtDlp)))) changed++;
if (!Equals(_autoUpdateYtDlp, _originalValues.GetValueOrDefault(nameof(AutoUpdateYtDlp)))) changed++;
if (!Equals(_autoInstallDeno, _originalValues.GetValueOrDefault(nameof(AutoInstallDeno)))) changed++;
if (!Equals(_defaultSocialQuality, _originalValues.GetValueOrDefault(nameof(DefaultSocialQuality)))) changed++;
if (!Equals(_youtubePlayerClients, _originalValues.GetValueOrDefault(nameof(YoutubePlayerClients)))) changed++;
if (!Equals(_userAgent, _originalValues.GetValueOrDefault(nameof(UserAgent)))) changed++;
if (!Equals(_proxyServer, _originalValues.GetValueOrDefault(nameof(ProxyServer)))) changed++;
if (!Equals(_updateRepo, _originalValues.GetValueOrDefault(nameof(UpdateRepo)))) changed++;
if (!Equals(_enableWindowsHelloVault, _originalValues.GetValueOrDefault(nameof(EnableWindowsHelloVault)))) changed++;
if (!Equals(_vaultAutoLockMinutes, _originalValues.GetValueOrDefault(nameof(VaultAutoLockMinutes)))) changed++;
if (!Equals(_requireBiometricForAutofill, _originalValues.GetValueOrDefault(nameof(RequireBiometricForAutofill)))) changed++;
if (!Equals(_requireBiometricForPasswordReveal, _originalValues.GetValueOrDefault(nameof(RequireBiometricForPasswordReveal)))) changed++;
DirtyCount = changed;
IsDirty = changed > 0;
}
public bool Validate()
{
_errors.Clear();
if (Concurrency < 1) _errors[nameof(Concurrency)] = "세그먼트 동시 수는 1 이상의 숫자여야 합니다.";
if (MaxConcurrentDownloads < 1) _errors[nameof(MaxConcurrentDownloads)] = "최대 동시 다운로드 수는 1 이상의 숫자여야 합니다.";
if (Retries < 1) _errors[nameof(Retries)] = "재시도 횟수는 1 이상의 숫자여야 합니다.";
if (MinSizeMB < 0) _errors[nameof(MinSizeMB)] = "최소 크기는 0 이상의 숫자여야 합니다.";
if (MaxDownloadSpeedKbps < 0) _errors[nameof(MaxDownloadSpeedKbps)] = "속도 상한은 0 이상의 숫자여야 합니다.";
if (ToastDurationMs < 500) _errors[nameof(ToastDurationMs)] = "토스트 표시 시간은 0.5초(500ms) 이상이어야 합니다.";
if (!string.IsNullOrWhiteSpace(DownloadScheduleWindow) &&
!Services.DownloadScheduler.TryParse(DownloadScheduleWindow.Trim(), out _, out _))
{
_errors[nameof(DownloadScheduleWindow)] = "예약 시간대는 HH:mm-HH:mm 형식이어야 합니다 (예: 02:00-08:00).";
}
if (!string.IsNullOrEmpty(AccentColor))
{
var acc = AccentColor.Trim();
if (!(acc.Length == 7 && acc.StartsWith('#') && acc[1..].All(Uri.IsHexDigit)))
{
_errors[nameof(AccentColor)] = "강조색은 #RRGGBB 형식이어야 합니다.";
}
}
HasErrors = _errors.Count > 0;
ValidationSummary = HasErrors ? _errors.Values.First() : "";
return !HasErrors;
}
public void LoadFromConfig(AppConfig config, bool isOriginalSnapshot = false)
{
_theme = config.Theme ?? "dark";
_accentColor = config.AccentColor;
_customThemePath = config.CustomThemePath;
_uiDensity = config.UiDensity ?? "normal";
_uiScale = Math.Clamp(config.UiScale, 1.0, 2.0);
_uiFontScale = Math.Clamp(config.UiFontScale <= 0 ? 1.0 : config.UiFontScale, 1.0, 2.0);
_uiFontFamily = config.UiFontFamily ?? "";
_uiFontKoreanFallback = string.IsNullOrWhiteSpace(config.UiFontKoreanFallback) ? "Malgun Gothic" : config.UiFontKoreanFallback;
_uiFontSize = config.UiFontSize > 0 ? config.UiFontSize : 13.0;
_outputFolder = config.OutputFolder ?? "";
_homeUrl = string.IsNullOrWhiteSpace(config.HomeUrl) ? "https://www.google.com" : config.HomeUrl;
_language = config.Language ?? "ko";
_searchEngine = config.SearchEngine ?? "Google";
_autoStart = config.AutoStart;
_minimizeToTray = config.MinimizeToTray;
_toolbarButtons = new ObservableCollection<string>(config.ToolbarButtons ?? AppConfig.DefaultToolbarButtons);
_concurrency = Math.Max(1, config.Concurrency);
_maxConcurrentDownloads = Math.Max(1, config.MaxConcurrentDownloads);
_retries = Math.Max(1, config.Retries);
_minSizeMB = Math.Max(0, config.MinSizeMB);
_maxDownloadSpeedKbps = Math.Max(0, config.MaxDownloadSpeedKbps);
_autoClearCompleted = config.AutoClearCompleted;
_openFolderOnComplete = config.OpenFolderOnComplete;
_subtitleLanguage = config.SubtitleLanguage ?? "";
_downloadScheduleWindow = config.DownloadScheduleWindow;
_notifySound = config.NotifySound;
_doNotDisturb = config.DoNotDisturb;
_pauseOnPowerSaver = config.PauseOnPowerSaver;
_toastDurationMs = config.ToastDurationMs > 0 ? config.ToastDurationMs : 3000;
_reduceMotion = config.ReduceMotion;
_showCaptions = config.ShowCaptions;
_focusSelectsAddress = config.FocusSelectsAddress;
_pasteAndGo = config.PasteAndGo;
_tabWheelSwitch = config.TabWheelSwitch;
_tabStripPosition = config.TabStripPosition ?? "top";
_verticalTabsDefault = config.VerticalTabsDefault;
_verticalTabsPosition = config.VerticalTabsPosition ?? "Left";
_verticalTabsCollapsed = config.VerticalTabsCollapsed;
_verticalTabsWidth = config.VerticalTabsWidth > 0 ? config.VerticalTabsWidth : 220.0;
_verticalTabsAutoExpand = config.VerticalTabsAutoExpand;
_confirmCloseMultipleTabs = config.ConfirmCloseMultipleTabs;
_bookmarkBarVisible = config.BookmarkBarVisible;
_restoreTabsSuspended = config.RestoreTabsSuspended;
_tabTitleMultiline = config.TabTitleMultiline;
_enabledFormats = config.EnabledFormats?.ToList() ?? new List<string> { "hls", "dash", "mp4" };
_ffmpegPath = config.FfmpegPath;
_ytDlpPath = config.YtDlpPath;
_useCookiesForSocial = config.UseCookiesForSocial;
_autoInstallYtDlp = config.AutoInstallYtDlp;
_autoUpdateYtDlp = config.AutoUpdateYtDlp;
_autoInstallDeno = config.AutoInstallDeno;
_defaultSocialQuality = config.DefaultSocialQuality ?? "best";
_youtubePlayerClients = config.YoutubePlayerClients;
_userAgent = string.IsNullOrWhiteSpace(config.UserAgent)
? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
: config.UserAgent;
_proxyServer = config.ProxyServer ?? "";
_updateRepo = config.UpdateRepo ?? "";
_enableWindowsHelloVault = config.EnableWindowsHelloVault;
_vaultAutoLockMinutes = config.VaultAutoLockMinutes;
_requireBiometricForAutofill = config.RequireBiometricForAutofill;
_requireBiometricForPasswordReveal = config.RequireBiometricForPasswordReveal;
if (isOriginalSnapshot)
{
_originalValues.Clear();
_originalValues[nameof(Theme)] = _theme;
_originalValues[nameof(AccentColor)] = _accentColor;
_originalValues[nameof(CustomThemePath)] = _customThemePath;
_originalValues[nameof(UiDensity)] = _uiDensity;
_originalValues[nameof(UiScale)] = _uiScale;
_originalValues[nameof(UiFontScale)] = _uiFontScale;
_originalValues[nameof(UiFontFamily)] = _uiFontFamily;
_originalValues[nameof(UiFontKoreanFallback)] = _uiFontKoreanFallback;
_originalValues[nameof(UiFontSize)] = _uiFontSize;
_originalValues[nameof(OutputFolder)] = _outputFolder;
_originalValues[nameof(HomeUrl)] = _homeUrl;
_originalValues[nameof(Language)] = _language;
_originalValues[nameof(SearchEngine)] = _searchEngine;
_originalValues[nameof(AutoStart)] = _autoStart;
_originalValues[nameof(MinimizeToTray)] = _minimizeToTray;
_originalValues[nameof(Concurrency)] = _concurrency;
_originalValues[nameof(MaxConcurrentDownloads)] = _maxConcurrentDownloads;
_originalValues[nameof(Retries)] = _retries;
_originalValues[nameof(MinSizeMB)] = _minSizeMB;
_originalValues[nameof(MaxDownloadSpeedKbps)] = _maxDownloadSpeedKbps;
_originalValues[nameof(AutoClearCompleted)] = _autoClearCompleted;
_originalValues[nameof(OpenFolderOnComplete)] = _openFolderOnComplete;
_originalValues[nameof(SubtitleLanguage)] = _subtitleLanguage;
_originalValues[nameof(DownloadScheduleWindow)] = _downloadScheduleWindow;
_originalValues[nameof(NotifySound)] = _notifySound;
_originalValues[nameof(DoNotDisturb)] = _doNotDisturb;
_originalValues[nameof(PauseOnPowerSaver)] = _pauseOnPowerSaver;
_originalValues[nameof(ToastDurationMs)] = _toastDurationMs;
_originalValues[nameof(ReduceMotion)] = _reduceMotion;
_originalValues[nameof(ShowCaptions)] = _showCaptions;
_originalValues[nameof(FocusSelectsAddress)] = _focusSelectsAddress;
_originalValues[nameof(PasteAndGo)] = _pasteAndGo;
_originalValues[nameof(TabWheelSwitch)] = _tabWheelSwitch;
_originalValues[nameof(TabStripPosition)] = _tabStripPosition;
_originalValues[nameof(VerticalTabsDefault)] = _verticalTabsDefault;
_originalValues[nameof(VerticalTabsPosition)] = _verticalTabsPosition;
_originalValues[nameof(VerticalTabsCollapsed)] = _verticalTabsCollapsed;
_originalValues[nameof(VerticalTabsWidth)] = _verticalTabsWidth;
_originalValues[nameof(VerticalTabsAutoExpand)] = _verticalTabsAutoExpand;
_originalValues[nameof(ConfirmCloseMultipleTabs)] = _confirmCloseMultipleTabs;
_originalValues[nameof(BookmarkBarVisible)] = _bookmarkBarVisible;
_originalValues[nameof(RestoreTabsSuspended)] = _restoreTabsSuspended;
_originalValues[nameof(TabTitleMultiline)] = _tabTitleMultiline;
_originalValues[nameof(FfmpegPath)] = _ffmpegPath;
_originalValues[nameof(YtDlpPath)] = _ytDlpPath;
_originalValues[nameof(UseCookiesForSocial)] = _useCookiesForSocial;
_originalValues[nameof(AutoInstallYtDlp)] = _autoInstallYtDlp;
_originalValues[nameof(AutoUpdateYtDlp)] = _autoUpdateYtDlp;
_originalValues[nameof(AutoInstallDeno)] = _autoInstallDeno;
_originalValues[nameof(DefaultSocialQuality)] = _defaultSocialQuality;
_originalValues[nameof(YoutubePlayerClients)] = _youtubePlayerClients;
_originalValues[nameof(UserAgent)] = _userAgent;
_originalValues[nameof(ProxyServer)] = _proxyServer;
_originalValues[nameof(UpdateRepo)] = _updateRepo;
_originalValues[nameof(EnableWindowsHelloVault)] = _enableWindowsHelloVault;
_originalValues[nameof(VaultAutoLockMinutes)] = _vaultAutoLockMinutes;
_originalValues[nameof(RequireBiometricForAutofill)] = _requireBiometricForAutofill;
_originalValues[nameof(RequireBiometricForPasswordReveal)] = _requireBiometricForPasswordReveal;
}
RecalculateDirtyState();
Validate();
OnPropertyChanged(""); // 전체 프로퍼티 리프레시
}
public void CommitToConfig(AppConfig target)
{
target.Theme = _theme;
target.AccentColor = string.IsNullOrWhiteSpace(_accentColor) ? null : _accentColor.Trim();
target.CustomThemePath = string.IsNullOrWhiteSpace(_customThemePath) ? null : _customThemePath.Trim();
target.UiDensity = _uiDensity;
target.UiScale = _uiScale;
target.UiFontScale = _uiFontScale;
target.UiFontFamily = _uiFontFamily.Trim();
target.UiFontKoreanFallback = string.IsNullOrWhiteSpace(_uiFontKoreanFallback) ? "Malgun Gothic" : _uiFontKoreanFallback.Trim();
target.UiFontSize = _uiFontSize;
target.OutputFolder = _outputFolder.Trim();
target.HomeUrl = string.IsNullOrWhiteSpace(_homeUrl) ? "https://www.google.com" : _homeUrl.Trim();
target.Language = _language;
target.SearchEngine = _searchEngine;
target.AutoStart = _autoStart;
target.MinimizeToTray = _minimizeToTray;
target.ToolbarButtons = _toolbarButtons.ToList();
target.Concurrency = Math.Max(1, _concurrency);
target.MaxConcurrentDownloads = Math.Max(1, _maxConcurrentDownloads);
target.Retries = Math.Max(1, _retries);
target.MinSizeMB = Math.Max(0, _minSizeMB);
target.MaxDownloadSpeedKbps = Math.Max(0, _maxDownloadSpeedKbps);
target.AutoClearCompleted = _autoClearCompleted;
target.OpenFolderOnComplete = _openFolderOnComplete;
target.SubtitleLanguage = _subtitleLanguage ?? "";
target.DownloadScheduleWindow = string.IsNullOrWhiteSpace(_downloadScheduleWindow) ? null : _downloadScheduleWindow.Trim();
target.NotifySound = _notifySound;
target.DoNotDisturb = _doNotDisturb;
target.PauseOnPowerSaver = _pauseOnPowerSaver;
target.ToastDurationMs = Math.Max(500, _toastDurationMs);
target.ReduceMotion = _reduceMotion;
target.ShowCaptions = _showCaptions;
target.FocusSelectsAddress = _focusSelectsAddress;
target.PasteAndGo = _pasteAndGo;
target.TabWheelSwitch = _tabWheelSwitch;
target.TabStripPosition = _tabStripPosition;
target.VerticalTabsDefault = _verticalTabsDefault;
target.VerticalTabsPosition = _verticalTabsPosition;
target.VerticalTabsCollapsed = _verticalTabsCollapsed;
target.VerticalTabsWidth = _verticalTabsWidth;
target.VerticalTabsAutoExpand = _verticalTabsAutoExpand;
target.ConfirmCloseMultipleTabs = _confirmCloseMultipleTabs;
target.BookmarkBarVisible = _bookmarkBarVisible;
target.RestoreTabsSuspended = _restoreTabsSuspended;
target.TabTitleMultiline = _tabTitleMultiline;
target.EnabledFormats = _enabledFormats.ToList();
target.FfmpegPath = string.IsNullOrWhiteSpace(_ffmpegPath) ? null : _ffmpegPath.Trim();
target.YtDlpPath = string.IsNullOrWhiteSpace(_ytDlpPath) ? null : _ytDlpPath.Trim();
target.UseCookiesForSocial = _useCookiesForSocial;
target.AutoInstallYtDlp = _autoInstallYtDlp;
target.AutoUpdateYtDlp = _autoUpdateYtDlp;
target.AutoInstallDeno = _autoInstallDeno;
target.DefaultSocialQuality = _defaultSocialQuality;
target.YoutubePlayerClients = string.IsNullOrWhiteSpace(_youtubePlayerClients) ? null : _youtubePlayerClients.Trim();
target.UserAgent = string.IsNullOrWhiteSpace(_userAgent)
? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
: _userAgent.Trim();
target.ProxyServer = _proxyServer.Trim();
target.UpdateRepo = _updateRepo.Trim();
target.EnableWindowsHelloVault = _enableWindowsHelloVault;
target.VaultAutoLockMinutes = _vaultAutoLockMinutes;
target.RequireBiometricForAutofill = _requireBiometricForAutofill;
target.RequireBiometricForPasswordReveal = _requireBiometricForPasswordReveal;
}
public void ResetToDefaults()
{
var temp = new AppConfig();
temp.ResetToDefaults();
LoadFromConfig(temp, isOriginalSnapshot: false);
}
public void ResetToOriginal()
{
LoadFromConfig(_originalConfig, isOriginalSnapshot: true);
}
#endregion
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(name);
return true;
}
private void OnPropertyChanged([CallerMemberName] string? name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}

23
src/Paca.App/app.manifest Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Paca.App"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 and Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>