T3-11/14/15 + M2 잔여: 권한 정책 UI(PermissionGate/PermissionRequested 다이얼로그+SavesInProfile), 추적 방지(TrackerBlocklist/WebResourceRequested 차단+실드 배지·팝업), 주소창 진행 바+파라미터 제거 복사(UrlCleaner/NavProgressTracker), U5 완료 알림(Mobile.Core 감지기+Plugin.LocalNotification), 재생중 ongoing 미디어 알림, 썸네일/해상도 API(Thumbnailer 사이드카 캐시+/thumb)+라이브러리 그리드, 외부망 안내(ConnectionTroubleshoot). 147 tests GREEN

This commit is contained in:
Yun Chan 2026-08-15 18:11:49 +09:00
parent 27c91c04dd
commit 46c6c321f2
26 changed files with 1058 additions and 30 deletions

View file

@ -0,0 +1,78 @@
namespace VideoDownloader.Browser;
public enum PermissionVerdict { Ask, Allow, Deny }
/// <summary>
/// T3-11 사이트별 권한 정책 — 출처×종류 단위 기억. WebView2 PermissionRequested 에서
/// Ask 만 프롬프트로 올리고 Allow/Deny 는 즉시 응답. "항상" 선택은 네이티브
/// Profile.SetPermissionStateAsync 로도 영속(세션 밖 저장).
/// </summary>
public sealed class PermissionGate
{
/// <summary>프롬프트 없이 거절: 센서류(피싱 표면 축소).</summary>
private static readonly HashSet<string> AutoDeny = new(StringComparer.OrdinalIgnoreCase) { "OtherSensors" };
/// <summary>프롬프트 없이 허용: 연속 다운로드(다운로더 앱 본업).</summary>
private static readonly HashSet<string> 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;
}
/// <summary>T3-14: 추적 스크립트 블랙리스트 — 호스트 또는 서브도메인 매칭.</summary>
public sealed class TrackerBlocklist
{
/// <summary>주요 광고/분석 도메인(EasyList 공통 항목 발췌). 생성자 주입으로 교체 가능(테스트).</summary>
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<string> _domains;
public TrackerBlocklist() : this(DefaultDomains) { }
public TrackerBlocklist(IEnumerable<string> domains) =>
_domains = new HashSet<string>(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;
}
}
/// <summary>T3-14: 차단 카운터 — 총계/도메인별 집계, 변경 통지(실드 배지 갱신용).</summary>
public sealed class TrackerCounter
{
private int _total;
public int Total => Volatile.Read(ref _total);
public Dictionary<string, int> 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();
}
}

View file

@ -0,0 +1,43 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace VideoDownloader.Browser;
/// <summary>T3-15: "파라미터 제거 복사" — 추적용 쿼리·프래그먼트를 뺀 URL 반환.</summary>
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;
}
}
/// <summary>T3-15: 주소창 진행 바 상태 — 진행 중 네비게이션 개수 집계(탭·리다이렉트 중복 안전).</summary>
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));
}