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

@ -191,6 +191,14 @@
<Button Name="HistoryBtn" Style="{StaticResource IconButton}" Content="&#xE823;" ToolTip="방문 기록" Margin="0,0,2,0" Click="ToggleHistory"/>
<Button Name="TabSearchBtn" Style="{StaticResource IconButton}" Content="&#xE721;" ToolTip="탭 검색 (Ctrl+Shift+A)" Margin="0,0,2,0" Click="ToggleTabSearch"/>
<Button Name="DownloadsBtn" Style="{StaticResource IconButton}" Content="&#xE896;" ToolTip="다운로드 (Ctrl+J)" Margin="0,0,2,0" Click="ToggleDownloads"/>
<!-- T3-14 추적 방지 실드 -->
<Grid Margin="0,0,2,0">
<Button Name="ShieldBtn" Style="{StaticResource IconButton}" Content="&#xE72E;" ToolTip="추적 방지" Click="Shield_Click"/>
<Border Name="ShieldBadge" CornerRadius="8" Background="#FF4CAF50" Padding="5,1"
HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,2,2,0" Visibility="Collapsed">
<TextBlock Name="ShieldBadgeText" Text="" FontSize="9" Foreground="White"/>
</Border>
</Grid>
<Button Name="PlaylistBtn" Style="{StaticResource IconButton}" Content="&#xE8D6;" ToolTip="미디어 재생목록" Margin="0,0,2,0" Click="TogglePlaylistPanel"/>
<Button Style="{StaticResource IconButton}" Content="&#xE8B7;" ToolTip="저장 폴더 열기" Margin="0,0,2,0" Click="OpenFolder_Click"/>
<Button Name="LinkBtn" Style="{StaticResource IconButton}" Content="&#xE71B;" ToolTip="링크로 받기 (YouTube·Instagram·X)" Margin="0,0,2,0" Click="ToggleLink"/>
@ -208,10 +216,25 @@
<!-- 주소창 -->
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="8" Height="32" VerticalAlignment="Center">
<TextBox Name="AddressBox" Style="{StaticResource DarkTextBox}"
PreviewKeyDown="AddressBox_PreviewKeyDown" TextChanged="AddressBox_TextChanged"
BorderThickness="0" Background="Transparent"
Padding="14,0" FontSize="13" VerticalContentAlignment="Center"/>
<Grid>
<TextBox Name="AddressBox" Style="{StaticResource DarkTextBox}"
PreviewKeyDown="AddressBox_PreviewKeyDown" TextChanged="AddressBox_TextChanged"
BorderThickness="0" Background="Transparent"
Padding="14,0" FontSize="13" VerticalContentAlignment="Center">
<TextBox.ContextMenu>
<ContextMenu>
<MenuItem Header="붙여넣기" Command="ApplicationCommands.Paste"/>
<MenuItem Header="복사" Command="ApplicationCommands.Copy"/>
<Separator/>
<MenuItem Header="파라미터 제거 후 복사" Click="CopyCleanUrl_Click" InputGestureText="T3-15"/>
</ContextMenu>
</TextBox.ContextMenu>
</TextBox>
<!-- T3-15 주소창 진행 바 -->
<ProgressBar Name="NavProgress" Height="2" VerticalAlignment="Bottom" MinHeight="2"
Minimum="0" Maximum="100" IsIndeterminate="True" Visibility="Collapsed"
Foreground="{StaticResource AccentBrush}" Background="Transparent" BorderThickness="0"/>
</Grid>
</Border>
</DockPanel>
</Border>
@ -527,6 +550,21 @@
</Popup>
<!-- ===== 다운로드 플라이아웃 (T1-5) ===== -->
<!-- T3-14 추적 방지 실드 팝업 -->
<Popup Name="ShieldPopup" PlacementTarget="{Binding ElementName=ShieldBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="12" Padding="10" Width="320" MaxHeight="360">
<StackPanel>
<TextBlock Text="추적 방지 — 차단된 요청" Foreground="{StaticResource TextPrimary}"
FontWeight="SemiBold" Margin="6,4,6,8"/>
<ListBox Name="ShieldList" Background="Transparent" BorderThickness="0"
Foreground="{StaticResource TextSecondary}" FontSize="12"/>
</StackPanel>
</Border>
</Popup>
<Popup Name="DownloadsPopup" PlacementTarget="{Binding ElementName=DownloadsBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">

View file

@ -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
/// <summary>탭별 WebView2 생명주기·배선 전담 서비스(재구성 ④).</summary>
private Services.TabViewService? _views;
/// <summary>T3-15 주소창 진행 바 상태(전 탭 네비게이션 집계).</summary>
private NavProgressTracker? _navTracker;
/// <summary>T3-15: 파라미터 제거 후 클립보드 복사(주소창 우클릭).</summary>
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 { }
}
/// <summary>T3-14: 실드 배지 갱신.</summary>
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}건 차단됨" : "추적 방지";
}
/// <summary>T3-14: 실드 클릭 → 차단 도메인 목록 팝업.</summary>
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;
}
/// <summary>T3-11: 권한 종류 한글 표기.</summary>
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);

View file

@ -55,6 +55,27 @@ public sealed class TabViewService
/// <summary>T2-4 리더 내 낭독(TTS) 요청 — 리더 본문 텍스트.</summary>
public event Action<CoreWebView2, string>? SpeakRequested;
/// <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>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; }
@ -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
/// <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; }
}
}