diff --git a/VideoDownloader.App/MainWindow.xaml b/VideoDownloader.App/MainWindow.xaml
index ccb1b94..c4bfb60 100644
--- a/VideoDownloader.App/MainWindow.xaml
+++ b/VideoDownloader.App/MainWindow.xaml
@@ -191,6 +191,14 @@
+
+
+
+
+
+
+
@@ -208,10 +216,25 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -527,6 +550,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/VideoDownloader.App/MainWindow.xaml.cs b/VideoDownloader.App/MainWindow.xaml.cs
index a5d1285..4a88e15 100644
--- a/VideoDownloader.App/MainWindow.xaml.cs
+++ b/VideoDownloader.App/MainWindow.xaml.cs
@@ -214,10 +214,33 @@ public partial class MainWindow : Window
_views.GlanceRequested += (_, link) => Dispatcher.BeginInvoke(new Action(() => OpenGlance(link))); // T2-6
_views.SpeakRequested += (_, text) => Dispatcher.BeginInvoke(new Action(() => SpeakText(text))); // T3-9
- _views.ContextMenuBuild += OnContextMenuBuild;
- _views.BoostScriptProvider = url => BoostCssBuilder.BuildScript(url, _boostStore.Get(url)); // T2-7
- _views.GlanceRequested += (_, link) => Dispatcher.BeginInvoke(new Action(() => OpenGlance(link))); // T2-6
- _views.SpeakRequested += (_, text) => Dispatcher.BeginInvoke(new Action(() => SpeakText(text))); // T3-9
+ // T3-15 주소창 진행 바: 전 탭 네비게이션 집계 → 표시
+ _navTracker = new NavProgressTracker();
+ _navTracker.PropertyChanged += (_, e) =>
+ NavProgress.Visibility = e.PropertyName == nameof(NavProgressTracker.IsNavigating) && _navTracker.IsNavigating
+ ? Visibility.Visible : Visibility.Collapsed;
+ _views.NavigationStarted += _navTracker.Started;
+ _views.NavigationCompleted += _navTracker.Completed;
+
+ // T3-11 권한 다이얼로그: 예=항상 허용 / 아니요=항상 거절 / 취소=이번만 거절
+ _views.PermissionPrompt += (cv, e) => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ var kind = e.PermissionKind.ToString();
+ var origin = Services.TabViewService.OriginOf(e.Uri);
+ var r = System.Windows.MessageBox.Show(this,
+ $"{origin} 사이트가 {KoreanPermission(kind)} 권한을 요청합니다.\n허용하시겠습니까?\n\n예: 항상 허용 · 아니요: 항상 거절 · 취소: 이번만 거절",
+ "권한 요청", MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
+ e.State = r == MessageBoxResult.Yes ? Microsoft.Web.WebView2.Core.CoreWebView2PermissionState.Allow
+ : Microsoft.Web.WebView2.Core.CoreWebView2PermissionState.Deny;
+ e.SavesInProfile = r != MessageBoxResult.Cancel; // 항상 선택만 프로필 영속
+ e.Handled = true;
+ if (r != MessageBoxResult.Cancel)
+ _views.Permissions.Remember(kind, origin,
+ r == MessageBoxResult.Yes ? PermissionVerdict.Allow : PermissionVerdict.Deny);
+ }));
+
+ // T3-14 실드 배지 갱신
+ _views.TrackerBlocked += UpdateShield;
_tabs = new TabManager(() => new TabItem(), _views.AttachView);
_tabs.ActiveChanged += OnActiveTabChanged;
@@ -284,6 +307,52 @@ public partial class MainWindow : Window
/// 탭별 WebView2 생명주기·배선 전담 서비스(재구성 ④).
private Services.TabViewService? _views;
+ /// T3-15 주소창 진행 바 상태(전 탭 네비게이션 집계).
+ private NavProgressTracker? _navTracker;
+
+ /// T3-15: 파라미터 제거 후 클립보드 복사(주소창 우클릭).
+ private void CopyCleanUrl_Click(object sender, RoutedEventArgs e)
+ {
+ var clean = UrlCleaner.StripQuery(AddressBox.Text);
+ if (string.IsNullOrEmpty(clean)) return;
+ try { Clipboard.SetText(clean); Log($"파라미터 제거 URL 복사: {clean}"); } catch { }
+ }
+
+ /// T3-14: 실드 배지 갱신.
+ private void UpdateShield()
+ {
+ if (_views == null) return;
+ var n = _views.TrackerCounter.Total;
+ ShieldBadgeText.Text = n > 0 ? n.ToString() : "";
+ ShieldBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
+ ShieldBtn.ToolTip = n > 0 ? $"추적 요청 {n}건 차단됨" : "추적 방지";
+ }
+
+ /// T3-14: 실드 클릭 → 차단 도메인 목록 팝업.
+ private void Shield_Click(object sender, RoutedEventArgs e)
+ {
+ if (_views == null) return;
+ var byDomain = _views.TrackerCounter.ByDomain;
+ ShieldList.ItemsSource = byDomain.Count == 0
+ ? new[] { "차단된 추적 요청이 없습니다" }
+ : byDomain.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Value,3} × {kv.Key}").ToList();
+ ShieldPopup.IsOpen = true;
+ }
+
+ /// T3-11: 권한 종류 한글 표기.
+ private static string KoreanPermission(string kind) => kind switch
+ {
+ "Microphone" => "마이크",
+ "Camera" => "카메라",
+ "Geolocation" => "위치",
+ "Notifications" => "알림",
+ "ClipboardRead" => "클립보드 읽기",
+ "Autoplay" => "자동 재생",
+ "FileReadWrite" => "파일 읽기/쓰기",
+ "PersistentStorage" => "영구 저장소",
+ _ => kind,
+ };
+
private void OnUrlChanged(TabItem tab, string url)
{
if (tab.PendingHistoryUrl != null) RecordHistory(tab.PendingHistoryUrl, tab.PendingHistoryUrl);
diff --git a/VideoDownloader.App/Services/TabViewService.cs b/VideoDownloader.App/Services/TabViewService.cs
index 0ae123c..48e5a16 100644
--- a/VideoDownloader.App/Services/TabViewService.cs
+++ b/VideoDownloader.App/Services/TabViewService.cs
@@ -55,6 +55,27 @@ public sealed class TabViewService
/// T2-4 리더 내 낭독(TTS) 요청 — 리더 본문 텍스트.
public event Action? SpeakRequested;
+ /// T3-15 주소창 진행 바: 네비게이션 시작(전 탭 집계).
+ public event Action? NavigationStarted;
+
+ /// T3-15 주소창 진행 바: 네비게이션 완료(전 탭 집계).
+ public event Action? NavigationCompleted;
+
+ /// T3-11 사이트별 권한 정책(출처×종류 기억).
+ public PermissionGate Permissions { get; set; } = new();
+
+ /// T3-11 Ask 판정 시 호스트 다이얼로그 — State/Handled/SavesInProfile 설정은 호스트 책임.
+ public event Action? PermissionPrompt;
+
+ /// T3-14 추적 방지 블랙리스트(교체 주입 가능).
+ public TrackerBlocklist TrackerBlocklist { get; set; } = new();
+
+ /// T3-14 차단 카운터(실드 배지·팝업 데이터).
+ public TrackerCounter TrackerCounter { get; } = new();
+
+ /// T3-14 차단 발생 알림(배지 갱신).
+ public event Action? TrackerBlocked;
+
/// T3-3 InPrivate 탭: 다음에 만들어지는 탭에 적용(1회성).
public bool NextTabIsPrivate { get; set; }
@@ -166,10 +187,11 @@ public sealed class TabViewService
}));
// 네비게이션 상태
- cv.NavigationStarting += (_, _) => _dispatcher.BeginInvoke(new Action(() => tab.IsLoading = true));
+ cv.NavigationStarting += (_, _) => _dispatcher.BeginInvoke(new Action(() => { tab.IsLoading = true; NavigationStarted?.Invoke(); }));
cv.NavigationCompleted += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
{
tab.IsLoading = false;
+ NavigationCompleted?.Invoke();
try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7 사이트별 줌
try // T2-7 부스트(강제 다크/커스텀 CSS) 적용
{
@@ -180,6 +202,37 @@ public sealed class TabViewService
}));
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;
+ }
+ };
+
+ // T3-14 추적 방지: 블랙리스트 요청 차단 + 카운터
+ cv.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
+ cv.WebResourceRequested += (_, e) =>
+ {
+ Uri u;
+ try { u = new Uri(e.Request.Uri); } catch { return; }
+ if (!TrackerBlocklist.Matches(u)) return;
+ try { e.Response = _env.CreateWebResourceResponse(null, 204, "Blocked", ""); } catch { }
+ TrackerCounter.Record(e.Request.Uri);
+ _dispatcher.BeginInvoke(new Action(() => TrackerBlocked?.Invoke()));
+ };
+
cv.SourceChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
{
var url = cv.Source ?? "";
@@ -250,4 +303,12 @@ public sealed class TabViewService
/// 활성 탭 검색 상태 표시 요청(FindStatus 텍스트 갱신 — 호스트가 구독).
public event Action? FindStateChanged;
+
+ /// 권한 기준 출처(scheme://host:port) 추출.
+ 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; }
+ }
}
diff --git a/VideoDownloader.Browser/PermissionGate.cs b/VideoDownloader.Browser/PermissionGate.cs
new file mode 100644
index 0000000..5ea8f73
--- /dev/null
+++ b/VideoDownloader.Browser/PermissionGate.cs
@@ -0,0 +1,78 @@
+namespace VideoDownloader.Browser;
+
+public enum PermissionVerdict { Ask, Allow, Deny }
+
+///
+/// T3-11 사이트별 권한 정책 — 출처×종류 단위 기억. WebView2 PermissionRequested 에서
+/// Ask 만 프롬프트로 올리고 Allow/Deny 는 즉시 응답. "항상" 선택은 네이티브
+/// Profile.SetPermissionStateAsync 로도 영속(세션 밖 저장).
+///
+public sealed class PermissionGate
+{
+ /// 프롬프트 없이 거절: 센서류(피싱 표면 축소).
+ private static readonly HashSet AutoDeny = new(StringComparer.OrdinalIgnoreCase) { "OtherSensors" };
+ /// 프롬프트 없이 허용: 연속 다운로드(다운로더 앱 본업).
+ private static readonly HashSet AutoAllow = new(StringComparer.OrdinalIgnoreCase) { "MultipleAutomaticDownloads" };
+
+ private readonly Dictionary<(string origin, string kind), PermissionVerdict> _saved = new();
+
+ public PermissionVerdict Decide(string kind, string origin)
+ {
+ if (AutoDeny.Contains(kind)) return PermissionVerdict.Deny;
+ if (AutoAllow.Contains(kind)) return PermissionVerdict.Allow;
+ return _saved.GetValueOrDefault((origin, kind), PermissionVerdict.Ask);
+ }
+
+ public void Remember(string kind, string origin, PermissionVerdict verdict)
+ => _saved[(origin, kind)] = verdict;
+}
+
+/// T3-14: 추적 스크립트 블랙리스트 — 호스트 또는 서브도메인 매칭.
+public sealed class TrackerBlocklist
+{
+ /// 주요 광고/분석 도메인(EasyList 공통 항목 발췌). 생성자 주입으로 교체 가능(테스트).
+ public static readonly string[] DefaultDomains =
+ {
+ "google-analytics.com", "googletagmanager.com", "doubleclick.net", "googlesyndication.com",
+ "googleadservices.com", "scorecardresearch.com", "hotjar.com", "mixpanel.com", "segment.io",
+ "facebook.net", "connect.facebook.net", "amplitude.com", "branch.io", "appsflyer.com",
+ "criteo.com", "taboola.com", "outbrain.com", "adnxs.com", "pubmatic.com", "rubiconproject.com",
+ };
+
+ private readonly HashSet _domains;
+
+ public TrackerBlocklist() : this(DefaultDomains) { }
+ public TrackerBlocklist(IEnumerable domains) =>
+ _domains = new HashSet(domains, StringComparer.OrdinalIgnoreCase);
+
+ public bool Matches(Uri url)
+ {
+ var host = url.Host;
+ foreach (var d in _domains)
+ if (host.Equals(d, StringComparison.OrdinalIgnoreCase) ||
+ host.EndsWith("." + d, StringComparison.OrdinalIgnoreCase))
+ return true;
+ return false;
+ }
+}
+
+/// T3-14: 차단 카운터 — 총계/도메인별 집계, 변경 통지(실드 배지 갱신용).
+public sealed class TrackerCounter
+{
+ private int _total;
+ public int Total => Volatile.Read(ref _total);
+ public Dictionary ByDomain { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public event Action? Changed;
+
+ public void Record(string url)
+ {
+ var host = new Uri(url).Host;
+ var baseDomain = host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host;
+ Interlocked.Increment(ref _total);
+ lock (ByDomain)
+ {
+ ByDomain[baseDomain] = ByDomain.GetValueOrDefault(baseDomain) + 1;
+ }
+ Changed?.Invoke();
+ }
+}
diff --git a/VideoDownloader.Browser/UrlCleaner.cs b/VideoDownloader.Browser/UrlCleaner.cs
new file mode 100644
index 0000000..1ed2009
--- /dev/null
+++ b/VideoDownloader.Browser/UrlCleaner.cs
@@ -0,0 +1,43 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace VideoDownloader.Browser;
+
+/// T3-15: "파라미터 제거 복사" — 추적용 쿼리·프래그먼트를 뺀 URL 반환.
+public static class UrlCleaner
+{
+ public static string? StripQuery(string? url)
+ {
+ if (url == null) return null;
+ var q = url.IndexOf('?');
+ if (q >= 0) url = url[..q];
+ var f = url.IndexOf('#');
+ if (f >= 0) url = url[..f];
+ return url;
+ }
+}
+
+/// T3-15: 주소창 진행 바 상태 — 진행 중 네비게이션 개수 집계(탭·리다이렉트 중복 안전).
+public sealed class NavProgressTracker : INotifyPropertyChanged
+{
+ private int _count;
+
+ public bool IsNavigating => Volatile.Read(ref _count) > 0;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public void Started() => Update(1);
+ public void Completed() => Update(-1);
+
+ private void Update(int delta)
+ {
+ var before = IsNavigating;
+ var c = _count + delta;
+ Volatile.Write(ref _count, Math.Max(0, c)); // 과잉 완료 방지
+ if (before != IsNavigating)
+ OnPropertyChanged(nameof(IsNavigating));
+ }
+
+ private void OnPropertyChanged([CallerMemberName] string? name = null) =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+}
diff --git a/VideoDownloader.Mobile.Core/ConnectionTroubleshoot.cs b/VideoDownloader.Mobile.Core/ConnectionTroubleshoot.cs
new file mode 100644
index 0000000..277db24
--- /dev/null
+++ b/VideoDownloader.Mobile.Core/ConnectionTroubleshoot.cs
@@ -0,0 +1,21 @@
+namespace VideoDownloader.Mobile;
+
+///
+/// 외부망/네트워크 불통 안내(MOBILE_PLAN §7 성공 기준) —
+/// "같은 네트워크 필요 / 외부망에서는 Tailscale 등 VPN 안내".
+///
+public static class ConnectionTroubleshoot
+{
+ public const string NotConfigured = "먼저 연결 탭에서 PC 와 페어링하세요";
+
+ public const string Unreachable =
+ "PC 에 연결할 수 없습니다. 폰과 PC 가 같은 네트워크(Wi-Fi)에 있는지 확인하세요. " +
+ "외부망에서 접속하려면 Tailscale 등 VPN 으로 PC 의 사설망에 접근해야 합니다.";
+
+ public static string Message(bool isConfigured, string? lastError)
+ {
+ if (!isConfigured) return NotConfigured;
+ if (!string.IsNullOrEmpty(lastError)) return Unreachable;
+ return "";
+ }
+}
diff --git a/VideoDownloader.Mobile/OfflineStore.cs b/VideoDownloader.Mobile.Core/OfflineStore.cs
similarity index 91%
rename from VideoDownloader.Mobile/OfflineStore.cs
rename to VideoDownloader.Mobile.Core/OfflineStore.cs
index 4d7f443..ca6bf1d 100644
--- a/VideoDownloader.Mobile/OfflineStore.cs
+++ b/VideoDownloader.Mobile.Core/OfflineStore.cs
@@ -10,16 +10,15 @@ public sealed record OfflineItem(string Id, string FileName, long SizeBytes);
///
public sealed class OfflineStore
{
- public static OfflineStore Default { get; } = new();
-
private readonly string _dir;
private readonly string _manifestPath;
private readonly object _gate = new();
private Dictionary _manifest;
- public OfflineStore()
+ /// baseDir 아래 offline/ 폴더 사용(호스트가 앱 데이터 디렉터리 주입).
+ public OfflineStore(string baseDir)
{
- _dir = Path.Combine(FileSystem.AppDataDirectory, "offline");
+ _dir = Path.Combine(baseDir, "offline");
_manifestPath = Path.Combine(_dir, "manifest.json");
try
{
diff --git a/VideoDownloader.Mobile.Core/QueueCompletionDetector.cs b/VideoDownloader.Mobile.Core/QueueCompletionDetector.cs
new file mode 100644
index 0000000..5e4df54
--- /dev/null
+++ b/VideoDownloader.Mobile.Core/QueueCompletionDetector.cs
@@ -0,0 +1,18 @@
+namespace VideoDownloader.Mobile;
+
+/// U5 완료 알림: 이전/다음 큐 스냅샷에서 새로 완료("완료")된 항목 ID 감지.
+/// 이전 스냅샷이 비면(앱 시작 직후 첫 폴링) 무시 — 과거 완료 알림 스팸 방지.
+public sealed class QueueCompletionDetector
+{
+ public IReadOnlyList Detect(
+ IReadOnlyList<(string Id, string Status)> previous,
+ IReadOnlyList<(string Id, string Status)> next)
+ {
+ if (previous.Count == 0) return Array.Empty();
+ var prev = previous.ToDictionary(x => x.Id, x => x.Status);
+ return next
+ .Where(n => n.Status == "완료" && prev.TryGetValue(n.Id, out var s) && s != "완료")
+ .Select(n => n.Id)
+ .ToList();
+ }
+}
diff --git a/VideoDownloader.Mobile.Core/VideoDownloader.Mobile.Core.csproj b/VideoDownloader.Mobile.Core/VideoDownloader.Mobile.Core.csproj
new file mode 100644
index 0000000..d4f8664
--- /dev/null
+++ b/VideoDownloader.Mobile.Core/VideoDownloader.Mobile.Core.csproj
@@ -0,0 +1,9 @@
+
+
+ net8.0
+ VideoDownloader.Mobile
+ enable
+ enable
+ latest
+
+
diff --git a/VideoDownloader.Mobile/ApiClient.cs b/VideoDownloader.Mobile/ApiClient.cs
index 5703875..79f7d8b 100644
--- a/VideoDownloader.Mobile/ApiClient.cs
+++ b/VideoDownloader.Mobile/ApiClient.cs
@@ -116,6 +116,7 @@ public sealed class ApiClient
public string StreamUrl(LibraryItem item) => $"{BaseUrl}/stream/{item.Id}?t={Token}";
public string FileUrl(LibraryItem item) => $"{BaseUrl}/file/{item.Id}?t={Token}";
+ public string ThumbUrl(LibraryItem item) => $"{BaseUrl}/api/library/{item.Id}/thumb?t={Token}";
/// 이어보기 위치 조회(U3). 실패/미저장 시 0.
public async Task GetPositionAsync(string id)
@@ -188,6 +189,6 @@ public sealed class ApiClient
public sealed record QueueItem(string Id, string Title, string Url, string Status, double Percent);
-public sealed record LibraryItem(string Id, string FileName, long SizeBytes, DateTime Modified);
+public sealed record LibraryItem(string Id, string FileName, long SizeBytes, DateTime Modified, string? Resolution = null);
public sealed record PositionDto(double PositionSec);
diff --git a/VideoDownloader.Mobile/AppOfflineStore.cs b/VideoDownloader.Mobile/AppOfflineStore.cs
new file mode 100644
index 0000000..b8e60ed
--- /dev/null
+++ b/VideoDownloader.Mobile/AppOfflineStore.cs
@@ -0,0 +1,7 @@
+namespace VideoDownloader.Mobile;
+
+/// 앱 데이터 디렉터리를 주입하는 오프라인 저장소 기본 인스턴스(Core 는 MAUI 비의존).
+public static class AppOfflineStore
+{
+ public static OfflineStore Default { get; } = new(FileSystem.AppDataDirectory);
+}
diff --git a/VideoDownloader.Mobile/Pages.cs b/VideoDownloader.Mobile/Pages.cs
index 00e6512..0c50727 100644
--- a/VideoDownloader.Mobile/Pages.cs
+++ b/VideoDownloader.Mobile/Pages.cs
@@ -70,13 +70,16 @@ public sealed class ConnectPage : ContentPage
}
}
-/// M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어.
+/// M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어 + U5 완료 알림 + 외부망 안내.
public sealed class QueuePage : ContentPage
{
private readonly Entry _url = new() { Placeholder = "YouTube·영상 URL 붙여넣기", Keyboard = Keyboard.Url };
private readonly Label _summary = new();
+ private readonly Label _trouble = new() { FontSize = 12, TextColor = Colors.OrangeRed, IsVisible = false };
private readonly CollectionView _list = new();
private readonly IDispatcherTimer _timer;
+ private readonly QueueCompletionDetector _completions = new();
+ private List _last = new();
public QueuePage()
{
@@ -131,19 +134,51 @@ public sealed class QueuePage : ContentPage
Content = new VerticalStackLayout
{
Padding = 16, Spacing = 10,
- Children = { _url, addBtn, _summary, _list }
+ Children = { _url, addBtn, _summary, _trouble, _list }
};
}
private async Task Refresh()
{
- if (!ApiClient.Instance.IsConfigured) { _summary.Text = "⚠️ 먼저 연결하세요"; return; }
+ var trouble = ConnectionTroubleshoot.Message(ApiClient.Instance.IsConfigured, ApiClient.Instance.LastError);
+ _trouble.Text = trouble;
+ _trouble.IsVisible = trouble.Length > 0;
+ if (!ApiClient.Instance.IsConfigured) { _summary.Text = "⚠️ 먼저 연결하세요"; _last = new(); return; }
var items = await ApiClient.Instance.GetQueueAsync();
_list.ItemsSource = items;
+
+ // U5: 이전 스냅샷 대비 새로 완료된 항목 → 로컬 알림
+ var done = _completions.Detect(
+ _last.Select(i => (i.Id, i.Status)).ToList(),
+ items.Select(i => (i.Id, i.Status)).ToList());
+ foreach (var id in done)
+ {
+ var item = items.First(i => i.Id == id);
+ _ = NotifyCompletionAsync(item.Title);
+ _summary.Text = $"🔔 완료: {item.Title}";
+ }
+ _last = items;
+
var err = ApiClient.Instance.LastError;
_summary.Text = err != null ? $"❌ {err}" : items.Count == 0 ? "대기 중인 항목이 없습니다" : $"{items.Count}건";
}
+ /// U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청).
+ private static async Task NotifyCompletionAsync(string title)
+ {
+ try
+ {
+ if (OperatingSystem.IsAndroidVersionAtLeast(33))
+ await Plugin.LocalNotification.LocalNotificationCenter.Current.RequestNotificationPermission();
+ Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
+ {
+ Title = "다운로드 완료",
+ Description = title,
+ });
+ }
+ catch { }
+ }
+
private sealed class PercentConverter : IValueConverter
{
public object Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c) => Math.Clamp(value is double d ? d / 100.0 : 0, 0, 1);
@@ -159,11 +194,14 @@ public sealed class LibraryPage : ContentPage
public LibraryPage()
{
Title = "라이브러리";
+ _list.ItemsLayout = new GridItemsLayout(2, ItemsLayoutOrientation.Vertical); // M2 썸네일 그리드
_list.ItemTemplate = new DataTemplate(() =>
{
- var name = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
+ var thumb = new Image { HeightRequest = 96, Aspect = Aspect.AspectFill, BackgroundColor = Colors.Black };
+ thumb.SetBinding(Image.SourceProperty, new Binding(".", converter: new ThumbConverter()));
+ var name = new Label { FontSize = 13, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation, LineHeight = 1.1 };
name.SetBinding(Label.TextProperty, "FileName");
- var size = new Label { FontSize = 11, TextColor = Colors.Gray };
+ var size = new Label { FontSize = 10, TextColor = Colors.Gray };
size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes"));
// U4 폰 저장: 카드 우측 버튼, 진행률을 버튼 텍스트로 표시
@@ -172,8 +210,8 @@ public sealed class LibraryPage : ContentPage
var card = new Frame
{
- Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
- Content = new HorizontalStackLayout { Spacing = 8, Children = { new VerticalStackLayout { Children = { name, size } }, save } }
+ Margin = new Thickness(6, 4), Padding = 8, CornerRadius = 10,
+ Content = new VerticalStackLayout { Spacing = 4, Children = { thumb, name, size, save } }
};
// CollectionView.SelectionChanged 는 Android 에서 발화 불안정 → 템플릿 카드에 탭 제스처
var tap = new TapGestureRecognizer();
@@ -194,15 +232,15 @@ public sealed class LibraryPage : ContentPage
private static async Task SaveOffline(Button button, LibraryItem? item)
{
if (item == null) return;
- if (OfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
+ if (AppOfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
button.Text = "0%";
try
{
- var dest = OfflineStore.Default.PathFor(item.Id);
+ var dest = AppOfflineStore.Default.PathFor(item.Id);
var progress = new Progress(p => MainThread.BeginInvokeOnMainThread(
() => button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%"));
await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None);
- OfflineStore.Default.Record(item.Id, item.FileName);
+ AppOfflineStore.Default.Record(item.Id, item.FileName);
button.Text = "✓ 저장됨";
}
catch (Exception e)
@@ -247,7 +285,7 @@ public sealed class DownloadsPage : ContentPage
var del = new Button { Text = "🗑", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center };
del.Clicked += (_, _) =>
{
- if ((del.BindingContext as OfflineItem) is { } it) { OfflineStore.Default.Delete(it.Id); Reload(); }
+ if ((del.BindingContext as OfflineItem) is { } it) { AppOfflineStore.Default.Delete(it.Id); Reload(); }
};
var card = new Frame
@@ -260,7 +298,7 @@ public sealed class DownloadsPage : ContentPage
{
if ((card.BindingContext as OfflineItem) is { } it)
_ = Application.Current!.MainPage!.Navigation.PushModalAsync(
- new NavigationPage(new PlayerPage(new LibraryItem(it.Id, it.FileName, it.SizeBytes, DateTime.MinValue), OfflineStore.Default.PathFor(it.Id))));
+ new NavigationPage(new PlayerPage(new LibraryItem(it.Id, it.FileName, it.SizeBytes, DateTime.MinValue), AppOfflineStore.Default.PathFor(it.Id))));
};
card.GestureRecognizers.Add(tap);
return card;
@@ -277,7 +315,7 @@ public sealed class DownloadsPage : ContentPage
private void Reload()
{
- var items = OfflineStore.Default.List();
+ var items = AppOfflineStore.Default.List();
_list.ItemsSource = items.Count > 0 ? items : null;
_empty.IsVisible = items.Count == 0;
}
@@ -321,14 +359,55 @@ public sealed class PlayerPage : ContentPage
});
}
Content = _media;
+ // M2: 재생 중 미디어 알림(ongoing 노티) — 재생은 앱 내(포그라운드) 한정
+ StartPlaybackNotification(item.FileName);
}
protected override void OnDisappearing()
{
if (_sync && _itemId != null && _media.Position > TimeSpan.Zero)
_ = ApiClient.Instance.PostPositionAsync(_itemId, _media.Position.TotalSeconds);
+ StopPlaybackNotification();
_media.Stop();
_media.Handler?.DisconnectHandler(); // 재생 리소스 해제
base.OnDisappearing();
}
+
+ private const int PlaybackNotificationId = 4711;
+
+ /// M2: 재생 중 미디어 알림 — ongoing 노티(닫으면 자동 소멸).
+ private static void StartPlaybackNotification(string title)
+ {
+ try
+ {
+ Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
+ {
+ NotificationId = PlaybackNotificationId,
+ Title = "재생 중",
+ Description = title,
+ Android =
+ {
+ ChannelId = "vd_playback",
+ Ongoing = true,
+ AutoCancel = false,
+ },
+ });
+ }
+ catch { }
+ }
+
+ private static void StopPlaybackNotification()
+ {
+ try { Plugin.LocalNotification.LocalNotificationCenter.Current.Cancel(PlaybackNotificationId); } catch { }
+ }
+}
+
+/// M2: LibraryItem → 썸네일 URL 변환(바인딩 컨버터).
+public sealed class ThumbConverter : IValueConverter
+{
+ public object? Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c)
+ => value is LibraryItem it && ApiClient.Instance.IsConfigured
+ ? Microsoft.Maui.Controls.ImageSource.FromUri(new Uri(ApiClient.Instance.ThumbUrl(it)))
+ : null;
+ public object ConvertBack(object? v, Type t, object? p, System.Globalization.CultureInfo c) => throw new NotSupportedException();
}
diff --git a/VideoDownloader.Mobile/Platforms/Android/AndroidManifest.xml b/VideoDownloader.Mobile/Platforms/Android/AndroidManifest.xml
index 5cdb4fd..ac3418f 100644
--- a/VideoDownloader.Mobile/Platforms/Android/AndroidManifest.xml
+++ b/VideoDownloader.Mobile/Platforms/Android/AndroidManifest.xml
@@ -4,5 +4,9 @@
-
+
+
+
+
+
\ No newline at end of file
diff --git a/VideoDownloader.Mobile/VideoDownloader.Mobile.csproj b/VideoDownloader.Mobile/VideoDownloader.Mobile.csproj
index 9928874..b285db5 100644
--- a/VideoDownloader.Mobile/VideoDownloader.Mobile.csproj
+++ b/VideoDownloader.Mobile/VideoDownloader.Mobile.csproj
@@ -66,8 +66,14 @@
+
+
+
+
+
+
diff --git a/VideoDownloader.Server/ServerHost.cs b/VideoDownloader.Server/ServerHost.cs
index c152bf6..8054ed1 100644
--- a/VideoDownloader.Server/ServerHost.cs
+++ b/VideoDownloader.Server/ServerHost.cs
@@ -96,8 +96,30 @@ public sealed class ServerHost : IAsyncDisposable
app.MapPost("/api/queue/{id}/cancel", async (string id) => { await queue.CancelAsync(id); Results.Ok(); });
// ── 라이브러리 / 스트리밍 ──
+ // ── 라이브러리 + 썸네일/해상도(M2) ──
+ var thumbnailer = options.Thumbnailer ?? ServerOptionsDefaults.DefaultThumbnailer();
+ var ffmpeg = VideoDownloader.Core.Platform.DesktopMediaMuxer.Default.ResolveFfmpeg(new VideoDownloader.Core.Config.AppConfig());
app.MapGet("/api/library", async (HttpContext ctx) =>
- await ctx.Response.WriteAsJsonAsync(MediaLibrary.List(options.LibraryFolder)));
+ {
+ var items = MediaLibrary.List(options.LibraryFolder);
+ if (ffmpeg != null)
+ {
+ for (var i = 0; i < items.Count; i++)
+ {
+ var res = await thumbnailer.GetResolutionAsync(ffmpeg, Path.Combine(options.LibraryFolder, items[i].FileName));
+ items[i] = items[i] with { Resolution = res };
+ }
+ }
+ await ctx.Response.WriteAsJsonAsync(items);
+ });
+
+ app.MapGet("/api/library/{id}/thumb", async (string id) =>
+ {
+ var file = MediaLibrary.Find(options.LibraryFolder, id);
+ if (file == null || ffmpeg == null) return Results.NotFound();
+ var jpg = await thumbnailer.GetThumbnailAsync(ffmpeg, file.Value.FullPath);
+ return jpg == null ? Results.NotFound() : Results.File(jpg, "image/jpeg");
+ });
app.MapGet("/stream/{id}", (string id) =>
{
diff --git a/VideoDownloader.Server/ServerModels.cs b/VideoDownloader.Server/ServerModels.cs
index 97ca183..2ce8dc7 100644
--- a/VideoDownloader.Server/ServerModels.cs
+++ b/VideoDownloader.Server/ServerModels.cs
@@ -4,7 +4,7 @@ namespace VideoDownloader.Server;
public sealed record QueueItemDto(string Id, string Title, string Url, string Status, double Percent);
/// 라이브러리(저장 폴더) 미디어 항목.
-public sealed record LibraryItemDto(string Id, string FileName, long SizeBytes, DateTime Modified);
+public sealed record LibraryItemDto(string Id, string FileName, long SizeBytes, DateTime Modified, string? Resolution = null);
/// 이어보기 위치(U3 동기화). 초 단위.
public sealed record PositionDto(double PositionSec);
@@ -22,6 +22,15 @@ public sealed class ServerOptions
public IQueueAdapter Queue { get; init; } = new EmptyQueueAdapter();
/// 기기 표시명(웹 컴패니언 헤더용).
public string DeviceName { get; init; } = Environment.MachineName;
+ /// 썸네일/해상도 생성기(기본: DesktopProcessRunner 기반 ffmpeg). 테스트 주입 가능.
+ public Thumbnailer? Thumbnailer { get; init; }
+}
+
+/// 기본 썸네일러 팩토리.
+public static class ServerOptionsDefaults
+{
+ public static Thumbnailer DefaultThumbnailer() =>
+ new(VideoDownloader.Core.Platform.DesktopProcessRunner.Default);
}
/// 호스트 앱의 다운로드 큐와 서버를 잇는 어댑터(UI 스레드 마샬링은 구현측 담당).
diff --git a/VideoDownloader.Server/Thumbnailer.cs b/VideoDownloader.Server/Thumbnailer.cs
new file mode 100644
index 0000000..53d7d8f
--- /dev/null
+++ b/VideoDownloader.Server/Thumbnailer.cs
@@ -0,0 +1,57 @@
+using System.Text.RegularExpressions;
+using VideoDownloader.Core.Config;
+using VideoDownloader.Core.Platform;
+
+namespace VideoDownloader.Server;
+
+///
+/// M2 라이브러리 메타데이터 — ffmpeg 기반 썸네일(.thumb.jpg)·해상도(.meta.json) 사이드카 캐시.
+/// IProcessRunner 주입(테스트는 Fake, 호스트는 DesktopProcessRunner).
+///
+public sealed partial class Thumbnailer
+{
+ private readonly IProcessRunner _runner;
+ public Thumbnailer(IProcessRunner runner) => _runner = runner;
+
+ /// 1초 지점 프레임 썸네일(320px 폭 JPEG). 실패 시 null. 캐시 히트 시 프로세스 없음.
+ public async Task GetThumbnailAsync(string ffmpeg, string mediaPath, CancellationToken ct = default)
+ {
+ var cache = mediaPath + ".thumb.jpg";
+ try
+ {
+ if (File.Exists(cache)) return await File.ReadAllBytesAsync(cache, ct);
+ var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, new[]
+ {
+ "-y", "-hide_banner", "-loglevel", "error", "-ss", "1", "-i", mediaPath,
+ "-frames:v", "1", "-vf", "scale=320:-2", cache,
+ }), null, ct);
+ if (res.ExitCode != 0 || !File.Exists(cache)) return null;
+ return await File.ReadAllBytesAsync(cache, ct);
+ }
+ catch { return null; }
+ }
+
+ /// 해상도("1920x1080"). ffmpeg -i stderr 의 Stream 라인에서 추출, .meta.json 캐시.
+ public async Task GetResolutionAsync(string ffmpeg, string mediaPath, CancellationToken ct = default)
+ {
+ var cache = mediaPath + ".meta.json";
+ try
+ {
+ if (File.Exists(cache))
+ {
+ var cached = await File.ReadAllTextAsync(cache, ct);
+ return cached.Length >= 3 ? cached.Trim('"') : null;
+ }
+ var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, new[]
+ { "-hide_banner", "-i", mediaPath }), null, ct);
+ var m = ResolutionRegex().Match(res.Stderr);
+ var value = m.Success ? m.Groups[1].Value : null;
+ await File.WriteAllTextAsync(cache, value == null ? "-" : $"\"{value}\"", ct);
+ return value;
+ }
+ catch { return null; }
+ }
+
+ [GeneratedRegex(@"Video:.*?,\s(\d{2,5}x\d{2,5})")]
+ private static partial Regex ResolutionRegex();
+}
diff --git a/VideoDownloader.Server/VideoDownloader.Server.csproj b/VideoDownloader.Server/VideoDownloader.Server.csproj
index 36d4ea7..30457e0 100644
--- a/VideoDownloader.Server/VideoDownloader.Server.csproj
+++ b/VideoDownloader.Server/VideoDownloader.Server.csproj
@@ -8,4 +8,7 @@
+
+
+
diff --git a/VideoDownloader.Tests/Browser/PermissionGateTests.cs b/VideoDownloader.Tests/Browser/PermissionGateTests.cs
new file mode 100644
index 0000000..3b3c9c1
--- /dev/null
+++ b/VideoDownloader.Tests/Browser/PermissionGateTests.cs
@@ -0,0 +1,46 @@
+using VideoDownloader.Browser;
+
+namespace VideoDownloader.Tests.Browser;
+
+/// T3-11: 사이트별 권한 정책 — 기억된 상태 우선, 미기억은 Ask(프롬프트).
+public class PermissionGateTests
+{
+ [Fact]
+ public void 미기억_권한은_Ask()
+ => Assert.Equal(PermissionVerdict.Ask, new PermissionGate().Decide("notifications", "https://x.com"));
+
+ [Theory]
+ [InlineData("allow")]
+ [InlineData("deny")]
+ public void 기억된_판정은_그대로_반환(string v)
+ {
+ var g = new PermissionGate();
+ g.Remember("notifications", "https://x.com", v == "allow" ? PermissionVerdict.Allow : PermissionVerdict.Deny);
+ Assert.Equal(v == "allow" ? PermissionVerdict.Allow : PermissionVerdict.Deny,
+ g.Decide("notifications", "https://x.com"));
+ }
+
+ [Fact]
+ public void 판정은_출처_단위_격리()
+ {
+ var g = new PermissionGate();
+ g.Remember("geolocation", "https://a.com", PermissionVerdict.Allow);
+ Assert.Equal(PermissionVerdict.Ask, g.Decide("geolocation", "https://b.com"));
+ }
+
+ [Fact]
+ public void 판정은_종류_단위_격리()
+ {
+ var g = new PermissionGate();
+ g.Remember("notifications", "https://a.com", PermissionVerdict.Allow);
+ Assert.Equal(PermissionVerdict.Ask, g.Decide("camera", "https://a.com"));
+ }
+
+ [Fact]
+ public void OtherSensors_등_민감_종류는_자동_거절()
+ => Assert.Equal(PermissionVerdict.Deny, new PermissionGate().Decide("OtherSensors", "https://x.com"));
+
+ [Fact]
+ public void MultipleAutomaticDownloads_파일_다운로드는_자동_허용()
+ => Assert.Equal(PermissionVerdict.Allow, new PermissionGate().Decide("MultipleAutomaticDownloads", "https://x.com"));
+}
diff --git a/VideoDownloader.Tests/Browser/TrackerBlockTests.cs b/VideoDownloader.Tests/Browser/TrackerBlockTests.cs
new file mode 100644
index 0000000..aaa490b
--- /dev/null
+++ b/VideoDownloader.Tests/Browser/TrackerBlockTests.cs
@@ -0,0 +1,40 @@
+using VideoDownloader.Browser;
+
+namespace VideoDownloader.Tests.Browser;
+
+/// T3-14: 추적 방지 — 블랙리스트 도메인 매칭(서브도메인 포함) + 차단 카운터.
+public class TrackerBlockTests
+{
+ [Theory]
+ [InlineData("https://google-analytics.com/analytics.js", true)] // 정확히 도메인
+ [InlineData("https://www.google-analytics.com/g/collect", true)] // 서브도메인
+ [InlineData("https://x.com/notgoogle-analytics.com/a", false)] // 접미사 함정 제외
+ [InlineData("https://doubleclick.net/instream/ad_status.js", true)]
+ [InlineData("https://safe.example.com/lib.js", false)] // 무관 도메인
+ public void Matches_블랙리스트_도메인_판정(string url, bool expected)
+ => Assert.Equal(expected, new TrackerBlocklist().Matches(new Uri(url)));
+
+ [Fact]
+ public void 커스텀_도메인_주입_가능()
+ {
+ var bl = new TrackerBlocklist(new[] { "blocked.test" });
+ Assert.True(bl.Matches(new Uri("https://t.blocked.test/x.js")));
+ Assert.False(bl.Matches(new Uri("https://ok.com/x.js")));
+ }
+
+ [Fact]
+ public void Counter_차단_누적_도메인별_집계_변경_통지()
+ {
+ var c = new TrackerCounter();
+ var changed = 0;
+ c.Changed += () => changed++;
+ Assert.Equal(0, c.Total);
+ c.Record("https://www.google-analytics.com/a.js");
+ c.Record("https://google-analytics.com/b.js");
+ c.Record("https://scorecardresearch.com/c.js");
+ Assert.Equal(3, c.Total);
+ Assert.Equal(2, c.ByDomain["google-analytics.com"]);
+ Assert.Equal(1, c.ByDomain["scorecardresearch.com"]);
+ Assert.Equal(3, changed);
+ }
+}
diff --git a/VideoDownloader.Tests/Browser/UrlCleanerTests.cs b/VideoDownloader.Tests/Browser/UrlCleanerTests.cs
new file mode 100644
index 0000000..a704dcb
--- /dev/null
+++ b/VideoDownloader.Tests/Browser/UrlCleanerTests.cs
@@ -0,0 +1,80 @@
+using VideoDownloader.Browser;
+
+namespace VideoDownloader.Tests.Browser;
+
+/// T3-15: 파라미터 제거 복사 + 주소창 진행 상태.
+public class UrlCleanerTests
+{
+ [Theory]
+ [InlineData("https://x.com/a?utm_source=1&b=2", "https://x.com/a")] // 쿼리 제거
+ [InlineData("https://x.com/a#frag", "https://x.com/a")] // 프래그먼트 제거
+ [InlineData("https://x.com/a?b=1#f", "https://x.com/a")] // 둘 다
+ [InlineData("https://x.com/a/", "https://x.com/a/")] // 없으면 무변경
+ [InlineData("https://x.com/a?q=한글", "https://x.com/a")] // 유니코드 쿼리
+ [InlineData("x.com/a?b=1", "x.com/a")] // 스킴 없어도 동작
+ public void StripQuery_파라미터와_프래그먼트를_제거(string input, string expected)
+ => Assert.Equal(expected, UrlCleaner.StripQuery(input));
+
+ [Fact]
+ public void StripQuery_빈값_null_안전()
+ {
+ Assert.Equal("", UrlCleaner.StripQuery(""));
+ Assert.Null(UrlCleaner.StripQuery(null));
+ }
+}
+
+/// T3-15: 탭 전체 네비게이션 진행 집계 — 주소창 진행 바 표시 상태.
+public class NavProgressTrackerTests
+{
+ [Fact]
+ public void 초기값은_비진행()
+ => Assert.False(new NavProgressTracker().IsNavigating);
+
+ [Fact]
+ public void 시작하면_진행()
+ {
+ var t = new NavProgressTracker();
+ t.Started();
+ Assert.True(t.IsNavigating);
+ }
+
+ [Fact]
+ public void 완료되면_비진행()
+ {
+ var t = new NavProgressTracker();
+ t.Started();
+ t.Completed();
+ Assert.False(t.IsNavigating);
+ }
+
+ [Fact]
+ public void 동시_내비게이션은_마지막_완료까지_진행() // 프레임 리다이렉트·중복 시작
+ {
+ var t = new NavProgressTracker();
+ t.Started(); t.Started();
+ t.Completed();
+ Assert.True(t.IsNavigating);
+ t.Completed();
+ Assert.False(t.IsNavigating);
+ }
+
+ [Fact]
+ public void 과잉_완료로_음수_되지_않음()
+ {
+ var t = new NavProgressTracker();
+ t.Completed();
+ Assert.False(t.IsNavigating);
+ }
+
+ [Fact]
+ public void IsNavigating_변경_시_INPC_발화()
+ {
+ var t = new NavProgressTracker();
+ var fired = new List();
+ t.PropertyChanged += (_, e) => fired.Add(e.PropertyName);
+ t.Started();
+ t.Completed();
+ t.Completed(); // false→false → 이벤트 없음
+ Assert.Equal(new[] { nameof(NavProgressTracker.IsNavigating), nameof(NavProgressTracker.IsNavigating) }, fired);
+ }
+}
diff --git a/VideoDownloader.Tests/E2E/PermissionTrackerE2ETests.cs b/VideoDownloader.Tests/E2E/PermissionTrackerE2ETests.cs
new file mode 100644
index 0000000..8f493cd
--- /dev/null
+++ b/VideoDownloader.Tests/E2E/PermissionTrackerE2ETests.cs
@@ -0,0 +1,109 @@
+using System.Windows;
+using System.Windows.Threading;
+using Microsoft.Web.WebView2.Core;
+using VideoDownloader.Browser;
+using VideoDownloader.Tests.Infrastructure;
+
+namespace VideoDownloader.Tests.E2E;
+
+///
+/// E2E (T3-11/T3-14): 실제 WebView2 에서 권한 정책 응답과 추적 요청 차단·집계를 검증.
+/// TabViewService 와 동일한 배선(PermissionGate/TrackerBlocklist/TrackerCounter)을 사용한다.
+///
+[Collection("E2E")]
+public class PermissionTrackerE2ETests
+{
+ [Fact]
+ public async Task 권한_요청이_정책을_거쳐_허용된다_T3_11()
+ {
+ using var sta = StaDispatcher.Start();
+ var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
+ var result = await sta.InvokeAsync(async () =>
+ {
+ var window = new Window();
+ var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
+ window.Content = browser;
+ window.Show();
+
+ var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
+ await browser.EnsureCoreWebView2Async(env);
+ var cv = browser.CoreWebView2;
+
+ // TabViewService 배선과 동일: 기억된 판정은 즉시 응답(출처는 이벤트에서 관측된 것으로 기억)
+ var gate = new PermissionGate();
+ cv.PermissionRequested += (_, e) =>
+ {
+ var o = VideoDownloader.App.Services.TabViewService.OriginOf(e.Uri);
+ gate.Remember("Notifications", o, PermissionVerdict.Allow); // E2E: 알림은 허용 정책
+ var verdict = gate.Decide(e.PermissionKind.ToString(), o);
+ e.State = verdict == PermissionVerdict.Allow ? CoreWebView2PermissionState.Allow : CoreWebView2PermissionState.Deny;
+ e.Handled = true;
+ };
+
+ var html = "";
+ cv.NavigateToString(html);
+ await WaitUntilNavCompleted(cv);
+ var title = await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""),
+ t => t.StartsWith("PERM=", StringComparison.Ordinal), 10000);
+
+ window.Close();
+ return title;
+ });
+
+ try { Directory.Delete(udf, true); } catch { }
+ Assert.Equal("PERM=granted", result);
+ }
+
+ [Fact]
+ public async Task 블랙리스트_스크립트가_차단되고_카운터에_집계된다_T3_14()
+ {
+ using var sta = StaDispatcher.Start();
+ var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
+ var result = await sta.InvokeAsync(async () =>
+ {
+ var window = new Window();
+ var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
+ window.Content = browser;
+ window.Show();
+
+ var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
+ await browser.EnsureCoreWebView2Async(env);
+ var cv = browser.CoreWebView2;
+
+ var bl = new TrackerBlocklist(new[] { "blocked.test" });
+ var counter = new TrackerCounter();
+ cv.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
+ cv.WebResourceRequested += (_, e) =>
+ {
+ if (!bl.Matches(new Uri(e.Request.Uri))) return;
+ e.Response = env.CreateWebResourceResponse(null, 204, "Blocked", "");
+ counter.Record(e.Request.Uri);
+ };
+
+ var html = "tracker-e2e
" +
+ "" +
+ "" +
+ "" +
+ "";
+ cv.NavigateToString(html);
+ await WaitUntilNavCompleted(cv);
+ await Wait.Until(() => Task.FromResult(counter.Total), t => t >= 2, 10000);
+
+ window.Close();
+ return counter.Total;
+ });
+
+ try { Directory.Delete(udf, true); } catch { }
+ Assert.Equal(2, result); // 정상 도메인(ok.example.com)은 집계되지 않음
+ }
+
+ private static async Task WaitUntilNavCompleted(CoreWebView2 cv)
+ {
+ var tcs = new TaskCompletionSource();
+ EventHandler h = (_, _) => tcs.TrySetResult();
+ cv.NavigationCompleted += h;
+ if (tcs.Task.IsCompleted) return;
+ await Task.WhenAny(tcs.Task, Task.Delay(15000));
+ cv.NavigationCompleted -= h;
+ }
+}
diff --git a/VideoDownloader.Tests/Mobile/MobileCoreTests.cs b/VideoDownloader.Tests/Mobile/MobileCoreTests.cs
new file mode 100644
index 0000000..a1dc9e4
--- /dev/null
+++ b/VideoDownloader.Tests/Mobile/MobileCoreTests.cs
@@ -0,0 +1,106 @@
+using VideoDownloader.Mobile;
+
+namespace VideoDownloader.Tests.Mobile;
+
+/// Mobile.Core 순수 로직 — U5 완료 감지, 외부망 안내, U4 저장소(파일 시스템).
+public class MobileCoreTests
+{
+ // ── U5 완료 알림 감지 ──
+
+ [Fact]
+ public void 완료_전환_항목_ID_반환()
+ {
+ var d = new QueueCompletionDetector();
+ var prev = new[] { ("a", "다운로드 중"), ("b", "대기") };
+ var next = new[] { ("a", "완료"), ("b", "다운로드 중") };
+ Assert.Equal(new[] { "a" }, d.Detect(prev, next));
+ }
+
+ [Fact]
+ public void 이미_완료였던_항목은_재알림_없음()
+ {
+ var d = new QueueCompletionDetector();
+ var prev = new[] { ("a", "완료") };
+ var next = new[] { ("a", "완료") };
+ Assert.Empty(d.Detect(prev, next));
+ }
+
+ [Fact]
+ public void 첫_폴링에서_이미_완료된_항목은_무시() // 앱 시작 시 과거 완료 스팸 방지
+ {
+ var d = new QueueCompletionDetector();
+ Assert.Empty(d.Detect(Array.Empty<(string, string)>(), new[] { ("a", "완료") }));
+ }
+
+ [Fact]
+ public void 목록에서_사라진_항목은_알림_없음()
+ {
+ var d = new QueueCompletionDetector();
+ var prev = new[] { ("a", "다운로드 중") };
+ Assert.Empty(d.Detect(prev, Array.Empty<(string, string)>()));
+ }
+
+ // ── 외부망 안내(MOBILE_PLAN §7) ──
+
+ [Fact]
+ public void 미연결_안내()
+ {
+ var m = ConnectionTroubleshoot.Message(isConfigured: false, lastError: null);
+ Assert.Contains("연결", m);
+ }
+
+ [Fact]
+ public void 연결됐지만_실패_시_같은네트워크_VPN_안내()
+ {
+ var m = ConnectionTroubleshoot.Message(isConfigured: true, lastError: "No such host is known.");
+ Assert.Contains("같은 네트워크", m);
+ Assert.Contains("Tailscale", m); // 계획 §3: 자체 릴레이 대신 Tailscale 문서화
+ }
+
+ [Fact]
+ public void 정상_시_빈_문자열()
+ => Assert.Equal("", ConnectionTroubleshoot.Message(isConfigured: true, lastError: null));
+
+ // ── U4 저장소(임시 디렉터리로 파일 시스템 검증) ──
+
+ [Fact]
+ public void OfflineStore_기록_목록_삭제_라운드트립()
+ {
+ var baseDir = Path.Combine(Path.GetTempPath(), $"vd_core_{Guid.NewGuid():N}");
+ var store = new OfflineStore(baseDir);
+ Assert.Empty(store.List());
+
+ var p = store.PathFor("id1");
+ Directory.CreateDirectory(Path.GetDirectoryName(p)!);
+ File.WriteAllBytes(p, new byte[123]);
+ store.Record("id1", "video.mp4");
+
+ Assert.True(store.Exists("id1"));
+ var list = store.List();
+ Assert.Single(list);
+ Assert.Equal(("id1", "video.mp4", 123L), (list[0].Id, list[0].FileName, list[0].SizeBytes));
+
+ store.Delete("id1");
+ Assert.False(store.Exists("id1"));
+ Assert.Empty(store.List());
+
+ Directory.Delete(baseDir, true);
+ }
+
+ [Fact]
+ public void OfflineStore_재시작_시_매니페스트_유지()
+ {
+ var baseDir = Path.Combine(Path.GetTempPath(), $"vd_core_{Guid.NewGuid():N}");
+ var store = new OfflineStore(baseDir);
+ var p = store.PathFor("id2");
+ Directory.CreateDirectory(Path.GetDirectoryName(p)!);
+ File.WriteAllBytes(p, new byte[10]);
+ store.Record("id2", "keep.mp4");
+
+ var reopened = new OfflineStore(baseDir); // 새 인스턴스 = 앱 재시작
+ Assert.True(reopened.Exists("id2"));
+ Assert.Equal("keep.mp4", reopened.List().Single().FileName);
+
+ Directory.Delete(baseDir, true);
+ }
+}
diff --git a/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs b/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs
index 3a5aaab..19b8cf0 100644
--- a/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs
+++ b/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs
@@ -155,6 +155,44 @@ public class RemoteServerE2ETests : IDisposable
Assert.Equal(93.5, persisted!.PositionSec);
}
+ [Fact]
+ public async Task 썸네일_API_E2E_생성_캐시_404()
+ {
+ Directory.CreateDirectory(_libDir);
+ var mediaPath = Path.Combine(_libDir, "clip.mp4");
+ File.WriteAllBytes(mediaPath, new byte[2048]);
+
+ // 가짜 ffmpeg: 출력 경로에 JPEG 생성
+ var fake = new Tests.Platform.FakeProcessRunner
+ {
+ Behavior = (spec, _, _) =>
+ {
+ File.WriteAllBytes(spec.Args[^1], new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x01 });
+ return Task.FromResult(new VideoDownloader.Core.Platform.ProcessResult(0, "", ""));
+ },
+ };
+
+ await using var server = await ServerHost.StartAsync(new ServerOptions
+ {
+ Port = 0, Token = "thumb-token", LibraryFolder = _libDir,
+ Queue = new FakeQueue(),
+ Thumbnailer = new Thumbnailer(fake),
+ });
+ using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") };
+
+ // 1) 익명 401
+ Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await http.GetAsync("/api/library/xyz/thumb")).StatusCode);
+
+ // 2) 없는 항목 404
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "thumb-token");
+ Assert.Equal(System.Net.HttpStatusCode.NotFound, (await http.GetAsync("/api/library/xyz/thumb")).StatusCode);
+
+ // 3) 실제 항목 → 200 + JPEG 매직
+ var lib = (await http.GetFromJsonAsync>("/api/library"))!;
+ var thumb = await http.GetByteArrayAsync($"/api/library/{lib[0].Id}/thumb");
+ Assert.True(thumb.Length >= 2 && thumb[0] == 0xFF && thumb[1] == 0xD8, "JPEG 매직 바이트");
+ }
+
public void Dispose()
{
try { Directory.Delete(_libDir, true); } catch { }
diff --git a/VideoDownloader.Tests/Server/ThumbnailerTests.cs b/VideoDownloader.Tests/Server/ThumbnailerTests.cs
new file mode 100644
index 0000000..ada9fc2
--- /dev/null
+++ b/VideoDownloader.Tests/Server/ThumbnailerTests.cs
@@ -0,0 +1,84 @@
+using VideoDownloader.Core.Platform;
+using VideoDownloader.Server;
+using VideoDownloader.Tests.Platform;
+
+namespace VideoDownloader.Tests.Server;
+
+/// M2 라이브러리 썸네일/해상도 — ffmpeg 서브프로세스를 Fake 러너로 검증(사이드카 캐시).
+public class ThumbnailerTests
+{
+ private string _dir = "";
+
+ private string NewMedia()
+ {
+ _dir = Path.Combine(Path.GetTempPath(), $"vd_thumb_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_dir);
+ var p = Path.Combine(_dir, "clip.mp4");
+ File.WriteAllBytes(p, new byte[100]);
+ return p;
+ }
+
+ private static Task FakeFfmpegCreatesJpg(ProcessSpec spec, Action? _, CancellationToken __)
+ {
+ // 출력 경로(마지막 인자)에 가짜 JPEG 생성
+ var outPath = spec.Args[^1];
+ File.WriteAllBytes(outPath, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }); // JPEG SOI/APP0
+ return Task.FromResult(new ProcessResult(0, "", ""));
+ }
+
+ private static Task FakeFfmpegReportsResolution(ProcessSpec spec, Action? onLine, CancellationToken _)
+ {
+ var line = " Stream #0:0[0x1](und): Video: h264 (High), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 5000 kb/s";
+ onLine?.Invoke("err", line);
+ return Task.FromResult(new ProcessResult(0, "", line));
+ }
+
+ [Fact]
+ public async Task 썸네일_생성후_사이드카_캐시로_재실행_없음()
+ {
+ var media = NewMedia();
+ var runner = new FakeProcessRunner { Behavior = FakeFfmpegCreatesJpg };
+ var t = new Thumbnailer(runner);
+
+ var bytes1 = await t.GetThumbnailAsync("ffmpeg", media);
+ Assert.NotNull(bytes1);
+ Assert.True(bytes1!.Length == 4);
+
+ runner.Behavior = (_, _, _) => throw new Xunit.Sdk.XunitException("캐시 히트 시 ffmpeg 재실행 없음");
+ var bytes2 = await t.GetThumbnailAsync("ffmpeg", media); // 캐시
+ Assert.Equal(bytes1, bytes2);
+ Directory.Delete(_dir, true);
+ }
+
+ [Fact]
+ public async Task ffmpeg_실패_시_null()
+ {
+ var media = NewMedia();
+ var runner = new FakeProcessRunner { Behavior = (_, _, _) => Task.FromResult(new ProcessResult(1, "", "boom")) };
+ Assert.Null(await new Thumbnailer(runner).GetThumbnailAsync("ffmpeg", media));
+ Directory.Delete(_dir, true);
+ }
+
+ [Fact]
+ public async Task 해상도_추출후_사이드카_캐시()
+ {
+ var media = NewMedia();
+ var runner = new FakeProcessRunner { Behavior = FakeFfmpegReportsResolution };
+ var t = new Thumbnailer(runner);
+
+ Assert.Equal("1920x1080", await t.GetResolutionAsync("ffmpeg", media));
+
+ runner.Behavior = (_, _, _) => throw new Xunit.Sdk.XunitException("캐시 히트 시 재실행 없음");
+ Assert.Equal("1920x1080", await t.GetResolutionAsync("ffmpeg", media));
+ Directory.Delete(_dir, true);
+ }
+
+ [Fact]
+ public async Task 해상기_추출_불가_시_null()
+ {
+ var media = NewMedia();
+ var runner = new FakeProcessRunner { Behavior = (_, _, _) => Task.FromResult(new ProcessResult(0, "", "no streams")) };
+ Assert.Null(await new Thumbnailer(runner).GetResolutionAsync("ffmpeg", media));
+ Directory.Delete(_dir, true);
+ }
+}
diff --git a/VideoDownloader.Tests/VideoDownloader.Tests.csproj b/VideoDownloader.Tests/VideoDownloader.Tests.csproj
index be121f8..1a2fe30 100644
--- a/VideoDownloader.Tests/VideoDownloader.Tests.csproj
+++ b/VideoDownloader.Tests/VideoDownloader.Tests.csproj
@@ -16,5 +16,6 @@
+