diff --git a/.claude/hooks/session-brief.sh b/.claude/hooks/session-brief.sh deleted file mode 100644 index 6742c44..0000000 --- a/.claude/hooks/session-brief.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# SessionStart 훅 — 폐쇄루프의 "state layer". -# -# 세션 시작 시 저장소의 현재 상태를 요약해 컨텍스트에 넣는다. -# 에이전트가 매번 git status/log 를 따로 물어보지 않아도 지금 상황을 알고 시작한다. -# stdout 이 그대로 컨텍스트로 들어가므로 짧게 유지한다. -set -uo pipefail - -ROOT="${CLAUDE_PROJECT_DIR:-}" -if [ -z "$ROOT" ]; then - ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -fi -cd "$ROOT" || exit 0 - -echo "## 저장소 현재 상태 (SessionStart 자동 브리핑)" -echo - -BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?") -echo "- 브랜치: \`$BRANCH\`" - -LAST=$(git log -1 --format='%h %s' 2>/dev/null | cut -c1-110) -echo "- 마지막 커밋: $LAST" - -CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') -if [ "$CHANGED" = "0" ]; then - echo "- 작업트리: 깨끗함" -else - echo "- 작업트리: 변경 $CHANGED 건" - git status --porcelain 2>/dev/null | head -10 | sed 's/^/ /' - if [ "$CHANGED" -gt 10 ]; then - echo " … 외 $((CHANGED - 10))건" - fi -fi - -# 마지막 검증 이후 소스가 바뀌었는지 (Stop 훅이 쓰는 스탬프와 같은 기준) -STAMP=".claude/.verify-stamp" -NEWEST=$(find ./VideoDownloader.* -type f \ - \( -name '*.cs' -o -name '*.csproj' -o -name '*.xaml' -o -name '*.axaml' \) \ - -not -path '*/bin/*' -not -path '*/obj/*' \ - -printf '%T@\n' 2>/dev/null | sort -rn | head -1) - -if [ -f "$STAMP" ] && [ "$(cat "$STAMP" 2>/dev/null)" = "$NEWEST" ]; then - echo "- 검증: 마지막 \`dotnet test\` 이후 소스 변경 없음 (GREEN 상태)" -else - echo "- 검증: **미검증 변경 있음** — 작업 전에 \`dotnet test VideoDownloader.Tests\` 로 기준선을 확인할 것" -fi - -# 미완료 백로그 -if [ -f docs/BACKLOG.md ]; then - OPEN=$(grep -c '^- \[ \]' docs/BACKLOG.md 2>/dev/null) || OPEN=0 - echo "- 백로그 미완료: ${OPEN}건 (\`docs/BACKLOG.md\`)" -fi - -echo -echo "작업 계약은 \`AGENTS.md\`, 하네스 운영법은 \`docs/HARNESS.md\` 참고." -exit 0 diff --git a/.claude/hooks/verify-on-stop.sh b/.claude/hooks/verify-on-stop.sh deleted file mode 100644 index e4389ba..0000000 --- a/.claude/hooks/verify-on-stop.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# Stop 훅 — 폐쇄루프의 "checker" 층. -# -# 소스(.cs/.csproj/.xaml/.axaml)가 마지막 성공 검증 이후 바뀌었으면 테스트를 돌린다. -# 실패하면 exit 2 로 턴 종료를 막고, 실패 내용을 에이전트에게 돌려준다. -# 변경이 없으면 즉시 통과하므로 대화만 하는 턴은 지연이 없다. -# -# 무한 루프 방지: stop_hook_active 가 true 면(=이미 이 훅 때문에 재개된 상태) 바로 통과. -set -uo pipefail - -INPUT=$(cat) - -# ── 1. 재진입 가드 ──────────────────────────────────────────────────── -if command -v jq >/dev/null 2>&1; then - ACTIVE=$(printf '%s' "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false) -else - case "$INPUT" in - *'"stop_hook_active"'*[Tt]rue*) ACTIVE=true ;; - *) ACTIVE=false ;; - esac -fi -if [ "$ACTIVE" = "true" ]; then - exit 0 -fi - -# ── 2. 프로젝트 루트 ────────────────────────────────────────────────── -ROOT="${CLAUDE_PROJECT_DIR:-}" -if [ -z "$ROOT" ]; then - ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -fi -cd "$ROOT" || exit 0 - -STAMP=".claude/.verify-stamp" - -# ── 3. 변경 감지 (소스 최신 수정시각) ───────────────────────────────── -NEWEST=$(find ./VideoDownloader.* -type f \ - \( -name '*.cs' -o -name '*.csproj' -o -name '*.xaml' -o -name '*.axaml' \) \ - -not -path '*/bin/*' -not -path '*/obj/*' \ - -printf '%T@\n' 2>/dev/null | sort -rn | head -1) - -# 소스를 못 찾으면(경로 이상 등) 훅이 개발을 막지 않도록 통과시킨다. -if [ -z "$NEWEST" ]; then - exit 0 -fi - -if [ -f "$STAMP" ] && [ "$(cat "$STAMP" 2>/dev/null)" = "$NEWEST" ]; then - exit 0 -fi - -# ── 4. 검증 ─────────────────────────────────────────────────────────── -LOG=$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/vd-verify.$$") -dotnet test VideoDownloader.Tests/VideoDownloader.Tests.csproj \ - --nologo -v quiet >"$LOG" 2>&1 -CODE=$? - -if [ $CODE -eq 0 ]; then - printf '%s' "$NEWEST" > "$STAMP" - rm -f "$LOG" - exit 0 -fi - -# ── 5. 실패 → 종료 차단 ─────────────────────────────────────────────── -{ - echo "검증 실패: dotnet test 가 통과하지 않았다. 테스트를 GREEN 으로 만들기 전에는 턴을 끝낼 수 없다." - echo "테스트를 삭제하거나 Skip 처리해서 통과시키지 말 것. 근본 원인을 고칠 것." - echo "---- dotnet test 출력 (마지막 60줄) ----" - tail -60 "$LOG" -} >&2 - -rm -f "$LOG" -exit 2 diff --git a/.claude/rules/desktop-wpf.md b/.claude/rules/desktop-wpf.md deleted file mode 100644 index ff91f0a..0000000 --- a/.claude/rules/desktop-wpf.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -paths: - - "VideoDownloader.App/**" - - "VideoDownloader.Browser/**" ---- - -# WPF · WebView2 영역 규칙 - -## UI 스레드 - -- WebView2·엔진 이벤트는 백그라운드 스레드에서 온다. UI 를 만지려면 - `Dispatcher.BeginInvoke` 로 마샬링한다 (`MainWindow.xaml.cs` 전반의 기존 패턴). -- 백그라운드에서 갱신되는 컬렉션은 `BindingOperations.EnableCollectionSynchronization` - 으로 락을 등록한다 (`Core/DownloadManager.cs:58` 참조). 이걸 빼면 랜덤하게 죽는다. - -## dispose 이후 이벤트 - -WebView2 가 dispose 된 뒤에도 이벤트 콜백이 도착한다. 콜백 안에서 `CoreWebView2` 를 만질 때는 -로컬 변수로 받아 null 을 확인하고 try 로 감싼다 — `UpdateNavState`(`MainWindow.xaml.cs:453`)가 -기준 패턴이다. 탭을 닫는 경로를 건드렸다면 이 방어를 반드시 확인한다. - -## 탭 - -- Environment 는 전 탭 공유, WebView2 인스턴스는 탭마다. 이 구조를 바꾸지 않는다. -- 팝업(`NewWindowRequested`)은 deferral 패턴으로 새 탭에 붙인다. 앱 통제를 벗어나게 두지 않는다. - -## 거대 파일 - -`MainWindow.xaml.cs`(86KB) · `MainWindow.xaml`(55KB)은 통째로 읽지 않는다. -Grep 으로 심볼 위치를 찾고 해당 구간만 Read 한다. 분할 리팩터링은 별도 승인 사항이다. - -## 프로세스 실행 - -외부 프로세스는 문자열 인자 연결로 실행하지 않는다. `ProcessStartInfo` 에 인자를 배열로 넘긴다 -(과거 `explorer.exe` 인자 인젝션 취약점이 있었다). - -## 테스트 접근 - -`VideoDownloader.App` 은 `InternalsVisibleTo("VideoDownloader.Tests")` 가 걸려 있다. -테스트를 위해 멤버를 `public` 으로 승격하지 말고 `internal` 로 두면 된다. - -## 아이콘 글리프 (PUA · Segoe Fluent Icons) - -- **암시적(키 없는) TextBlock 스타일에 FontFamily 를 넣지 않는다.** 버튼 ContentPresenter 가 만드는 - 자동 TextBlock 이 IconFont 상속을 잃어 모든 아이콘이 tofu(□)가 된다(2026-08-18 실결함 — 원인 규명에 - 실캡처·픽셀 비교까지 필요했다). UI 폰트 통일은 각 창 루트의 `FontFamily="{DynamicResource UiFont}"` - 상속으로 한다. 코드 생성 창은 `SetResourceReference(FontFamilyProperty, "UiFont")`. -- PUA 글리프는 IconFont 보장 스타일(IconButton·FluentIcon·CaptionButton 계열)이나 명시 - `FontFamily="{DynamicResource IconFont}"` 에서만 쓴다. **한 문자열에 PUA 와 일반 텍스트를 섞지 않는다.** - `Infrastructure/IconGlyphXamlTests` 가 정적으로, `E2E/IconContentFontTests` 가 실창에서 이를 강제한다. diff --git a/.claude/rules/engine-core.md b/.claude/rules/engine-core.md deleted file mode 100644 index 76d4506..0000000 --- a/.claude/rules/engine-core.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -paths: - - "VideoDownloader.Core/**" - - "VideoDownloader.Server/**" ---- - -# 엔진 · 서버 영역 규칙 - -## 플랫폼 중립 - -`Core` 는 `net8.0` 이다. WPF·Windows 전용 API 를 끌어들이지 않는다. -Avalonia/MAUI 가 같은 엔진을 쓰므로 여기 들어간 Windows 의존은 크로스플랫폼 이관을 막는다. -OS별 경로는 `Platform/CorePaths.cs` 를 통한다. - -## 외부 프로세스 (ffmpeg 등) - -`Platform/ProcessModels.cs` 의 실행 헬퍼를 쓴다. 직접 `Process.Start` 를 새로 쓰지 않는다. -그 헬퍼가 보장하는 것들을 우회하면 과거 버그가 재발한다: - -- stdout/stderr **동시 드레인** — 안 읽으면 파이프 버퍼가 차서 ffmpeg 가 hang 한다 -- 취소·실패 시 `Kill(entireProcessTree: true)` — 안 하면 좀비 프로세스가 남는다 -- 인자는 배열 전달 — 문자열 연결 금지 -- exit code ≠ 0 이면 예외 — 조용히 넘어가면 실패가 `.ts` 잔재로만 드러난다 - -## 취소 - -모든 장기 작업은 `CancellationToken` 을 끝까지 전달한다. 중간에 삼키지 않는다. -`Parallel.ForEachAsync` 는 `ParallelOptions.CancellationToken` 에 넣는다 -(`Hls/HlsDownloader.cs:77` 패턴). - -## 동시성 - -세그먼트 병렬도는 `AppConfig.Concurrency`, 동시 다운로드 수는 `MaxConcurrentDownloads`(기본 3)로 -제어한다. 하드코딩된 병렬도를 새로 만들지 않는다 — 소켓 고갈로 이어진다. - -## 데이터 경로 - -사용자 데이터는 `CorePaths` 를 통해서만 접근한다. `VD_DATA_DIR` 환경변수 오버라이드가 -E2E 테스트 격리의 공식 후크이므로, 경로를 직접 조립하면 테스트가 실사용자 폴더를 오염시킨다. - -## 서버 - -`Server` 는 App 안에서 호스팅되는 라이브러리다. 포트 0 이면 빈 포트를 자동 할당한다. -LAN 노출 API 이므로 인증·경로 검증을 우회하는 엔드포인트를 추가하지 않는다. diff --git a/.claude/rules/mobile-maui.md b/.claude/rules/mobile-maui.md deleted file mode 100644 index 94ea4ca..0000000 --- a/.claude/rules/mobile-maui.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -paths: - - "VideoDownloader.Mobile/**" - - "VideoDownloader.Mobile.Core/**" ---- - -# MAUI 모바일 영역 규칙 - -## 로직은 Mobile.Core 로 - -`VideoDownloader.Mobile` 은 `net10.0-android`(macOS 에서는 +`net10.0-ios`)이라 -`VideoDownloader.Tests`(net8.0-windows)가 참조할 수 없다. **테스트 가능한 로직은 -`VideoDownloader.Mobile.Core`(net8.0)에 둔다.** UI 프로젝트에는 화면 결선만 남긴다. - -새 기능을 UI 프로젝트에 통째로 넣으면 검증할 방법이 사라진다. - -## 링커 함정 - -Release/링커 트리밍에서 참조가 정적으로 안 보이는 타입은 통째로 잘려나가고, -**런타임 `FileNotFoundException` 으로만** 드러난다. 빌드는 통과한다. - -실제 사례: 모달 `NavigationPage` 가 크래시 → `Xamarin.AndroidX.LocalBroadcastManager` 를 -csproj 에 명시 참조해 해결. 리플렉션·XAML 로만 참조되는 타입을 쓸 때 이 함정을 의심한다. - -## 플랫폼 분기 - -TFM 조건은 `$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'` -형식으로 쓴다(csproj 기존 패턴). Windows 개발머신에는 iOS 워크로드가 없으므로 -iOS TFM 은 macOS 조건부로만 켠다 — 이 조건을 무조건부로 바꾸면 Windows 빌드가 깨진다. - -## 빌드 · 검증 - -```powershell -dotnet build VideoDownloader.Mobile -f net10.0-android -``` - -- MAUI 워크로드가 없으면 실패한다. 없는 환경이면 `Mobile.Core` 만 고치고 - UI 결선은 워크로드가 있는 환경에서 확인하도록 사용자에게 알린다. -- 화면 동작을 바꿨으면 에뮬레이터에 배포해 확인한 내용을 보고에 적는다. - 단위 테스트만으로 "됐다"고 하지 않는다. diff --git a/.claude/rules/tests.md b/.claude/rules/tests.md deleted file mode 100644 index 0d65d0b..0000000 --- a/.claude/rules/tests.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -paths: - - "VideoDownloader.Tests/**" ---- - -# 테스트 영역 규칙 - -## 배치 - -`VideoDownloader.Tests/<영역>/<대상>Tests.cs` — 영역은 -`Browser` · `E2E` · `Infrastructure` · `Mobile` · `Platform` · `Server`. -새 영역을 만들기 전에 기존 영역에 맞는지 먼저 본다. - -## 격리 (필수) - -사용자 데이터 경로를 건드리는 테스트는 `VD_DATA_DIR` 을 임시 디렉터리로 지정하고 -끝나면 `null` 로 되돌린다. `E2E/MainWindowSmokeE2ETests.cs:17` 이 기준 패턴이다. -이걸 빼면 테스트가 실사용자의 설정·세션·프로필을 덮어쓴다. - -## WPF 테스트 - -테스트 프로세스에는 `App.xaml` 리소스가 없다. WPF 요소를 만드는 테스트는 -`Infrastructure/WpfTestApp.Ensure(dispatcher)` 를 먼저 호출해 테마 사전을 로드한다. -직접 `new Application()` 을 만들지 않는다. - -## 금지 - -- 통과시키려고 테스트를 **삭제하거나 `Skip` 처리하지 않는다.** 회귀를 숨기는 것이다. -- 실제 네트워크에 의존하는 단정을 새로 만들지 않는다. 외부 사이트가 바뀌면 무작위로 깨진다. - (`Cli` 의 `--dashparse` 같은 수동 검증 도구는 예외 — 테스트가 아니다.) -- 시간·순서에 의존하는 `Thread.Sleep` 기반 단정을 쓰지 않는다. - -## 알려진 경고 - -이 프로젝트에 남아 있는 경고: CS8602(null 역참조) 몇 건, xUnit1031(블로킹 대기), -xUnit2013(`Assert.Equal` 로 개수 확인). **개수를 늘리지 않는다.** -새 테스트에서는 `Assert.Single`/`Assert.Empty` 와 `async` 대기를 쓴다. - -## 실행 - -```powershell -dotnet test VideoDownloader.Tests # 전체 (~13초) -dotnet test VideoDownloader.Tests --filter "FullyQualifiedName~<이름>" # 단건 -``` diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 0ddc3d0..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/claude-code-settings.json", - "hooks": { - "SessionStart": [ - { - "matcher": "startup|resume|clear", - "hooks": [ - { - "type": "command", - "command": "bash", - "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/session-brief.sh"], - "timeout": 30, - "statusMessage": "저장소 상태 브리핑…" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "bash", - "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-on-stop.sh"], - "timeout": 420, - "statusMessage": "dotnet test 검증 중…" - } - ] - } - ] - }, - "permissions": { - "allow": [ - "Bash(dotnet build *)", - "Bash(dotnet test *)", - "Bash(dotnet restore *)", - "Bash(dotnet run *)", - "Bash(dotnet format *)", - "Bash(dotnet --*)", - "Bash(git status *)", - "Bash(git diff *)", - "Bash(git log *)", - "Bash(git show *)", - "Bash(git branch *)" - ], - "deny": [ - "Read(.env)", - "Edit(.env)", - "Write(.env)" - ] - } -} diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md deleted file mode 100644 index 7df4259..0000000 --- a/.claude/skills/release/SKILL.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: release -description: VideoDownloader 배포 절차. Windows 설치자(x64/arm64) 생성과 macOS/Linux 원격 빌드머신 빌드를 수행한다. "배포", "설치자 만들어", "릴리스", "publish" 요청에 사용. ---- - -# 배포 - -배포는 되돌리기 어렵다. **각 단계 전에 사용자 확인을 받는다.** 요청받지 않은 배포를 임의로 하지 않는다. - -## 0. 사전 점검 (필수) - -```powershell -dotnet test VideoDownloader.Tests -git status --short -``` - -- 테스트 실패가 있으면 배포하지 않는다. -- 커밋되지 않은 변경이 있으면 사용자에게 알린다. 원격 빌드는 `git archive HEAD` 로 소스를 보내므로 - **커밋되지 않은 변경은 원격 빌드에 반영되지 않는다.** - -## 1. Windows 설치자 - -```powershell -./build/publish.ps1 -Config Release -Version <버전> -``` - -- 하는 일: `VideoDownloader.App` 을 `win-x64`/`win-arm64` self-contained 로 publish (`publish//`) - → Inno Setup 으로 설치자 컴파일 → `out/MediaBrowser-Setup-.exe` -- 필요: **Inno Setup 6** (`winget install --id JRSoftware.InnoSetup -e`). - 없으면 스크립트가 명시적으로 throw 한다. -- 특정 아키텍처만: `-Archs x64` -- arm64 첫 빌드는 yt-dlp/WebView2 런타임 관련으로 실패할 수 있다. 실패 메시지를 그대로 사용자에게 전달한다. -- 완료 시 스크립트가 산출물 크기를 출력한다. **그 출력을 인용해 보고한다.** - -## 2. macOS / Linux 원격 빌드 - -```bash -bash build/remote/build-remote.sh all # 또는 linux | mac -``` - -- 하는 일: `git archive HEAD` 로 현재 커밋 소스를 빌드머신에 SSH 전송 → - `Cli` + `Avalonia` 를 RID별 self-contained publish → `out/remote//artifacts-.tar.gz` 수집 -- 자격 증명은 `.env` 의 `MAC_OS_HOST`/`LINUX_OS_HOST` 등을 쓴다. - **`.env` 내용을 읽어서 출력하거나 로그에 남기지 않는다.** -- 빌드머신이 절전이면 SSH 가 끊긴다. 재연결 후 재시도한다. -- Avalonia 12.1 은 빌드머신에 .NET 10 SDK 가 있어야 한다. 8만 있으면 CS0103 으로 실패한다. - -## 3. 배포 후 - -- 산출물 경로와 크기를 보고한다. -- `out/`, `publish/` 는 gitignore 대상이다. 커밋하지 않는다. -- 릴리스 배포처(GitHub Releases 등)에 올리는 것은 **별도 승인 사항**이다. 임의로 업로드하지 않는다. -- 배포 체계 전반은 `docs/DEPLOYMENT_PLAN.md` 참고. diff --git a/.claude/skills/tdd/SKILL.md b/.claude/skills/tdd/SKILL.md deleted file mode 100644 index a509952..0000000 --- a/.claude/skills/tdd/SKILL.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: tdd -description: VideoDownloader 에서 기능을 추가하거나 버그를 고칠 때 쓰는 RED→GREEN→증거 폐쇄루프 절차. 실패 테스트를 먼저 만들고, 최소 구현으로 통과시키고, 실행 증거까지 남긴다. "기능 추가", "버그 수정", "TDD로", "테스트부터" 같은 작업에 사용. ---- - -# TDD 폐쇄루프 - -이 저장소는 RED→GREEN→증거로 개발해 왔다. 이 순서를 건너뛰지 않는다. - -## 0. 기준선 확보 (건너뛰지 말 것) - -```powershell -dotnet test VideoDownloader.Tests -``` - -지금 몇 개가 통과하는지 **숫자를 기록**한다. 이미 깨져 있다면 그것부터 사용자에게 알린다. -내 변경으로 깨진 것과 원래 깨져 있던 것을 섞지 않기 위해 반드시 먼저 한다. - -## 1. RED — 실패를 먼저 본다 - -고칠 동작을 **재현하는 테스트**를 쓴다. - -- 위치: `VideoDownloader.Tests/<영역>/<대상>Tests.cs` - (영역 = `Browser` · `E2E` · `Infrastructure` · `Mobile` · `Platform` · `Server`) -- 순수 로직이면 `Core`/`Mobile.Core` 쪽으로 밀어 넣어 UI 없이 테스트되게 만든다. - WPF 의존이 꼭 필요하면 `Infrastructure/WpfTestApp.cs` 패턴을 따른다. -- 사용자 데이터 경로를 건드리는 테스트는 `VD_DATA_DIR` 로 격리한다. - -```powershell -dotnet test VideoDownloader.Tests --filter "FullyQualifiedName~<새테스트이름>" -``` - -**실패하는 것을 눈으로 확인한다.** 여기서 통과해 버리면 테스트가 대상을 못 잡고 있는 것이다. -테스트를 고쳐서 진짜 실패하게 만든 다음 진행한다. - -## 2. GREEN — 최소 구현 - -- 테스트를 통과시키는 가장 작은 변경만 한다. 겸사겸사 리팩터링하지 않는다. -- 에러를 삼키지 않는다. 빈 `catch`, 가짜 폴백 금지. 근본 원인을 고친다. -- 거대 파일(`MainWindow.xaml.cs` 86KB)은 Grep 으로 위치를 찾아 필요한 구간만 읽고 고친다. - -```powershell -dotnet test VideoDownloader.Tests -``` - -**전체**가 통과해야 한다. 기준선보다 테스트 수가 줄면 무언가를 지운 것이다 — 되돌린다. - -## 3. 증거 — 테스트로 부족한 것 - -UI·플랫폼 동작을 바꿨다면 테스트 GREEN 만으로는 "된다"는 근거가 아니다. - -| 바꾼 것 | 확인 방법 | -|---|---| -| WPF 화면·상호작용 | `dotnet run --project VideoDownloader.App` 로 실제 창을 띄워 확인 | -| WebView2 감지·쿠키 | 실제 사이트를 열어 미디어가 목록에 잡히는지 확인 | -| 서버 API | 앱 기동 후 해당 엔드포인트 호출 | -| MAUI 화면 | Android 에뮬레이터에 배포해 확인 | -| 다운로드 엔진 | `dotnet run --project VideoDownloader.Cli -- <검증 옵션>` | - -무엇을 어떻게 확인했는지 보고에 쓴다. "확인했습니다"만 쓰지 말고 관측한 내용을 쓴다. - -## 4. 마무리 - -- `docs/BACKLOG.md` 해당 항목 `- [ ]` → `- [x]` -- 계획 대비 구현 상태가 바뀌었으면 `docs/IMPLEMENTATION_AUDIT.md` 갱신 -- 보고에는 **실제 테스트 출력의 숫자**를 인용한다 (예: "164/164 통과"). 기억으로 쓰지 않는다. - -## 막혔을 때 - -- 원인을 모르겠으면 추측으로 코드를 바꾸지 말고, 먼저 재현 범위를 좁히는 테스트를 더 쓴다. -- 그래도 안 되면 무엇을 시도했고 무엇이 관측됐는지 정리해 사용자에게 보고한다. - 통과하지 못한 것을 통과한 것처럼 쓰지 않는다. diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md deleted file mode 100644 index 7a0daa4..0000000 --- a/.claude/skills/verify/SKILL.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -name: verify -description: VideoDownloader 변경사항이 완료 기준(Definition of Done)을 만족하는지 전수 점검한다. 빌드·테스트·경고·문서 동기화·산출물 오염까지 확인하고 통과/미통과를 판정한다. "다 됐나 확인", "검증해줘", "완료 기준 점검", 작업을 마무리하기 직전에 사용. ---- - -# 완료 검증 - -작업을 "완료"로 보고하기 전에 아래를 순서대로 실행하고, 각 항목의 **실제 출력**을 근거로 판정한다. -하나라도 실패하면 완료가 아니다. 고치거나, 못 고치는 이유를 명시한다. - -## 1. 테스트 - -```powershell -dotnet test VideoDownloader.Tests -``` - -- 실패 0건인가? -- 통과 수가 작업 전보다 줄지 않았는가? (현재 기준선 164) -- 새로 추가한 동작을 실제로 검증하는 테스트가 있는가? 없으면 지금 쓴다. - -## 2. 빌드 · 경고 - -```powershell -dotnet build VideoDownloader.App -``` - -- 오류 0인가? -- **내가 새로 만든 경고가 있는가?** 기존 경고(`MainWindow.xaml.cs`·테스트의 CS8602 몇 건, - xUnit 분석기 경고)는 알려진 것이다. 개수가 늘었으면 내가 늘린 것이다 — 고친다. - -모바일을 건드렸다면 (MAUI 워크로드가 설치돼 있을 때): - -```powershell -dotnet build VideoDownloader.Mobile -f net10.0-android -``` - -## 3. 실행 증거 - -UI·플랫폼 동작을 바꿨는데 실행해 보지 않았다면 여기서 멈추고 실행한다. -`/tdd` 스킬의 "3. 증거" 표를 따른다. 무엇을 관측했는지 기록한다. - -## 4. 저장소 위생 - -```powershell -git status --short -``` - -- 산출물(`out/`, `publish/`, `bin/`, `obj/`)이 변경 목록에 보이는가? → `.gitignore` 가 뚫린 것이다. -- `.env` 가 보이는가? → **즉시 멈추고 사용자에게 알린다.** 실제 자격 증명이 들어 있다. -- 의도하지 않은 파일이 섞였는가? - -```powershell -git diff --stat -``` - -- 변경 규모가 작업 범위와 맞는가? 요청하지 않은 리팩터링이 섞이지 않았는가? - -## 5. 문서 동기화 - -- `docs/BACKLOG.md` — 완료 항목 체크박스 반영했는가? -- `docs/IMPLEMENTATION_AUDIT.md` — 계획 대비 구현 상태가 바뀌었으면 갱신했는가? -- 명령·구조·규약이 바뀌었으면 `AGENTS.md` 와 `README.md` 도 갱신했는가? - -## 6. 판정 보고 - -아래 형식으로 사용자에게 보고한다. **실제 출력에서 숫자를 인용한다.** - -``` -검증 결과 -- 테스트: <통과>/<전체> 통과, 실패 건 -- 빌드: 오류 , 신규 경고 -- 실행 증거: <무엇을 어떻게 확인했는지 / 해당 없음> -- 저장소: 변경 건, 산출물 오염 없음 -- 문서: <갱신한 파일 / 갱신 불필요> -판정: 완료 | 미완료(사유: …) -``` - -미통과 항목을 숨기거나 완곡하게 쓰지 않는다. diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..0b60d5c --- /dev/null +++ b/.cursorrules @@ -0,0 +1,3 @@ +# Cursor AI Rules +# Single Source of Truth: AGENTS.md (Linux Foundation AAIF Standard) +@AGENTS.md diff --git a/.env.example b/.env.example index 1eba5e4..8550ca5 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,11 @@ # 이 파일을 .env 로 복사한 뒤 실제 값을 채운다. .env 는 git 에 커밋되지 않는다(.gitignore). # 값에 따옴표는 붙이지 않는다. -# ── Cloudflare Pages (다운로드 사이트 배포) ────────────────────────── +# ── Cloudflare Pages / DNS (다운로드 사이트 배포 & 도메인 관리) ───────── # https://dash.cloudflare.com/profile/api-tokens → Create Token -# 권한: Account → Cloudflare Pages → Edit (+ Zone → Cache Purge) +# 권한: Account → Cloudflare Pages → Edit +# Zone → DNS → Edit (특정 영역: chanpaca.net) +# (선택) Zone → Cache Purge → Purge CLOUDFLARE_API_TOKEN= CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_PAGES_PROJECT= @@ -31,9 +33,29 @@ LINUX_OS_PASSWORD= # GITHUB_REPO=owner/videodownloader # ---- Forgejo(git 원격 서버) ---- -# 로컬 네트워크 Git 서버(Synology NAS/Forgejo). git push 자동화에 사용. -# FORGEJO_URL=http://192.168.0.39:3000 +# 로컬/외부 Git 서버(Synology NAS/Forgejo). git push 자동화 및 릴리스 관리에 사용. +# FORGEJO_URL=https://git.chanpaca.net # FORGEJO_USER= # FORGEJO_PASSWORD= # (선택) 개인 액세스 토큰 — 값이 있으면 비밀번호 대신 우선 사용 # FORGEJO_TOKEN= + +# ── Google AdMob / Desktop Ad Units (PACA) ─────────────────────────── +# Google AdMob 앱 ID (PACA Android) +# ADMOB_APP_ID=ca-app-pub-1039714767792854~5680462775 + +# 모바일 Android 하단 배너 (PACA Bottom Banner) +# ADMOB_BANNER_ID=ca-app-pub-1039714767792854/3110491400 + +# 데스크톱 다운로드 패널 배너 (PACA Desktop Banner) +# ADMOB_DESKTOP_BANNER_ID=ca-app-pub-1039714767792854/1521297311 + +# 데스크톱 새 탭 페이지 배너 (PACA Desktop NTP Banner) +# ADMOB_DESKTOP_NTP_BANNER_ID=ca-app-pub-1039714767792854/4387520844 + +# Google 공식 웹/디스플레이 테스트 태그 (Google Publisher Tag / Ad Manager) +# GOOGLE_AD_MANAGER_TEST_SLOT=/6499/example/banner + +# 테스트 광고 강제 활성화 여부 +# ADMOB_FORCE_TEST_ADS=true +# ADMOB_USE_PRODUCTION_ADS=false diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 09502a7..3da3095 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -16,7 +16,7 @@ env: DOTNET_CLI_TELEMETRY_OPTOUT: "true" # 릴리스 자산은 공개 배포 저장소로 업로드한다(소스 저장소는 비공개라 익명 다운로드 불가). # draft 상태는 익명에게 보이지 않으므로 QA 후 수동 Publish 흐름은 그대로다. - RELEASE_REPO: "Video-Downloader/releases" + RELEASE_REPO: "Paca/releases" jobs: # ── 관문: 전 플랫폼 빌드 전에 단위+E2E 통과 ── @@ -39,7 +39,7 @@ jobs: - name: 환경 정보 run: dotnet --list-sdks - name: dotnet test - run: dotnet test VideoDownloader.Tests -c Release + run: dotnet test tests/Paca.Tests -c Release # ── Windows 설치자 (x64 / arm64 교차 publish + Inno Setup) ── windows: @@ -74,7 +74,7 @@ jobs: run: ./build/publish.ps1 -Config Release -Version $env:APP_VERSION - name: 릴리스 업로드 env: { RELEASE_TOKEN: "${{ secrets.RELEASE_TOKEN }}" } - run: ./build/ci/upload-release-asset.ps1 -Tag $env:RELTAG -Files out/MediaBrowser-Setup-x64.exe, out/MediaBrowser-Setup-arm64.exe + run: ./build/ci/upload-release-asset.ps1 -Tag $env:RELTAG -Files out/Paca-Setup-x64.exe, out/Paca-Setup-arm64.exe # ── Android APK (키스토어 시크릿 없으면 디버그 서명 APK) ── android: @@ -120,16 +120,16 @@ jobs: } else { Write-Host "ANDROID_KEYSTORE 시크릿 없음 — 디버그 서명 APK 생성" } # RID 는 CLI 전역 속성이 아니라 csproj 게이트(CiPackArm64Only)로 좁힌다 — 전역 -p:RuntimeIdentifiers 는 # 참조 프로젝트(net8.0 Mobile.Core)까지 전파돼 MSB3030 으로 깨진다(2026-08-17 리허설 실증) - dotnet publish VideoDownloader.Mobile -f net10.0-android -c Release ` + dotnet publish src/Paca.Mobile -f net10.0-android -c Release ` -p:AndroidPackageFormat=apk -p:CiPackArm64Only=true @sign -o out/android if ($LASTEXITCODE -ne 0) { throw "publish 실패" } $apk = Get-ChildItem out/android/*-Signed.apk | Select-Object -First 1 if (-not $apk) { $apk = Get-ChildItem out/android/*.apk | Select-Object -First 1 } if (-not $apk) { throw "APK 산출물 없음" } - Copy-Item $apk.FullName "out/MediaBrowser-$env:APP_VERSION-arm64.apk" + Copy-Item $apk.FullName "out/Paca-$env:APP_VERSION-arm64.apk" - name: 릴리스 업로드 env: { RELEASE_TOKEN: "${{ secrets.RELEASE_TOKEN }}" } - run: ./build/ci/upload-release-asset.ps1 -Tag $env:RELTAG -Files "out/MediaBrowser-$env:APP_VERSION-arm64.apk" + run: ./build/ci/upload-release-asset.ps1 -Tag $env:RELTAG -Files "out/Paca-$env:APP_VERSION-arm64.apk" # ── Linux 셸 (tar.gz self-contained — Cli + Avalonia, x64 네이티브 + arm64 교차) ── linux: @@ -161,13 +161,13 @@ jobs: run: | dotnet --list-sdks for rid in linux-x64 linux-arm64; do - dotnet publish VideoDownloader.Cli -c Release -r "$rid" --self-contained -o "out/cli-$rid" - dotnet publish VideoDownloader.Avalonia -c Release -r "$rid" --self-contained -o "out/avalonia-$rid" - tar -C out -czf "out/mediabrowser-$rid.tar.gz" "cli-$rid" "avalonia-$rid" + dotnet publish src/Paca.Cli -c Release -r "$rid" --self-contained -o "out/cli-$rid" + dotnet publish src/Paca.Avalonia -c Release -r "$rid" --self-contained -o "out/avalonia-$rid" + tar -C out -czf "out/Paca-$rid.tar.gz" "cli-$rid" "avalonia-$rid" done - name: 릴리스 업로드 env: { RELEASE_TOKEN: "${{ secrets.RELEASE_TOKEN }}" } - run: ./build/ci/upload-release-asset.sh "$RELTAG" out/mediabrowser-linux-x64.tar.gz out/mediabrowser-linux-arm64.tar.gz + run: ./build/ci/upload-release-asset.sh "$RELTAG" out/Paca-linux-x64.tar.gz out/Paca-linux-arm64.tar.gz # ── macOS 셸 (tar.gz — arm64 네이티브 + x64 교차. 서명·공증은 D4, Apple Developer Program 후) ── macos: @@ -199,10 +199,10 @@ jobs: run: | dotnet --list-sdks for rid in osx-arm64 osx-x64; do - dotnet publish VideoDownloader.Cli -c Release -r "$rid" --self-contained -o "out/cli-$rid" - dotnet publish VideoDownloader.Avalonia -c Release -r "$rid" --self-contained -o "out/avalonia-$rid" - tar -C out -czf "out/mediabrowser-$rid.tar.gz" "cli-$rid" "avalonia-$rid" + dotnet publish src/Paca.Cli -c Release -r "$rid" --self-contained -o "out/cli-$rid" + dotnet publish src/Paca.Avalonia -c Release -r "$rid" --self-contained -o "out/avalonia-$rid" + tar -C out -czf "out/Paca-$rid.tar.gz" "cli-$rid" "avalonia-$rid" done - name: 릴리스 업로드 env: { RELEASE_TOKEN: "${{ secrets.RELEASE_TOKEN }}" } - run: ./build/ci/upload-release-asset.sh "$RELTAG" out/mediabrowser-osx-arm64.tar.gz out/mediabrowser-osx-x64.tar.gz + run: ./build/ci/upload-release-asset.sh "$RELTAG" out/Paca-osx-arm64.tar.gz out/Paca-osx-x64.tar.gz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a67cd0..0f06560 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: 8.0.x 10.0.x - name: dotnet test - run: dotnet test VideoDownloader.Tests -c Release + run: dotnet test tests/Paca.Tests -c Release # ── Windows 설치자 (x64 / arm64 교차 publish + Inno Setup) ── windows: @@ -52,7 +52,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: win-${{ matrix.arch }} - path: out/MediaBrowser-Setup-${{ matrix.arch }}.exe + path: out/Paca-Setup-${{ matrix.arch }}.exe # ── Linux 셸 (tar.gz self-contained — Avalonia + Cli) ── linux: @@ -72,13 +72,13 @@ jobs: 10.0.x - name: publish (Cli + Avalonia) run: | - dotnet publish VideoDownloader.Cli -c Release -r ${{ matrix.rid }} --self-contained -o out/cli-${{ matrix.rid }} - dotnet publish VideoDownloader.Avalonia -c Release -r ${{ matrix.rid }} --self-contained -o out/avalonia-${{ matrix.rid }} - tar -C out -czf out/mediabrowser-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }} + dotnet publish src/Paca.Cli -c Release -r ${{ matrix.rid }} --self-contained -o out/cli-${{ matrix.rid }} + dotnet publish src/Paca.Avalonia -c Release -r ${{ matrix.rid }} --self-contained -o out/avalonia-${{ matrix.rid }} + tar -C out -czf out/Paca-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }} - uses: actions/upload-artifact@v4 with: name: ${{ matrix.rid }} - path: out/mediabrowser-${{ matrix.rid }}.tar.gz + path: out/Paca-${{ matrix.rid }}.tar.gz # ── macOS 셸 (tar.gz — D4 서명·공증은 Developer ID 시크릿 등록 후 확장) ── macos: @@ -98,14 +98,14 @@ jobs: 10.0.x - name: publish (Cli + Avalonia) run: | - dotnet publish VideoDownloader.Cli -c Release -r ${{ matrix.rid }} --self-contained -o out/cli-${{ matrix.rid }} - dotnet publish VideoDownloader.Avalonia -c Release -r ${{ matrix.rid }} --self-contained -o out/avalonia-${{ matrix.rid }} - tar -C out -czf out/mediabrowser-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }} + dotnet publish src/Paca.Cli -c Release -r ${{ matrix.rid }} --self-contained -o out/cli-${{ matrix.rid }} + dotnet publish src/Paca.Avalonia -c Release -r ${{ matrix.rid }} --self-contained -o out/avalonia-${{ matrix.rid }} + tar -C out -czf out/Paca-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }} # D4: MACOS_P12/KEYCHAIN_PASS/NOTARY_PROFILE 시크릿 등록 시 codesign+notarytool+DMG 스텝 추가 - uses: actions/upload-artifact@v4 with: name: ${{ matrix.rid }} - path: out/mediabrowser-${{ matrix.rid }}.tar.gz + path: out/Paca-${{ matrix.rid }}.tar.gz # ── Android APK (D2 — 서명 키스토어 시크릿 없으면 미서명 APK) ── android: @@ -137,7 +137,7 @@ jobs: else echo "::notice::ANDROID_KEYSTORE 시크릿 없음 — 미서명 APK 생성" fi - dotnet publish VideoDownloader.Mobile -f net10.0-android -c Release \ + dotnet publish src/Paca.Mobile -f net10.0-android -c Release \ -p:AndroidPackageFormat=apk -p:RuntimeIdentifiers=android-arm64 $SIGN_ARGS \ -o out/android - uses: actions/upload-artifact@v4 @@ -160,7 +160,7 @@ jobs: run: dotnet workload install maui-ios - name: publish ipa (배포 인증서/프로비저닝 시크릿 필요) run: | - dotnet publish VideoDownloader.Mobile -f net10.0-ios -c Release \ + dotnet publish src/Paca.Mobile -f net10.0-ios -c Release \ -p:ArchiveOnBuild=true -p:CodesignKey="${{ secrets.IOS_SIGNING_KEY }}" \ -p:CodesignProvision="${{ secrets.IOS_PROVISION }}" -o out/ios - uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index fe8d4bf..a81a5ba 100644 --- a/.gitignore +++ b/.gitignore @@ -75,19 +75,39 @@ packages/ !.vscode/launch.json !.vscode/tasks.json -# ── Claude Code (프로젝트 공용 설정은 추적, 로컬/생성물만 제외) ── -# settings.json · hooks/ · rules/ · skills/ 는 팀 공유 자산이므로 커밋한다. docs/HARNESS.md 참고. -.claude/settings.local.json -.claude/.verify-stamp +# ── AI 어시스턴트 & 로컬 메타데이터 ──────────────────────────────── +.claude/ +.cursor/ CLAUDE.local.md +.gemini/ +agy-gui-launch.log +launch.ps1 -# ── OS (macOS · Linux 원격 빌드머신 포함) ─────────────────────────── +# ── OS 및 임시/백업 파일 (macOS · Linux 원격 빌드머신 포함) ─────────── .DS_Store ._* Thumbs.db desktop.ini +*.tmp +*.bak +*~ +*.swp +*.swo +*.pid +crash.dmp -# ── 배포 사이트 산출물 (deploy-site 가 배포 시점에 생성) ───────────── +# ── 배포 사이트 산출물 & 스크래치 파일 ──────────────────────────── site/releases.json .playwright-mcp/ .wrangler/ +icon-candidates.html +site/icon-candidates.html +site/assets/icon_candidates/ +scratch/ +scratch_* +mobile_*.png +paca_live*.png +disp*.png +current_*.png +anr_temp.txt +.design-ref/ diff --git a/AGENTS.md b/AGENTS.md index b2136af..5fb4a63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,90 +1,115 @@ -# AGENTS.md — VideoDownloader 에이전트 작업 계약 +# AGENTS.md — Paca 에이전트 작업 계약 (SSOT) -이 파일은 도구 중립 표준(AGENTS.md)이다. Claude Code · Codex · Cursor 등 어떤 코딩 에이전트든 -작업 시작 시 이 파일을 읽고, 여기 적힌 명령·규약·완료 기준을 따른다. -Claude Code 는 `CLAUDE.md` 가 이 파일을 `@AGENTS.md` 로 임포트해 함께 읽는다. +> 모든 AI 코딩 에이전트(AGY, Claude Code, DeepSeek, Cursor 등) 공용 표준 계약. +> Claude Code는 `CLAUDE.md`의 `@AGENTS.md`, Cursor는 `.cursorrules`로 본 파일을 참조한다. +> 이론적 토대 및 상세 표준: `docs/TDD_THEORY_SSOT.md` (Paca TDD & 디자인 감사 SSOT). -## 1. 프로젝트 개요 +--- -브라우저 내장 데스크톱 영상 다운로더 + 모바일 컴패니언. 앱 내 브라우저가 재생 중인 미디어 -(HLS/DASH/MP4)를 감지 → 쿠키·Referer 를 물려받아 다운로드 → 내장 서버로 휴대폰에 스트리밍/전송. +## 0. TDD 절대주의 헌법 (The Supreme Law of TDD) -| 프로젝트 | TFM | 역할 | -|---|---|---| -| `VideoDownloader.Core` | net8.0 | 다운로드 엔진 (HLS/DASH 파싱, AES-128, 병렬·이어받기, 감지, ffmpeg 리먹스) | -| `VideoDownloader.Browser` | net8.0-windows | WebView2 탭 컴포넌트 | -| `VideoDownloader.App` | net8.0-windows | 메인 WPF 앱 (브라우저 + 다운로드 UI + 서버 호스팅) | -| `VideoDownloader.Server` | net8.0 | ASP.NET 라이브러리. App 안에서 기동, 모바일/웹에 API 제공 | -| `VideoDownloader.Mobile` | net10.0-android (+ios on macOS) | MAUI 컴패니언 | -| `VideoDownloader.Mobile.Core` | net8.0 | 모바일 공용 로직 (UI 없이 테스트 가능하게 분리) | -| `VideoDownloader.Avalonia` | net8.0 | macOS/Linux 셸 (진행 중) | -| `VideoDownloader.Cli` | net8.0 | 엔진 검증용 개발 도구 | -| `VideoDownloader.Tests` | net8.0-windows | xUnit. Mobile 을 제외한 전 영역 + E2E | +1. **테스트 없는 코드는 존재하지 않는다 (No Production Code Without RED)**: + - 실패하는 자동화 테스트(RED)가 존재하지 않는 한, 단 한 줄의 프로덕션 코드도 추가하거나 수정할 수 없다. + - Robert C. Martin의 TDD 3대 법칙(Three Laws)을 엄수한다: 실패를 증명할 최소 테스트 작성 → 컴파일 에러를 포함한 실패 확인 → 오직 통과만을 위한 최소 구현. +2. **기계적 검증 없는 디자인은 결함이다 (Strict Design Audit in RED)**: + - 모든 UI/UX 요소는 눈대중이 아닌 기계적 테스트 게이트(`VisualDesignGateTests`, `IconGlyphDesignSystemTests`, `UiContractUseCasesMegaPipelineTests`)로 사전에 RED화되고 검증되어야 한다. +3. **RED 체계의 수명주기 관리 (Pruning & Scenario Evolution)**: + - 무의미한 RED 남발(Red Inflation)을 금지한다. + - 사소한 단편 단정문은 과감히 추리거나, 실제 사용자 행동(입력창 타이핑, 버튼 클릭, 옵션 변경, 진행률 표시)이 결합된 **복합 시나리오(End-to-End User Journey)**로 통폐합한다. -## 2. 명령 (이 명령들이 진실이다 — 추측하지 말 것) +--- + +## 1. 아키텍처 매핑 (Pitchfork Layout: `src/` + `tests/`) + +| 프로젝트 | TFM | 위치 | 역할 | +|---|---|---|---| +| `Paca.Core` | net8.0 | `src/Paca.Core` | 다운로드 엔진 (HLS/DASH, AES-128, 병렬/이어받기, ffmpeg) | +| `Paca.Browser` | net8.0-windows | `src/Paca.Browser` | WebView2 탭 컴포넌트, 브라우저 확장 프로그램 가로채기 | +| `Paca.App` | net8.0-windows | `src/Paca.App` | 메인 WPF 데스크톱 앱 (UI + 서버 호스팅, 반응형 테마) | +| `Paca.Server` | net8.0 | `src/Paca.Server` | 내장 ASP.NET Core API 서버 | +| `Paca.Mobile` / `.Mobile.Core` | net10.0-android / net8.0;net10.0 | `src/Paca.Mobile` / `src/Paca.Mobile.Core` | MAUI 컴패니언 앱 및 공용 로직 | +| `Paca.Avalonia` | net8.0 | `src/Paca.Avalonia` | macOS/Linux 셸 | +| `Paca.Cli` | net8.0 | `src/Paca.Cli` | CLI 도구 | +| `Paca.Tests` | net8.0-windows | `tests/Paca.Tests` | xUnit 전체 테스트 (1,886+ 건 전수 무결성) | + +--- + +## 2. 핵심 실행 명령 ```powershell -dotnet test VideoDownloader.Tests # 전체 검증. 증분 ~13초, 현재 180/180 통과 -dotnet build VideoDownloader.App # 데스크톱 빌드 -dotnet run --project VideoDownloader.App # 데스크톱 실행 -dotnet build VideoDownloader.Mobile -f net10.0-android # 모바일 (MAUI 워크로드 필요) -./build/publish.ps1 -Config Release -Version 1.0.0 # Windows 설치자 → out/ -bash build/remote/build-remote.sh all # macOS/Linux 원격 빌드 → out/remote/ +dotnet test tests/Paca.Tests # 전체 검증 (1,886건 전수 PASS) +dotnet test tests/Paca.Tests --filter "FullyQualifiedName~<이름>" # 단건 검증 +dotnet build src/Paca.App # 데스크톱 빌드 +# GUI 앱 실행 (WMI 세션 분리 — 세션 종료 후 창 유지 필수) +Stop-Process -Name 'Paca' -Force -ErrorAction SilentlyContinue +powershell -NoProfile -Command "Start-Process powershell -ArgumentList '-NoProfile','-WindowStyle','Hidden','-ExecutionPolicy','Bypass','-File','C:\Users\encep\.gemini\agy-gui-launch.ps1','-ExePath','D:\workspace\videodownloader\src\Paca.App\bin\Debug\net8.0-windows\win-x64\Paca.exe','-ProcessName','Paca'" +Start-Sleep -Seconds 120 +Get-Content C:\Users\encep\.gemini\agy-gui-launch.log -Tail 3 ``` -- 솔루션 전체 빌드(`dotnet build VideoDownloader.slnx`)는 MAUI 워크로드가 있어야 통과한다. - 워크로드 없이 작업할 땐 프로젝트를 개별 지정한다. -- 단위 테스트 하나만: `dotnet test VideoDownloader.Tests --filter "FullyQualifiedName~HlsMuxerWiring"` -- 환경: Windows 11 · .NET 8/10 SDK 공존 · ffmpeg PATH 필요 · 셸은 PowerShell 7 (Git Bash 도 사용 가능) +--- -## 3. 작업 방식 — TDD 폐쇄루프 +## 3. 안드레 카파시 4대 원칙 (Karpathy Principles) -이 저장소는 **RED → GREEN → 증거** 사이클로 개발해 왔다. 이 순서를 지킨다. +1. **Think Before Coding**: 모호하면 추측 금지. 코드 위치를 확인하고, 3개 이상 파일 수정 시 플랜 수립. +2. **Simplicity First**: 오버엔지니어링 금지. RED를 GREEN으로 바꾸는 최소한의 코드만 작성. +3. **Surgical Changes**: 요청 외 주변 코드·주석·공백 무단 수정 금지. 폭발 반경(Blast Radius) 최소화. +4. **Goal-Driven Closed Loop**: "다 됐다" 선언 금지. 기계적 검증(Exit 0) 통과 전까지 자율 루프 유지. -1. **RED**: 변경 전에 실패하는 테스트를 먼저 쓴다. 실패를 눈으로 확인한다. -2. **GREEN**: 최소 구현으로 통과시킨다. `dotnet test` 전체가 통과해야 한다. -3. **증거**: UI·플랫폼 동작을 바꿨다면 테스트만으로 부족하다. 실제 앱 실행/에뮬레이터/스크린샷으로 - 확인하고, 무엇을 어떻게 확인했는지 보고에 적는다. -4. **문서 동기화**: 계획 대비 구현 상태는 `docs/IMPLEMENTATION_AUDIT.md`, 작업 항목은 - `docs/BACKLOG.md` 체크박스에 반영한다. +--- -## 4. 완료 기준 (Definition of Done) +## 4. 저가형 모델(Flash/DeepSeek/Lite) 하네스 수칙 -아래를 모두 만족하기 전에는 "완료"라고 보고하지 않는다. +- **Token Diet**: 80KB+ 대용량 파일 전체 읽기 금지. `grep_search`로 위치 확인 후 라인 슬라이스(`view_file`). +- **Anti-Cheating Guard**: 테스트 삭제, `[Fact(Skip="...")]`, 빈 `catch { }` 엄격 금지 (위반 시 즉시 실패). +- **Error Hill-Climbing**: 컴파일 에러(`CSxxxx`) 및 단정 실패 diff의 파일:라인을 1~2턴 내 정밀 타격. +- **Subagent Hierarchy**: 넓은 범위 탐색/조사는 초경량 서브에이전트(`invoke_subagent`)로 격리. -- [ ] `dotnet test VideoDownloader.Tests` — **실패 0건**. 테스트 수는 줄지 않는다(180 이상). -- [ ] 빌드 **오류 0**. 새로 만든 경고를 남기지 않는다. - (기존 경고: `MainWindow.xaml.cs`·테스트의 CS8602 몇 건, xUnit 분석기 경고 — 늘리지 말 것) -- [ ] 동작을 바꿨으면 그 동작을 검증하는 테스트가 있다. -- [ ] UI/플랫폼 변경이면 실행 증거가 있다. -- [ ] 관련 문서(`docs/`)를 갱신했다. +--- -실패한 것을 통과한 것처럼 보고하지 않는다. 막혔으면 막혔다고 쓴다. +## 5. TDD 폐쇄 루프 & 초고강도 디자인 감사 완료 기준 (DoD) -## 5. 코드 규약 +### 5.1 25대 디자인/인터랙션/접근성 감사 게이트 (Strict Design & Interaction Gate) +1. **중앙 정렬 및 시각적 치우침 방지**: 헤더, 툴바, 팝업의 중앙 정렬 요소 오차 0px. +2. **컨테이너 오버플로우 방지**: 가로 스크롤 버그(`scrollWidth > clientWidth`) 금지, `TextTrimming="CharacterEllipsis"` 강제. +3. **WCAG 2.1/2.2 AA/AAA & APCA 지각 대비**: 텍스트-배경 대비 AA 4.5:1, 고대비 AAA 7.0:1, APCA Lc 60+ 엄수. +4. **인풋 필드 규격 및 내부 패딩**: 텍스트 박스 패딩 `Padding="8,6"` 이상, 최소 높이 36px~44px 유지. +5. **아이콘-텍스트 수직 정렬**: `VerticalAlignment="Center"` 및 폰트 베이스라인 정합성 100% 일치. +6. **타이포그래피 계층구조**: H1(24~32px) → H2(18~22px) → Body(13~15px) → Caption(11~12px) 토큰 일치. +7. **Flexbox / Grid 레이아웃 왜곡 방지**: 컬럼 비율 깨짐 및 0픽셀 축소 방지. +8. **클릭 타깃 최소 면적**: 기본 인터랙션 요소 최소 32x32px 이상 확보. +9. **디자인 철학 일치**: Designpaca / Swiss Typography / Modern Minimalist 토큰 일치. +10. **모달 오버레이 무결성**: 딤 오버레이 및 포커스 트랩 보장. +11. **WCAG 2.2 타깃 크기 (SC 2.5.8/2.5.5)**: 24x24px 최소 타깃 엄수 및 간격 원 중첩 방지, 중요 액션 44x44px 준수. +12. **WCAG 2.2 포커스 시인성 (SC 2.4.11/2.4.13)**: 포커스 링 최소 2px 둘레 및 3:1 대비비, 가림(Obscured) 방지. +13. **다크모드 광륜/난시 생리학적 보호**: 완전 검정(#000000) 위 순백(#FFFFFF) 직접 배치 금지, 표면 표고 및 오프화이트(#E2E8F0) 강제. +14. **스위스 8pt/4pt 그리드 불변식**: 마진/패딩/간격 4px 단위 배수 엄수 (7px/9px 등 분수/홀수 픽셀 금지). +15. **마이크로 타이포그래피 안티지터**: 속도/시간/카운터 실시간 갱신 요소 OpenType `Typography.NumeralAlignment="Tabular"`(`tnum`) 강제. +16. **피츠의 법칙 (Fitts's Law) 가장자리 무한 타깃**: 창 캡션 단추 모서리 밀착(마진 0), 주 액션 CTA 타깃 면적 최적화. +17. **힉-하이먼 & 밀러의 법칙 인지 밀도**: 툴바 단일 계층 버튼 $7 \pm 2$개 제한, 서랍(Drawer) 위계화 및 3단 카드 청크 분할. +18. **WCAG 2.2 드래그 대안 (SC 2.5.7)**: 드래그 탭/큐 이동에 대한 단일 클릭 컨텍스트 메뉴 및 키보드 단축키 대안 보증. +19. **WCAG 2.2 중복 입력 방지 (SC 3.3.7)**: 히스토리 피커, 클립보드 자동 인식, DPAPI CredentialVault 자동 채움 지원. +20. **Microsoft UIA FastPass 스크린 리더 계약**: 모든 인터랙티브 컨트롤 `AutomationProperties.Name` 매핑 및 장식 요소 배제. +21. **윈도우 고대비 모드(WHCM / Forced Colors) WCAG AAA 7.0:1**: 시맨틱 브러시 7.0:1 초고대비 보증. +22. **전정기관 보호 움직임 감소(Reduced Motion SC 2.3.3)**: 애니메이션 지속시간 상한(<=300ms) 및 무한 모션 배제. +23. **포커스 비가림(Focus Not Obscured SC 2.4.11/2.4.12)**: 스크롤 및 고정 요소에 의한 포커스 요소 가림 원천 차단. +24. **모달 포커스 트랩 및 Esc 안전 복원**: 팝업/모달 포커스 순환 및 원래 호출 트리거로 포커스 복원. +25. **게슈탈트 근접성 및 최적 행폭(CPL 50~75) 독서 인체공학**: 안구 피로 방지 MaxWidth 제약 및 여백 기반 시각 청킹. -- **C#**: file-scoped namespace, 4스페이스, Allman 중괄호, `_camelCase` private 필드, nullable enable. - 세부는 `.editorconfig` 가 강제한다. 공통 빌드 속성은 `Directory.Build.props`. -- **주석·커밋 메시지·문서는 한국어.** 기존 코드가 한국어 주석이므로 섞지 않는다. -- **테스트**: xUnit. 파일은 `VideoDownloader.Tests/<영역>/<대상>Tests.cs`. - 영역 = `Browser` · `E2E` · `Infrastructure` · `Mobile` · `Platform` · `Server`. -- 에러를 삼키지 않는다. 근본 원인을 고친다. 빈 `catch`, 가짜 폴백 데이터 금지. +### 5.2 자율 완료 체크리스트 (Definition of Done) +- [ ] `dotnet test tests/Paca.Tests` 실패 0건, 건너뜀 0건 (1,886 전수 통과) +- [ ] 빌드 에러 0건, 신규 컴파일러 경고 0건 +- [ ] 초고강도 디자인 감사 게이트(`VisualDesignGateTests`) 100% 통과 +- [ ] WMI 분리 런처 기반 GUI 실창 렌더링 검증 완료 (`MainWindowHandle != 0`) +- [ ] 문서 동기화 완료 (`docs/TDD_THEORY_SSOT.md`, `docs/IMPLEMENTATION_AUDIT.md`, `docs/BACKLOG.md`) -## 6. 경계 — 하지 말 것 +--- -- `.env` 를 커밋하거나 그 값을 출력·로그·문서에 남기지 않는다. 실제 Cloudflare 토큰과 - 빌드머신 SSH 비밀번호가 들어 있다. 템플릿은 `.env.example` 만 추적한다. -- `out/`, `publish/`, `bin/`, `obj/` 는 산출물이다. 커밋하지 않고, 내용을 소스로 참조하지 않는다. -- `VideoDownloader.App/Assets/*.min.js`(hls/dash/readability)는 서드파티 번들이다. 수정 금지. -- 사용자가 요청하지 않은 커밋·푸시·배포를 하지 않는다. -- 대규모 리팩터링을 임의로 시작하지 않는다. `MainWindow.xaml.cs` 는 86KB 로 비대하지만, - 분할은 별도 승인 후 진행한다. +## 6. 절대 금기 (Boundaries) -## 7. 알아둘 함정 - -- **WebView2**: 탭마다 인스턴스, Environment 는 공유. dispose 순서를 어기면 UI 스레드에서 죽는다. -- **MAUI**: 링커가 안 싣는 타입은 런타임 `FileNotFoundException` 으로만 드러난다 - (실제 사례: 모달 NavigationPage → `Xamarin.AndroidX.LocalBroadcastManager` 명시 참조로 해결). -- **ffmpeg**: stderr 를 읽지 않으면 파이프 버퍼가 차서 hang 한다. 취소 시 `Kill(entireProcessTree)`. -- **Avalonia 12.1** 은 .NET 10 SDK 를 요구한다. 8만 있으면 CS0103 으로 실패한다. -- 테스트는 `VD_DATA_DIR` 로 사용자 데이터 경로를 격리한다. 실제 사용자 폴더를 건드리지 않는다. +- `.env` 커밋/노출 절대 금지 (Cloudflare 토큰, SSH 암호 보호). +- `Paca.App/Assets/*.min.js` 서드파티 번들 수정 금지. +- 사용자 승인 없는 임의 커밋/푸시/배포 금지. +- WebView2: dispose 후 이벤트 콜백 방어 (`null` 체크 + `try`). +- 외부 프로세스: `ProcessModels.cs` 헬퍼 필수 (stderr 동시 드레인 + 전체 프로세스 트리 킬). +- 테스트 격리: 사용자 경로 접근 시 `VD_DATA_DIR` 환경변수 필수. 외부 제3자 서비스 장애에 깨지지 않도록 테스트 더블/오프라인 피스처 방어 구축. diff --git a/CLAUDE.md b/CLAUDE.md index 9ccf6c0..a89bcf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,38 +1,25 @@ @AGENTS.md -# Claude Code 전용 지침 +# Claude Code 전용 바인딩 -위 계약(AGENTS.md)이 기본이다. 아래는 이 저장소에서 Claude Code 가 쓰는 하네스 설명이다. -전체 설계와 운영법은 `docs/HARNESS.md` 에 있다. +`AGENTS.md`가 진실의 원천(SSOT)이다. 아래는 Claude Code 전용 훅 및 스킬 바인딩이다. -## 자동으로 도는 검증 (내가 끌 수 없는 층) +## 1. 자동 검증 훅 -- **Stop 훅** — 턴을 끝내려 할 때 소스가 바뀌었으면 `dotnet test` 를 돌린다. - 실패하면 종료가 차단되고 실패 내용이 돌아온다. **테스트를 깨둔 채로 턴을 끝낼 수 없다.** - 차단되면 변명하지 말고 고친다. 고칠 수 없으면 왜 못 고치는지 사용자에게 보고한다. -- **SessionStart 훅** — 세션 시작 시 브랜치·변경 파일·마지막 검증 상태를 컨텍스트에 넣는다. - 이 브리핑을 먼저 읽고 지금 저장소가 어떤 상태인지 파악한 뒤 움직인다. +- **Stop 훅**: 턴 종료 시 소스 변경 감지 → `dotnet test` 자동 실행. 실패 시 종료 차단 (`exit 2`). +- **SessionStart 훅**: 브랜치, 변경 파일, 백로그 상태 자동 브리핑. -훅 때문에 작업이 막히면 훅을 우회하거나 무력화하지 않는다. 원인을 고친다. +## 2. 절차 스킬 (On-Demand) -## 스킬 — 절차는 여기 있다 - -| 스킬 | 언제 | +| 스킬 | 사용 시점 | |---|---| -| `/tdd` | 기능 추가·버그 수정. RED→GREEN→증거 사이클을 끝까지 진행 | -| `/verify` | "다 됐나?" 확인. 빌드+테스트+문서 동기화까지 완료 기준 전체 점검 | -| `/release` | Windows 설치자 / 원격 크로스플랫폼 빌드 | +| `/tdd` | 기능 추가 및 버그 수정 (RED → GREEN → 증거 사이클) | +| `/verify` | 완료 기준 전수 점검 (빌드 + 테스트 + 문서 동기화) | +| `/release` | Windows 설치자 및 원격 크로스플랫폼 빌드 | -절차를 기억에 의존해 재구성하지 말고 해당 스킬을 부른다. +## 3. 핵심 규칙 -## 이 저장소에서의 작업 태도 +- **한국어로 응답** (코드 식별자/기술 용어는 원문 유지). +- **대형 파일 토큰 절약**: `MainWindow.xaml.cs`(86KB) 통째 읽기 금지, Grep + 슬라이스 조회. +- **치팅 금지**: 테스트 삭제/Skip 금지. 실패 시 실제 출력 숫자를 인용하여 보고. -- **한국어로 답한다.** 코드 식별자와 기술 용어는 원문 유지. -- **계획이 필요한 크기면 플랜 모드**를 먼저 쓴다. 기준: 파일 3개 이상 변경, 또는 공개 API· - 스레딩·dispose 순서 변경, 또는 어디를 고쳐야 할지 아직 모를 때. -- **탐색은 서브에이전트에게** 맡긴다. "이 기능 어디 있지" 류의 넓은 검색은 Explore 로 위임하고 - 결론만 받는다. 파일 위치를 이미 아는 단건 확인은 직접 한다. -- **거대 파일 주의**: `MainWindow.xaml.cs`(86KB)·`MainWindow.xaml`(55KB)은 통째로 읽지 말고 - Grep 으로 위치를 찾아 필요한 구간만 Read 한다. -- **테스트를 지우거나 `Skip` 으로 무력화해서 GREEN 을 만들지 않는다.** 그건 회귀를 숨기는 것이다. -- 작업 결과를 보고할 때 테스트 수를 실제 출력에서 인용한다. 기억으로 쓰지 않는다. diff --git a/Directory.Build.props b/Directory.Build.props index b6a39ea..c86f15b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,6 +13,14 @@ false + + 65001 + + + Paca + Paca + Paca Team + Copyright © 2026 Paca true diff --git a/Paca.slnx b/Paca.slnx new file mode 100644 index 0000000..243d1c0 --- /dev/null +++ b/Paca.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/VideoDownloader.App/BookmarkManagerWindow.xaml b/VideoDownloader.App/BookmarkManagerWindow.xaml deleted file mode 100644 index e9d5a9a..0000000 --- a/VideoDownloader.App/BookmarkManagerWindow.xaml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/VideoDownloader.App/MainWindow.xaml.cs b/VideoDownloader.App/MainWindow.xaml.cs deleted file mode 100644 index 869e91b..0000000 --- a/VideoDownloader.App/MainWindow.xaml.cs +++ /dev/null @@ -1,2214 +0,0 @@ -using System.Collections.ObjectModel; -using System.Net.Http.Headers; -using System.Runtime.InteropServices; -using System.Threading; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Input; -using System.Windows.Interop; -using System.Windows.Automation.Peers; -using System.Windows.Media; -using Microsoft.Web.WebView2.Core; -using VideoDownloader.Browser; -using VideoDownloader.App.Browser; -using VideoDownloader.App.Browser.Features; -using VideoDownloader.Browser.Tabs; -using VideoDownloader.App.Core; -using VideoDownloader.Core.Detection; -using VideoDownloader.App.ViewModels; -using VideoDownloader.Core.Config; -using Microsoft.Win32; -using Wv2 = Microsoft.Web.WebView2.Wpf; -using TabItem = VideoDownloader.Browser.Tabs.TabItem; - -namespace VideoDownloader.App; - -public partial class MainWindow : Window -{ - private readonly DownloadManager _manager; - private AppConfig _config; - private int _totalRequests; - private string _appLogPath = ""; - - // ---- 탭 아키텍처 (Phase A) ---- - private CoreWebView2Environment? _env; - private TabManager _tabs = null!; - internal TabManager Tabs => _tabs; - internal Wv2.WebView2? ActiveView => _tabs?.ActiveTab?.View; - private CoreWebView2? Cv => _tabs?.ActiveTab?.Core; - - private readonly ObservableCollection _bookmarks = new(); - private readonly ObservableCollection _history = new(); - private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지) - - // M0: 원격 접근 서버(모바일 연동) - private Server.ServerHost? _remoteServer; - - // Phase D: 미디어 재생목록 (T2-10) - private readonly MediaPlaylist _playlist = new(); - private PlayerWindow? _player; - - // Phase C: 팔레트/부스트/워크스페이스/레이아웃 - private readonly CommandPalette _palette = new(); - private BoostStore _boostStore = null!; - private WorkspaceRoutingRules _routingRules = null!; // T3-1 - private TabArchive _tabArchive = null!; // T3-2 - internal TabArchive Archive => _tabArchive; // E2E 검증용 - internal BoostStore BoostStore => _boostStore; // E2E 검증용 - internal NotesStore Notes => _notes; // E2E 검증용 - private NotesStore _notes = null!; // T3-12 - private string? _currentWorkspaceName; // T3-1 라우팅 기준 - private bool _compactMode; // T3-13 - private bool _chromeRevealed; // T3-13 호버 복원 상태 - private List _workspaces = new(); - private bool _verticalTabs; - private TabItem? _splitTab; - private System.Speech.Synthesis.SpeechSynthesizer? _speech; - - // Phase B: 다운로드 매니저(T1-5) / 탭 절전(T2-2) - private readonly PageDownloadTracker _pageDownloads = new(); - private System.Windows.Threading.DispatcherTimer? _speedTimer; - private System.Windows.Threading.DispatcherTimer? _suspendTimer; - private TabSuspendService? _suspendService; - private readonly System.Diagnostics.Stopwatch _speedWatch = new(); - - // T1-4 검색엔진 / T1-7 사이트별 줌 / T1-3 페이지 내 검색 / T1-11 전체화면 - private SearchEngineResolver _resolver = null!; - private SiteZoomStore _zoomStore = null!; - private bool _fullscreen; - private WindowState _stateBeforeFullscreen = WindowState.Normal; - - // #15 위장 미디어(image/png 등) magic byte 검증용. 세션당 상한 두어 폭주 방지. - private static readonly HttpClient ProbeClient = new(new HttpClientHandler { AllowAutoRedirect = true }) - { Timeout = TimeSpan.FromSeconds(15) }; - private readonly HashSet _probed = new(); - private int _probeCount; - - // 범용 우측 패널: 접기/펼치기 + 폭 리사이즈 상태(설정에 저장) - private bool _panelCollapsed; - private double _panelExpandedWidth = 420; - private const double CollapsedPanelWidth = 56; - private const double MinExpandedWidth = 280; - private const double MaxExpandedWidth = 900; - - private static string SessionPath => Path.Combine(AppDataDir, "session.json"); - - public MainWindow() - { - InitializeComponent(); - _config = ConfigStore.Load(); - Directory.CreateDirectory(_config.OutputFolder); - _appLogPath = Path.Combine(_config.OutputFolder, "app.log"); - try { File.WriteAllText(_appLogPath, $"=== 시작 {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==={Environment.NewLine}"); } catch { } - _manager = new DownloadManager(_config, GetCookiesAsync, Log); - DataContext = _manager; - - foreach (var b in BrowserStore.LoadBookmarks()) _bookmarks.Add(b); - foreach (var h in BrowserStore.LoadHistory()) _history.Add(h); - BookmarksList.ItemsSource = _bookmarks; - HistoryList.ItemsSource = GroupHistoryByDate(_history); // UC-M20 날짜 묶음 뷰 - - _resolver = new SearchEngineResolver(_config.SearchEngine); - _zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json")); - _boostStore = new BoostStore(Path.Combine(AppDataDir, "boost.json")); - _routingRules = new WorkspaceRoutingRules(Path.Combine(AppDataDir, "routing.json")); // T3-1 - _tabArchive = new TabArchive(Path.Combine(AppDataDir, "archive.json")); // T3-2 - _notes = new NotesStore(Path.Combine(AppDataDir, "notes.json")); // T3-12 - try - { - var wsPath = Path.Combine(AppDataDir, "workspaces.json"); - if (File.Exists(wsPath)) - _workspaces = System.Text.Json.JsonSerializer.Deserialize>(File.ReadAllText(wsPath)) ?? new(); - } - catch { } - _playlist.Load(Path.Combine(AppDataDir, "playlist.json")); - BuildPalette(); - - // UI 복잡 버전 유즈케이스(§A~§J) 초기화 — MainWindow.Usecases.cs - InitializeUsecases(); - - // UC-L11/L12/L26 창 위치·크기·최대화·전체화면 복원 - RestoreWindowBounds(); - - Loaded += async (_, _) => await InitializeBrowserAsync(); - PreviewKeyDown += OnPreviewKeyDown; - PreviewMouseDown += Window_PreviewMouseDown; // UC-K05 마우스 제스처(XButton)·UC-K08 계열 - StateChanged += OnWindowStateChanged; - AddressBox.LostKeyboardFocus += (_, _) => AddressPopup.IsOpen = false; - - // 범용 우측 패널: 저장된 접힘/폭 상태 복원 - _panelExpandedWidth = ClampPanelWidth(_config.RightPanelWidth); - ApplyPanelState(_config.RightPanelCollapsed); - Closing += OnWindowClosing; - } - - // 보더리스: Win11 어두운 타이틀바 + 둥근 모서리(DWMWA_WINDOW_CORNER_PREFERENCE) - protected override void OnSourceInitialized(EventArgs e) - { - base.OnSourceInitialized(e); - try - { - var hwnd = new WindowInteropHelper(this).Handle; - int dark = App.IsLightTheme ? 0 : 1; // DWMWA_USE_IMMERSIVE_DARK_MODE (라이트 테마면 밝게) - DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int)); - int round = 2; // DWMWCP_ROUND - DwmSetWindowAttribute(hwnd, 33, ref round, sizeof(int)); - } - catch { } - } - - private void OnWindowStateChanged(object? sender, EventArgs e) - { - // 최대화 시 모서리 각진(Fluent 표준), 복원 시 둥글게. 캡션 아이콘도 전환. - try - { - var hwnd = new WindowInteropHelper(this).Handle; - int pref = WindowState == WindowState.Maximized ? 0 : 2; // 0=기본(최대화 시 사각) - DwmSetWindowAttribute(hwnd, 33, ref pref, sizeof(int)); - if (MaxRestoreBtn != null) - { - MaxRestoreBtn.Content = WindowState == WindowState.Maximized ? "" : ""; - MaxRestoreBtn.ToolTip = WindowState == WindowState.Maximized ? "복원" : "최대화"; - } - } - catch { } - } - - // ---- 보더리스: 툴바의 빈 공간을 드래그 (버튼/입력창 위는 제외해 클릭 전달) ---- - private void TitleBar_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) - { - // 더블클릭 → 최대화/복원 (네이티브 타이틀바 동작) - if (e.ClickCount >= 2) { MaximizeToggle(); return; } - - // 클릭된 대상(또는 조상)이 버튼/입력창이면 드래그하지 않고 클릭을 그대로 흘려보낸다 - for (var d = e.OriginalSource as DependencyObject; d != null; d = LogicalTreeHelper.GetParent(d) ?? VisualTreeHelper.GetParent(d)) - { - if (d is ButtonBase || d is TextBox) return; - } - if (e.LeftButton == MouseButtonState.Pressed) { try { DragMove(); } catch { } } - } - - private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; - private void MaxRestore_Click(object sender, RoutedEventArgs e) => MaximizeToggle(); - private void Close_Click(object sender, RoutedEventArgs e) => Close(); - - private void MaximizeToggle() - => WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; - - [DllImport("dwmapi.dll")] - private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute); - - /// 앱 데이터 디렉터리(AppPaths.DataDir — VD_DATA_DIR 오버라이드 지원). - internal static string AppDataDir => AppPaths.DataDir; - - internal static string ProfileFolder => Path.Combine(AppDataDir, "webview"); - - // ==================== 탭 생명주기 (Phase A) ==================== - - private async Task InitializeBrowserAsync() - { - try - { - // 명시적 영속 프로필: 기본(exe 디렉터리) 폴더는 빌드/재배포 시 날아가 로그인이 풀림 → %AppData% 로 고정. - Directory.CreateDirectory(ProfileFolder); - _env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: ProfileFolder); - } - catch (Exception ex) { Log($"WebView2 초기화 실패: {ex.Message}"); return; } - - _views = new Services.TabViewService(_env!, _config, TabHost, _zoomStore, Dispatcher) - { - NewTabProvider = () => Task.FromResult(_tabs.NewTab(activate: false)), - }; - _views.TabCoreReady += (tab, cv) => - { - cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지 - WireTabMouseGestures(cv); // UC-K06 휠 클릭 새 탭 - }; - _views.DownloadStarting += OnDownloadStarting; - _views.UrlChanged += OnUrlChanged; - _views.TitleChanged += OnTitleChanged; - _views.HistoryChanged += UpdateNavState; - _views.FindStateChanged += tab => - { - if (tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText; - }; - - _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) - { - MaxTabs = _config.MaxTabs, // UC-B11 탭 상한 - }; - _tabs.ActiveChanged += OnActiveTabChanged; - _tabs.TabRemoved += OnTabRemoved; - _views.TabCoreReady += (tab, cv) => - { - // UC-K15 새 탭 기본 줌 — 사이트별 저장 줌이 없을 때만 적용(T1-7 과 충돌 방지) - try { if (tab.View != null && _zoomStore.Count == 0 && Math.Abs(_config.DefaultZoomFactor - 1.0) > 0.01) tab.View.ZoomFactor = _config.DefaultZoomFactor; } catch { } - }; - TabList.ItemsSource = _tabs.Tabs; - UpdatePrivateButton(); - - // T1-10 세션 복원: 저장된 탭이 있으면 복원, 아니면 홈 탭 - var snap = _config.RestoreSession ? TabSessionStore.Load(SessionPath) : null; - if (snap != null && snap.Urls.Count > 0) - _tabs.RestoreSession(snap); - else - _tabs.NewTab(_config.HomeUrl); - // UC-B12 세션 복원을 잠자기로 — 비활성 탭 뷰를 접어 빠른 시작 - if (_config.RestoreTabsSuspended && _tabs.ActiveTab != null) - foreach (var t in _tabs.Tabs.Where(t => !ReferenceEquals(t, _tabs.ActiveTab)).ToList()) - try { if (t.View != null) t.View.Visibility = Visibility.Collapsed; } catch { } - UpdateNavState(); - - // T1-5 다운로드 속도 갱신 타이머 - DownloadsList.ItemsSource = _pageDownloads.Items; - PlaylistList.ItemsSource = _playlist.Tracks; - PlaylistEmptyHint.Visibility = _playlist.Tracks.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - _speedTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; - _speedTimer.Tick += (_, _) => - { - foreach (var item in _pageDownloads.Items) - item.UpdateSpeed(item.Op.BytesReceived, TimeSpan.FromSeconds(1)); - DownloadsEmptyHint.Visibility = _pageDownloads.Items.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - }; - _speedTimer.Start(); - - // M0: 설정돼 있으면 원격 접근 서버 자동 시작 - if (_config.RemoteAccessEnabled && !string.IsNullOrEmpty(_config.RemoteAccessToken)) - { - try - { - _remoteServer = await Server.ServerHost.StartAsync(new Server.ServerOptions - { - Port = 0, - Token = _config.RemoteAccessToken!, - LibraryFolder = Path.GetFullPath(_config.OutputFolder), - Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher), - }); - Log($"원격 접근 서버 포트 {_remoteServer.Port}"); - UpdateServerStatusIcon(); // §J 서버 상태 아이콘 - } - catch { } - } - - // T2-2 탭 절전: 1분마다 유휴(10분+) 비활성 탭 서스펜드 - _suspendService = new TabSuspendService(TimeSpan.FromMinutes(10), tab => - { - var cv = tab.Core; - if (cv == null || cv.IsSuspended) return false; - if (tab.View?.Visibility != Visibility.Collapsed) return false; // 숨겨진 탭만 서스펜드 가능 - _ = cv.TrySuspendAsync(); - return true; - }, tab => tab.Core != null); - _suspendTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMinutes(1) }; - _suspendTimer.Tick += (_, _) => - { - if (_tabs.ActiveTab != null) - _suspendService.SuspendIdleTabs(_tabs.Tabs, _tabs.ActiveTab, DateTime.UtcNow); - }; - _suspendTimer.Start(); - } - - /// 탭별 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; - ShieldDomainStats.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); - if (tab.IsActive && !AddressBox.IsKeyboardFocused) AddressBox.Text = url; - tab.PendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url; - DetectSocial(tab, url); - } - - private void OnTitleChanged(TabItem tab, string title) - { - tab.Title = string.IsNullOrWhiteSpace(title) ? (tab.Url.StartsWith("http") ? tab.Url : "새 탭") : title; - if (tab.PendingHistoryUrl != null) - { - RecordHistory(string.IsNullOrWhiteSpace(title) ? tab.PendingHistoryUrl : title, tab.PendingHistoryUrl); - tab.PendingHistoryUrl = null; - } - // 소셜 카드 제목을 페이지 타이틀로 갱신(출력 파일명 포함, 시작 전일 때) - if (tab.PendingSocialUrl != null && !string.IsNullOrWhiteSpace(title)) - { - _manager.RenameSocial(new Uri(tab.PendingSocialUrl), title); - tab.PendingSocialUrl = null; - } - } - - private void OnDownloadStarting(CoreWebView2 cv, CoreWebView2DownloadStartingEventArgs de) - { - try - { - var folder = Path.GetFullPath(_config.OutputFolder); - Directory.CreateDirectory(folder); - var fn = Path.GetFileName(de.ResultFilePath); - if (string.IsNullOrWhiteSpace(fn)) fn = $"download_{DateTime.Now:yyyyMMdd_HHmmss}"; - de.ResultFilePath = MakeUniqueDownloadName(folder, fn); - _manager.LogPanel($"📥 페이지 다운로드 → 저장 폴더: {fn}"); - - var item = _pageDownloads.Add(new DownloadOperationAdapter(de.DownloadOperation), fn); // T1-5 - item.UpdateSpeed(0, TimeSpan.FromSeconds(1)); - DownloadsEmptyHint.Visibility = Visibility.Collapsed; - } - catch { } - } - - // ---- T1-5 다운로드 플라이아웃 ---- - private void ToggleDownloads(object sender, RoutedEventArgs e) - { - DownloadsPopup.IsOpen = !DownloadsPopup.IsOpen; - } - - private void DownloadsClear_Click(object sender, RoutedEventArgs e) => _pageDownloads.ClearCompleted(); - - private void DlPause_Click(object sender, RoutedEventArgs e) - => IfDownloadItem(sender, i => _pageDownloads.Pause(i)); - - private void DlResume_Click(object sender, RoutedEventArgs e) - => IfDownloadItem(sender, i => _pageDownloads.Resume(i)); - - private void DlCancel_Click(object sender, RoutedEventArgs e) - => IfDownloadItem(sender, i => _pageDownloads.Cancel(i)); - - private static void IfDownloadItem(object sender, Action act) - { - if ((sender as FrameworkElement)?.DataContext is PageDownloadItem item) act(item); - } - - private void OnActiveTabChanged(TabItem tab) - { - if (_splitTab != null && !ReferenceEquals(tab, _splitTab)) ExitSplit(); // v1: 전환 시 분할 해제 - foreach (var t in _tabs.Tabs) - if (t.View != null) t.View.Visibility = ReferenceEquals(t, tab) ? Visibility.Visible : Visibility.Collapsed; - if (!AddressBox.IsKeyboardFocused) AddressBox.Text = tab.Url; - if (tab.IsSuspended) // T2-2: 절전 탭 활성화 시 재개 - { - try { tab.Core?.Resume(); } catch { } - tab.IsSuspended = false; - } - // UC-K14 현재 탭 줌 배율 표시 갱신 - if (ZoomIndicator != null && tab.View != null) - ZoomIndicator.Content = $"{Math.Round(tab.View.ZoomFactor * 100)}%"; - UpdateNavState(); - } - - private void OnTabRemoved(TabItem tab) - { - if (ReferenceEquals(_splitTab, tab)) ExitSplit(); - _tabArchive.Add(tab.Title, tab.Url); // T3-2 닫힘 → 아카이브(영속, 500 상한) - _views?.RemoveView(tab); - } - - private void UpdateNavState() - { - Dispatcher.BeginInvoke(new Action(() => - { - try - { - var cv = Cv; // dispose 이후 이벤트 경로 방어 - NavBack.IsEnabled = cv?.CanGoBack == true; - NavForward.IsEnabled = cv?.CanGoForward == true; - } - catch (ObjectDisposedException) { } - catch (InvalidOperationException) { } - })); - } - - // ---- 탭 스트립 UI 이벤트 ---- - private void NewTab_Click(object sender, RoutedEventArgs e) => NewTabWithDial(); - - private void Tab_Click(object sender, MouseButtonEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is TabItem tab) - _tabs.Activate(tab); - } - - private void Tab_PreviewMouseDown(object sender, MouseButtonEventArgs e) - { - // 휠 클릭(미들) → 탭 닫기 (브라우저 관례). MiddleButton 상태로 판정. - if (e.MiddleButton == MouseButtonState.Pressed) - { - if ((sender as FrameworkElement)?.DataContext is TabItem tab) - { - _tabs.CloseTab(tab); - e.Handled = true; - } - } - } - - private void TabClose_Click(object sender, RoutedEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is TabItem tab) - _tabs.CloseTab(tab); - } - - // T1-8 스피커 아이콘 클릭 → 음소거 토글 - private void TabAudio_Click(object sender, MouseButtonEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is TabItem { Core: { } cv } tab) - { - try { cv.IsMuted = !cv.IsMuted; tab.IsMuted = cv.IsMuted; } catch { } - } - e.Handled = true; - } - - private void OnResponseReceived(object? sender, CoreWebView2WebResourceResponseReceivedEventArgs e) - { - try - { - var uri = e.Request.Uri ?? ""; - var method = e.Request.Method ?? "GET"; - string ct = "", cl = ""; - int status = 0; - try { ct = e.Response.Headers.GetHeader("Content-Type") ?? ""; } catch { } - try { cl = e.Response.Headers.GetHeader("Content-Length") ?? ""; } catch { } - try { status = e.Response.StatusCode; } catch { } - - int n = Interlocked.Increment(ref _totalRequests); - var ctLow = (ct ?? "").ToLowerInvariant(); - // #8 노이즈 content-type(image/font/css/js/beacon)은 requests.log 기록 스킵 - bool loggable = !ctLow.StartsWith("image/") && !ctLow.StartsWith("font/") - && !ctLow.Contains("/css") && !ctLow.Contains("javascript") - && !ctLow.Contains("beacon") && !ctLow.Contains("ping"); - if (loggable) _manager.AppendAll($"{n}|{status}|{method}|{ct}|{cl}|{uri}"); - - if (status >= 300) return; - - var l = uri.ToLowerInvariant(); - var c = (ct ?? "").ToLowerInvariant(); - long len = long.TryParse(cl, out var lv) ? lv : -1; - // 206(Partial Content) Range 응답은 Content-Length 가 청크 크기라 작게 잡힘 → - // Content-Range 의 전체 크기(/total) 로 보정 (Instagram 등 단일 MP4 Range 스트리밍 감지). - try - { - var cr = e.Response.Headers.GetHeader("Content-Range"); - if (!string.IsNullOrEmpty(cr)) - { - var slash = cr.LastIndexOf('/'); - if (slash >= 0 && long.TryParse(cr.AsSpan(slash + 1), out var total) && total > len) - len = total; - } - } - catch { } - - string referer = (sender as CoreWebView2)?.Source ?? Cv?.Source ?? ""; - try { var r = e.Request.Headers.GetHeader("Referer"); if (!string.IsNullOrEmpty(r)) referer = r; } catch { } - - var kind = MediaDetector.Classify(uri, ct, len, _config); - if (kind == MediaKind.Hls) - { - _manager.LogPanel($"★ HLS 감지: {uri}"); - _manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Hls, ct)); - } - else if (kind == MediaKind.Dash) - { - _manager.LogPanel($"★ DASH 감지: {uri}"); - _manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Dash, ct)); - } - else if (kind == MediaKind.Direct) - { - // #15(b) chunked video/* (len<0) 는 크기 가드를 건너뛰어 토큰 만료 HTML 오류 페이지가 mp4 로 오탐됨 → - // magic-byte probe 로 본문이 실제 미디어인지 재검증(정상 chunked MP4 는 ftyp box 로 통과). - if (c.StartsWith("video/") && len < 0 && Uri.TryCreate(uri, UriKind.Absolute, out var probeUri)) - ProbeMediaAsync(probeUri, referer); - else - { - _manager.LogPanel($"★ 미디어 감지 [{ct}] {FmtLen(len)}: {uri}"); - AddToPlaylist(uri, GuessTitle(uri), MediaKind.Direct.ToString(), referer); - _manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Direct, ct)); - } - } - else - { - // #15 위장 미디어(image/* 또는 octet-stream, 대용량) → magic byte 검증 - long minBytes = _config.MinSizeMB * 1024L * 1024L; - bool disguised = (c.StartsWith("image/") || c.Contains("octet-stream")) && (len < 0 || len > minBytes); - if (disguised && Uri.TryCreate(uri, UriKind.Absolute, out var probeUri)) - ProbeMediaAsync(probeUri, referer); - else if (MediaDetector.LooksLikeCandidate(l, c)) - _manager.LogPanel($"[후보 {method}] {uri}"); - } - } - catch { } - } - - private async void ProbeMediaAsync(Uri uri, string referer) - { - try - { - string key = uri.GetLeftPart(UriPartial.Path); - lock (_probed) - { - if (_probeCount > 40) return; // 세션당 상한 - if (!_probed.Add(key)) return; // 중복 URL 스킵 - _probeCount++; - } - using var req = new HttpRequestMessage(HttpMethod.Get, uri); - req.Headers.Range = new RangeHeaderValue(0, 63); - req.Headers.UserAgent.ParseAdd(_config.UserAgent); - if (!string.IsNullOrEmpty(referer)) { try { req.Headers.Referrer = new Uri(referer); } catch { } } - using var resp = await ProbeClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); - if ((int)resp.StatusCode >= 300) return; - await using var stream = await resp.Content.ReadAsStreamAsync(); - var head = new byte[64]; - int total = 0, m; - while (total < 64 && (m = await stream.ReadAsync(head, total, 64 - total)) > 0) total += m; - var fmt = MediaDetector.ProbeFormat(head.AsSpan(0, total)); - if (fmt != null) - { - _manager.LogPanel($"★ 위장 미디어 감지 (→{fmt}): {uri}"); - _manager.OnDetected(new DetectionContext(uri.ToString(), referer, MediaKind.Direct, "video/" + fmt)); - } - } - catch { } - } - - private static string FmtLen(long len) => len < 0 ? "?" : $"{len / 1048576.0:F1}MB"; - - private async Task> GetCookiesAsync(Uri url) - { - var cv = Cv; - if (cv == null) return new(); - try { return (await cv.CookieManager.GetCookiesAsync(url.ToString())).ToList(); } - catch { return new(); } - } - - private void Log(string s) - { - var line = $"[{DateTime.Now:HH:mm:ss}] {s}"; - try { File.AppendAllText(_appLogPath, line + Environment.NewLine); } catch { } - Dispatcher.BeginInvoke(new Action(() => { StatusText.Text = s; })); - } - - // ---- 네비게이션 ---- - private void Go_Click(object sender, RoutedEventArgs e) => NavigateTo(AddressBox.Text); - - // ---- 주소창 자동완성/제안 ---- - private void AddressBox_TextChanged(object sender, TextChangedEventArgs e) - { - if (_suggesting) return; - UpdateSuggestions(AddressBox.Text); - TryInlineSuggest(AddressBox.Text); - } - - private void UpdateSuggestions(string text) - { - text = text?.Trim() ?? ""; - if (text.Length == 0 || !AddressBox.IsKeyboardFocused) - { - AddressPopup.IsOpen = false; - return; - } - var low = text.ToLowerInvariant(); - var matches = _history - .Where(h => h.Url.ToLowerInvariant().Contains(low) - || (h.Title ?? "").ToLowerInvariant().Contains(low)) - .Take(8) - .ToList(); - AddressSuggestions.ItemsSource = matches; - AddressSuggestions.SelectedIndex = matches.Count > 0 ? 0 : -1; - AddressPopup.IsOpen = matches.Count > 0; - } - - // 인라인 자동완성: 방문한 URL 중 입력이 접두사면 나머지를 선택영역으로 채움 (Tab/→ 로 확정) - private void TryInlineSuggest(string text) - { - if (text.Length == 0 || AddressBox.CaretIndex != text.Length) return; - var low = text.ToLowerInvariant(); - var match = _history.FirstOrDefault(h => h.Url.Length > text.Length - && h.Url.ToLowerInvariant().StartsWith(low)); - if (match == null) return; - _suggesting = true; - AddressBox.Text = match.Url; - AddressBox.Select(text.Length, match.Url.Length - text.Length); - _suggesting = false; - } - - private void AddressBox_PreviewKeyDown(object sender, KeyEventArgs e) - { - var k = e.Key; - - // ↑/↓ 제안 목록 탐색 - if (AddressPopup.IsOpen && AddressSuggestions.HasItems && (k == Key.Down || k == Key.Up)) - { - int n = AddressSuggestions.Items.Count; - int delta = k == Key.Down ? 1 : -1; - int i = Math.Clamp(AddressSuggestions.SelectedIndex + delta, 0, n - 1); - AddressSuggestions.SelectedIndex = i; - AddressSuggestions.ScrollIntoView(AddressSuggestions.Items[i]); - e.Handled = true; - return; - } - - // Tab / → 로 인라인 제안 확정 (선택영역을 끝으로 붕괴) - if ((k == Key.Tab || k == Key.Right) && AddressBox.SelectionLength > 0 - && AddressBox.CaretIndex + AddressBox.SelectionLength == AddressBox.Text.Length) - { - AddressBox.CaretIndex = AddressBox.Text.Length; - AddressBox.Select(AddressBox.Text.Length, 0); - e.Handled = true; - return; - } - - if (k == Key.Escape) { AddressPopup.IsOpen = false; e.Handled = true; return; } - - if (k == Key.Enter) - { - AddressPopup.IsOpen = false; - if (AddressSuggestions.SelectedItem is HistoryEntry sel) - NavigateTo(sel.Url); - else - NavigateTo(AddressBox.Text); - Keyboard.ClearFocus(); - e.Handled = true; - } - } - - // 제안 항목 클릭 → 이동 - private void AddressSuggestion_Click(object sender, MouseButtonEventArgs e) - { - if (AddressSuggestions.SelectedItem is HistoryEntry sel) - { - AddressPopup.IsOpen = false; - NavigateTo(sel.Url); - } - } - - private void NavBack_Click(object sender, RoutedEventArgs e) => Cv?.GoBack(); - private void NavForward_Click(object sender, RoutedEventArgs e) => Cv?.GoForward(); - private void NavRefresh_Click(object sender, RoutedEventArgs e) => Cv?.Reload(); - private void NavHome_Click(object sender, RoutedEventArgs e) => NavigateTo(_config.HomeUrl); - - internal void NavigateTo(string? url) - { - // T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당 - var intent = _resolver.Resolve(url); - if (intent == null) return; - - // T3-1: 도메인 라우팅 규칙 — 대상 워크스페이스가 다르면 자동 전환 후 이동(Arc ATC 패턴) - var routeTo = _routingRules.Match(intent.Target); - if (routeTo != null && routeTo != _currentWorkspaceName && - _workspaces.FirstOrDefault(w => w.Name == routeTo) is { } wsTarget) - { - SwitchWorkspace(wsTarget); - _currentWorkspaceName = routeTo; - Log($"라우팅 규칙 → 워크스페이스 '{routeTo}' 전환"); - } - Cv?.Navigate(intent.Target); - } - - // 소셜 영상 페이지(YouTube/Instagram/X/...) 감지 → yt-dlp 카드 등록. (탭별 상태) - private void DetectSocial(TabItem tab, string? url) - { - if (string.IsNullOrEmpty(url)) { tab.PendingSocialUrl = null; return; } - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { tab.PendingSocialUrl = null; return; } - if (SocialSiteDetector.TryMatch(uri, out var normalized, out var label)) - { - _manager.LogPanel($"★ 소셜 감지 [{label}]: {normalized}"); - _manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label)); - tab.PendingSocialUrl = normalized; - } - else tab.PendingSocialUrl = null; - } - - // ---- 링크로 받기 (URL 붙여넣기) ---- - private void ToggleLink(object sender, RoutedEventArgs e) - { - BookmarksPopup.IsOpen = false; - HistoryPopup.IsOpen = false; - LinkPopup.IsOpen = !LinkPopup.IsOpen; - if (LinkPopup.IsOpen) { LinkBox.Clear(); Dispatcher.BeginInvoke(new Action(() => LinkBox.Focus())); } - } - - private void LinkBox_PreviewKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Enter) { LinkDownload_Click(sender, e); e.Handled = true; } - } - - private void LinkDownload_Click(object sender, RoutedEventArgs e) - { - var raw = (LinkBox.Text ?? "").Trim(); - if (string.IsNullOrEmpty(raw)) return; - if (!raw.StartsWith("http", StringComparison.OrdinalIgnoreCase)) raw = "https://" + raw; - if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri)) { Log("올바른 링크가 아닙니다"); return; } - if (SocialSiteDetector.TryMatch(uri, out var normalized, out var label)) - { - _manager.LogPanel($"★ 소셜 감지 [{label}]: {normalized}"); - _manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label)); - LinkPopup.IsOpen = false; - Log("카드에 추가했습니다. 화질을 선택하고 받기를 누르세요"); - } - else Log("지원하는 소셜 영상 링크가 아닙니다 (YouTube·Instagram·X·TikTok·Facebook)"); - } - - // #7 전역 키보드 단축키 (+Phase A 탭/T1-3/T1-9/T1-11) - private void OnPreviewKeyDown(object sender, KeyEventArgs e) - { - var cv = Cv; - var mods = Keyboard.Modifiers; - - // Phase A 탭 단축키 - if (mods == ModifierKeys.Control && e.Key == Key.T) { NewTabWithDial(); e.Handled = true; } - else if (mods == ModifierKeys.Control && e.Key == Key.J) { DownloadsPopup.IsOpen = !DownloadsPopup.IsOpen; e.Handled = true; } - else if (mods == ModifierKeys.Control && e.Key == Key.K) { TogglePalette(); e.Handled = true; } - else if (e.Key == Key.F9) { _ = ToggleReader(); e.Handled = true; } - else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.D) { ToggleBoostDark(); e.Handled = true; } - else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.S) { _ = TakeScreenshot(); e.Handled = true; } - else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.N) { OpenInPrivateTab(); e.Handled = true; } - else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.M) { ToggleCompactMode(); e.Handled = true; } // T3-13 - else if (mods == (ModifierKeys.Control | ModifierKeys.Alt) && e.Key == Key.N) { ToggleNotes(); e.Handled = true; } // T3-12 - else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.V) { ToggleVerticalTabs(); e.Handled = true; } - else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.H) { ToggleSplit(); e.Handled = true; } - else if (mods == ModifierKeys.Control && e.Key == Key.U) { ViewSource(); e.Handled = true; } - else if (mods == ModifierKeys.Control && e.Key == Key.P) { try { Cv?.ShowPrintUI(); } catch { } e.Handled = true; } - else if (e.Key == Key.OemQuestion && Keyboard.FocusedElement is not TextBox) { OpenFind(); e.Handled = true; } - else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.A) { ToggleTabSearch(this, e); e.Handled = true; } - else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.B) { ToggleBookmarkBar(); e.Handled = true; } - else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.T) { _tabs.ReopenClosedTab(); e.Handled = true; } - else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.W) { _tabs.CloseTab(_tabs.ActiveTab!); e.Handled = true; } - else if (mods == ModifierKeys.Control && e.Key == Key.W) { if (_tabs.ActiveTab != null) _tabs.CloseTab(_tabs.ActiveTab); e.Handled = true; } - else if (mods == ModifierKeys.Control && e.Key == Key.Tab) { _tabs.ActivateNext(); e.Handled = true; } - else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.Tab) { _tabs.ActivatePrevious(); e.Handled = true; } - else if (mods == ModifierKeys.Control && e.Key >= Key.D1 && e.Key <= Key.D9) - { - var idx = e.Key - Key.D1; - if (idx == 8) _tabs.ActivateLast(); else _tabs.ActivateAt(idx); // Ctrl+9 = 마지막 탭 - e.Handled = true; - } - // T1-3 검색 바가 열려 있으면 Esc 는 닫기가 우선 - else if (e.Key == Key.Escape && FindBar.Visibility == Visibility.Visible) { CloseFind(); e.Handled = true; } - else if (mods == ModifierKeys.Control && e.Key == Key.F) { OpenFind(); e.Handled = true; } - else if (e.Key == Key.F3) - { - if (FindBar.Visibility == Visibility.Visible) - { - if (mods == ModifierKeys.Shift) _tabs.ActiveTab?.FindSession?.Previous(); - else _tabs.ActiveTab?.FindSession?.Next(); - } - else OpenFind(); - e.Handled = true; - } - else if (e.Key == Key.F11) { ToggleFullscreen(); e.Handled = true; } - else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.R) { ForceReload(); e.Handled = true; } - else if (mods == ModifierKeys.Control && e.Key == Key.L) { AddressBox.Focus(); AddressBox.SelectAll(); e.Handled = true; } - else if (e.Key == Key.F5 || (mods == ModifierKeys.Control && e.Key == Key.R)) { cv?.Reload(); e.Handled = true; } - else if (e.Key == Key.F12) { OpenDevTools(); e.Handled = true; } // UC-K12 - else if (e.Key == Key.Escape) - { - // UC-K10: 열린 팝업이 있으면 모두 닫고, 없으면 페이지 로딩 중지 - if (AnyPopupOpen()) CloseAllPopups(); - else try { cv?.Stop(); } catch { } - e.Handled = true; - } - else if (mods == ModifierKeys.Alt && e.Key == Key.Left) { try { if (cv?.CanGoBack == true) cv.GoBack(); } catch { } e.Handled = true; } - else if (mods == ModifierKeys.Alt && e.Key == Key.Right) { try { if (cv?.CanGoForward == true) cv.GoForward(); } catch { } e.Handled = true; } - else if (mods == ModifierKeys.Control) - { - if (e.Key == Key.OemPlus || e.Key == Key.Add) { try { ZoomBy(+0.1); } catch { } e.Handled = true; } - else if (e.Key == Key.OemMinus || e.Key == Key.Subtract) { try { ZoomBy(-0.1); } catch { } e.Handled = true; } - else if (e.Key == Key.D0) { try { ZoomSet(1.0); } catch { } e.Handled = true; } - } - } - - private void ZoomBy(double delta) => ZoomSet(Math.Clamp((ActiveView?.ZoomFactor ?? 1.0) + delta, 0.1, 5.0)); - - private void ZoomSet(double zoom) - { - if (ActiveView == null) return; - ActiveView.ZoomFactor = zoom; - PersistZoom(); - } - - // ---- T1-3 페이지 내 검색 바 (활성 탭 세션) ---- - internal void OpenFind() - { - FindBar.Visibility = Visibility.Visible; - FindStatus.Text = ""; - FindBox.Focus(); - FindBox.SelectAll(); - if (FindBox.Text.Length > 0) _ = _tabs.ActiveTab?.FindSession?.StartAsync(FindBox.Text); - } - - internal void CloseFind() - { - FindBar.Visibility = Visibility.Collapsed; - _tabs.ActiveTab?.FindSession?.Close(); - FindStatus.Text = ""; - } - - private void FindBox_TextChanged(object sender, TextChangedEventArgs e) - { - if (FindBar.Visibility == Visibility.Visible) - _ = _tabs.ActiveTab?.FindSession?.StartAsync(FindBox.Text); - } - - private void FindBox_PreviewKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Enter) - { - if (Keyboard.Modifiers == ModifierKeys.Shift) _tabs.ActiveTab?.FindSession?.Previous(); - else _tabs.ActiveTab?.FindSession?.Next(); - e.Handled = true; - } - else if (e.Key == Key.Escape) { CloseFind(); e.Handled = true; } - } - - private void FindNext_Click(object sender, RoutedEventArgs e) => _tabs.ActiveTab?.FindSession?.Next(); - private void FindPrev_Click(object sender, RoutedEventArgs e) => _tabs.ActiveTab?.FindSession?.Previous(); - private void FindClose_Click(object sender, RoutedEventArgs e) => CloseFind(); - - private void FindOption_Changed(object sender, RoutedEventArgs e) - => _tabs.ActiveTab?.FindSession?.SetOptions(FindCaseBox.IsChecked == true, FindWordBox.IsChecked == true); - - // T1-7 줌 변경을 사이트별 저장소에 영속 - private void PersistZoom() - { - var cv = Cv; - if (cv != null && ActiveView != null) _zoomStore.Set(cv.Source, ActiveView.ZoomFactor); - } - - // T1-9 강제 새로고침: 디스크 캐시 비우고 다시 로드 - private async void ForceReload() - { - var cv = Cv; - if (cv == null) return; - try { await cv.Profile.ClearBrowsingDataAsync(CoreWebView2BrowsingDataKinds.DiskCache); } - catch { } - try { cv.Reload(); } catch { } - } - - // T1-11 전체화면(F11): 툴바/탭/상태바 숨기고 최대화. 재누르면 복원. - internal void ToggleFullscreen() - { - _fullscreen = !_fullscreen; - if (_fullscreen) - { - _stateBeforeFullscreen = WindowState; - WindowState = WindowState.Maximized; - } - else - { - WindowState = _stateBeforeFullscreen; - } - ToolbarRow.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible; - TabStripRow.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible; - StatusBar.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible; - } - - private static string MakeUniqueDownloadName(string folder, string file) - { - var p = Path.Combine(folder, file); - if (!File.Exists(p)) return p; - var name = Path.GetFileNameWithoutExtension(file); - var ext = Path.GetExtension(file); - int i = 1; - while (File.Exists(Path.Combine(folder, $"{name} ({i}){ext}"))) i++; - return Path.Combine(folder, $"{name} ({i}){ext}"); - } - - // ---- 범용 우측 패널: 접기/펼치기 + 리사이즈 + 상태 저장 ---- - private double ClampPanelWidth(double w) => Math.Clamp(w, Math.Max(160, _config.RightPanelMinWidth), MaxExpandedWidth); // UC-L20 최소 폭 설정 반영 - - private void ApplyPanelState(bool collapsed) - { - _panelCollapsed = collapsed; - RightCol.Width = new GridLength(collapsed ? CollapsedPanelWidth : _panelExpandedWidth); - PanelBody.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible; - PanelTitle.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible; - PanelCountBadge.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible; - RightSplitter.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible; - // ←(펼치기) / →(접기) chevron. 접힘 시에도 토글 버튼은 헤더에 남아 클릭 가능. - PanelToggle.Content = collapsed ? "" : ""; - PanelToggle.ToolTip = collapsed ? "패널 펼치기" : "패널 접기"; - } - - private void PanelToggle_Click(object sender, RoutedEventArgs e) - { - // 접기 직전 폭을 기억(다시 펼칠 때 복원) - if (!_panelCollapsed) _panelExpandedWidth = ClampPanelWidth(RightCol.Width.Value); - ApplyPanelState(!_panelCollapsed); - PersistPanel(); - } - - private void RightSplitter_DragCompleted(object sender, DragCompletedEventArgs e) - { - if (_panelCollapsed) return; - double w = RightCol.Width.Value; - if (w <= 120) ApplyPanelState(true); // 너무 좁히면 접기 처리 - else _panelExpandedWidth = ClampPanelWidth(w); - PersistPanel(); - } - - private void PersistPanel() - { - _config.RightPanelCollapsed = _panelCollapsed; - _config.RightPanelWidth = _panelCollapsed ? _panelExpandedWidth : ClampPanelWidth(RightCol.Width.Value); - ConfigStore.Save(_config); - } - - private void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e) - { - // UC-K11: 다운로드 진행 중 종료 확인 - var activeDownloads = _manager.Items.Count(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중" or "대기") - + _pageDownloads.Items.Count(i => !i.IsFinished); - if (activeDownloads > 0 && !_forceClose) - { - e.Cancel = true; - var r = MessageBox.Show(this, - $"진행 중인 다운로드가 {activeDownloads}건 있습니다.\n그래도 종료하시겠습니까?", - "종료 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning); - if (r != MessageBoxResult.Yes) return; - _forceClose = true; - Close(); - return; - } - - _config.RightPanelCollapsed = _panelCollapsed; - if (!_panelCollapsed) _config.RightPanelWidth = ClampPanelWidth(RightCol.Width.Value); - _config.CompactMode = _compactMode; // UC-L07 - if (_splitTab != null) - _config.SplitViewRatio = Math.Clamp(SplitCol.Width.Value / Math.Max(1, SplitCol.Width.Value + TabHost.ActualWidth), 0.1, 0.9); // UC-L10 - _config.FullScreen = _fullscreen; // UC-L26 - _config.WindowMaximized = WindowState == WindowState.Maximized; // UC-L12 - _config.WindowBounds = $"{Left},{Top},{Width},{Height}"; // UC-L11 - ConfigStore.Save(_config); - // T1-10 세션 저장: 탭 URL/활성/핀 보존 - try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { } - try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } // T2-10 - try { if (_remoteServer != null) _ = _remoteServer.DisposeAsync(); } catch { } // M0 - System.Net.NetworkInformation.NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged; // §J - try { Microsoft.Win32.SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; } catch { } - } - - private bool _forceClose; - - /// UC-L11/L12/L26: 저장된 창 상태(위치·크기·최대화) 복원. - private void RestoreWindowBounds() - { - try - { - if (!string.IsNullOrWhiteSpace(_config.WindowBounds)) - { - var parts = _config.WindowBounds.Split(','); - if (parts.Length == 4 && - double.TryParse(parts[0], out var l) && double.TryParse(parts[1], out var t) && - double.TryParse(parts[2], out var w) && double.TryParse(parts[3], out var h)) - { - // 화면 밖 좌표(모니터 분리 후) 방어 — 보이는 가상 화면 범위로 클램프 - var bounds = SystemParameters.VirtualScreenLeft; - var boundsTop = SystemParameters.VirtualScreenTop; - var boundsW = SystemParameters.VirtualScreenWidth; - var boundsH = SystemParameters.VirtualScreenHeight; - if (l < bounds || t < boundsTop || l + w > bounds + boundsW || t + h > boundsTop + boundsH) - WindowStartupLocation = WindowStartupLocation.CenterScreen; - else { Left = l; Top = t; Width = Math.Max(600, w); Height = Math.Max(400, h); } - } - } - if (_config.WindowMaximized) WindowState = WindowState.Maximized; - } - catch { } - } - - /// UC-K10 판정: 팝업 중 하나라도 열려 있는가. - private bool AnyPopupOpen() => - BookmarksPopup.IsOpen || HistoryPopup.IsOpen || AddressPopup.IsOpen || NotesPopup.IsOpen || - ShieldPopup.IsOpen || DownloadsPopup.IsOpen || TabSearchPopup.IsOpen || PalettePopup.IsOpen || - PlaylistPopup.IsOpen || LinkPopup.IsOpen || LibraryPopup.IsOpen || ArchivePopup.IsOpen || - ShortcutHelpPopup.IsOpen || NotificationCenterPopup.IsOpen || TabVolumePopup.IsOpen; - - // ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ---- - private void UpdatePrivateButton() - { - if (PrivateBtn == null) return; - try - { - PrivateBtn.Foreground = _config.PrivateMode - ? (Brush)FindResource("AccentBrush") - : (Brush)FindResource("TextSecondary"); - PrivateBtn.ToolTip = _config.PrivateMode - ? "개인정보 보호 모드 켜짐 — 클릭 시 일반 모드로 전환(재시작)" - : "개인정보 보호 모드(InPrivate)"; - } - catch { } - } - - // 토글 → 확인 → 플래그 저장 → 재시작(테마 전환과 동일 패턴) - private void TogglePrivate_Click(object sender, RoutedEventArgs e) - { - bool next = !_config.PrivateMode; - var msg = next - ? "개인정보 보호 모드(InPrivate)로 전환합니다.\n이 모드에서는 쿠키·세션·localStorage가 저장되지 않습니다.\n앱이 재시작되며 현재 페이지는 닫힙니다." - : "일반 모드로 전환합니다.\n앱이 재시작되며 현재 페이지는 닫힙니다."; - if (MessageBox.Show(msg + "\n\n계속할까요?", "브라우저 모드", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK) - return; - _config.PrivateMode = next; - ConfigStore.Save(_config); - RestartApp(); - } - - /// 현재 프로필의 브라우저 데이터(쿠키·DOM 저장소·캐시) 삭제. 일반 모드: 영속 프로필 정리. - public async Task ClearBrowsingDataAsync() - { - var cv = Cv; - if (cv == null) { Log("브라우저가 아직 준비되지 않았습니다"); return; } - try - { - await cv.Profile.ClearBrowsingDataAsync(); - Log("브라우저 데이터(쿠키·localStorage·캐시)를 삭제했습니다. 페이지를 새로고침하세요."); - } - catch (Exception ex) { Log($"데이터 삭제 실패: {ex.Message}"); } - } - - // ---- 즐겨찾기 ---- - private void ToggleBookmarks(object sender, RoutedEventArgs e) - { - HistoryPopup.IsOpen = false; - LinkPopup.IsOpen = false; - BookmarksPopup.IsOpen = !BookmarksPopup.IsOpen; - } - - private void AddBookmark_Click(object sender, RoutedEventArgs e) - { - var cv = Cv; - var url = cv?.Source ?? ""; - if (string.IsNullOrEmpty(url) || url.StartsWith("about:", StringComparison.OrdinalIgnoreCase)) return; - if (_bookmarks.Any(b => b.Url == url)) { Log("이미 즐겨찾기에 있습니다"); return; } - var title = Trunc(string.IsNullOrWhiteSpace(cv!.DocumentTitle) ? url : cv.DocumentTitle, 80); - _bookmarks.Insert(0, new Bookmark(title, url)); - BrowserStore.SaveBookmarks(_bookmarks.ToList()); - if (BookmarkBar.Visibility == Visibility.Visible) RefreshBookmarkBar(); - Log($"즐겨찾기 추가: {title}"); - } - - private void RemoveBookmark_Click(object sender, RoutedEventArgs e) - { - if (sender is FrameworkElement fe && fe.DataContext is Bookmark b) - { - _bookmarks.Remove(b); - BrowserStore.SaveBookmarks(_bookmarks.ToList()); - } - } - - private void OpenBookmark_Click(object sender, RoutedEventArgs e) - { - if (sender is FrameworkElement fe && fe.DataContext is Bookmark b) - { - BookmarksPopup.IsOpen = false; - NavigateTo(b.Url); - } - } - - // ---- 히스토리 ---- - private void ToggleHistory(object sender, RoutedEventArgs e) - { - BookmarksPopup.IsOpen = false; - LinkPopup.IsOpen = false; - HistoryPopup.IsOpen = !HistoryPopup.IsOpen; - } - - private void OpenHistory_Click(object sender, RoutedEventArgs e) - { - if (sender is FrameworkElement fe && fe.DataContext is HistoryRow h) // UC-M20 묶음 뷰 행 - { - HistoryPopup.IsOpen = false; - NavigateTo(h.Url); - } - } - - private void ClearHistory_Click(object sender, RoutedEventArgs e) - { - _history.Clear(); - BrowserStore.SaveHistory(new List()); - Log("방문 기록을 삭제했습니다"); - } - - private void RecordHistory(string title, string url) - { - if (string.IsNullOrEmpty(url)) return; - if (_history.Count > 0 && _history[0].Url == url) return; - _history.Insert(0, new HistoryEntry(Trunc(title, 80), url, DateTime.Now)); - while (_history.Count > 500) _history.RemoveAt(_history.Count - 1); - BrowserStore.SaveHistory(_history.ToList()); - HistoryList.ItemsSource = GroupHistoryByDate(_history); // UC-M20 날짜 묶음 뷰 유지 - } - - // ---- T2-8 탭 검색 (Ctrl+Shift+A) ---- - private void ToggleTabSearch(object sender, RoutedEventArgs e) - { - var open = !TabSearchPopup.IsOpen; - TabSearchPopup.IsOpen = open; - if (open) - { - TabSearchBox.Clear(); - TabSearchBox.Focus(); - } - } - - private void TabSearchBox_TextChanged(object sender, TextChangedEventArgs e) - { - var results = TabSearchFilter.Search(_tabs.Tabs, _tabs.ClosedStack, TabSearchBox.Text); - TabSearchResults.ItemsSource = results; - TabSearchResults.SelectedIndex = results.Count > 0 ? 0 : -1; - } - - private void TabSearchBox_PreviewKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Escape) { TabSearchPopup.IsOpen = false; e.Handled = true; } - else if (e.Key == Key.Down && TabSearchResults.HasItems) { TabSearchResults.SelectedIndex = Math.Min(TabSearchResults.SelectedIndex + 1, TabSearchResults.Items.Count - 1); e.Handled = true; } - else if (e.Key == Key.Up && TabSearchResults.HasItems) { TabSearchResults.SelectedIndex = Math.Max(TabSearchResults.SelectedIndex - 1, 0); e.Handled = true; } - else if (e.Key == Key.Enter) - { - if (TabSearchResults.SelectedItem is TabSearchResult sel) ActivateSearchResult(sel); - e.Handled = true; - } - } - - private void TabSearchResult_Click(object sender, MouseButtonEventArgs e) - { - if (TabSearchResults.SelectedItem is TabSearchResult sel) ActivateSearchResult(sel); - } - - private void ActivateSearchResult(TabSearchResult sel) - { - TabSearchPopup.IsOpen = false; - if (sel.IsClosed) - { - var reopened = _tabs.ReopenClosedTab(); - if (reopened == null) _tabs.NewTab(sel.Url); - } - else _tabs.Activate(sel.Tab); - } - - // ---- T1-6 북마크바 (Ctrl+Shift+B) ---- - private void ToggleBookmarkBar() - { - BookmarkBar.Visibility = BookmarkBar.Visibility == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible; - if (BookmarkBar.Visibility == Visibility.Visible) RefreshBookmarkBar(); - } - - private void RefreshBookmarkBar() => BookmarkBarList.ItemsSource = _bookmarks.Take(30).ToList(); - - private void BookmarkBar_Click(object sender, RoutedEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is Bookmark b) NavigateTo(b.Url); - } - - // ---- T2-12 컨텍스트 메뉴 ---- - private void OnContextMenuBuild(CoreWebView2 cv, CoreWebView2ContextMenuRequestedEventArgs ce) - { - try - { - var menu = new ContextMenu(); - var target = ce.ContextMenuTarget; - - var link = target?.LinkUri; - if (!string.IsNullOrEmpty(link)) - { - var download = new MenuItem { Header = "링크 다운로드", FontWeight = FontWeights.Medium }; // UC-K20 - download.Click += (_, _) => QueueLinkForDownload(link); - menu.Items.Add(download); - - var copy = new MenuItem { Header = "링크 주소 복사" }; - copy.Click += (_, _) => { try { Clipboard.SetText(link); } catch { } }; - menu.Items.Add(copy); - menu.Items.Add(new Separator()); - } - - foreach (var builtin in ce.MenuItems) - { - if (builtin.Kind == CoreWebView2ContextMenuItemKind.Separator) - { - if (menu.Items.Count > 0 && menu.Items[^1] is not Separator) menu.Items.Add(new Separator()); - continue; - } - if (builtin.Kind != CoreWebView2ContextMenuItemKind.Command) continue; - var mi = new MenuItem { Header = builtin.Label, IsEnabled = builtin.IsEnabled }; - var commandId = builtin.CommandId; - mi.Click += (_, _) => - { - try { ce.SelectedCommandId = commandId; ce.Handled = true; } catch { } - }; - menu.Items.Add(mi); - } - while (menu.Items.Count > 0 && menu.Items[^1] is Separator) menu.Items.RemoveAt(menu.Items.Count - 1); - - var view = ActiveView; - if (view != null) - { - var pt = view.PointToScreen(new Point(ce.Location.X, ce.Location.Y)); - menu.PlacementTarget = view; - menu.Placement = System.Windows.Controls.Primitives.PlacementMode.AbsolutePoint; - menu.HorizontalOffset = pt.X; - menu.VerticalOffset = pt.Y; - } - menu.IsOpen = true; - } - catch { } - } - - private void QueueLinkForDownload(string? link) - { - if (string.IsNullOrEmpty(link)) return; - if (!Uri.TryCreate(link, UriKind.Absolute, out var uri)) return; - var referer = Cv?.Source ?? link; - - if (SocialSiteDetector.TryMatch(uri, out var normalized, out var label)) - { - _manager.LogPanel($"컨텍스트 메뉴 소셜 감지 [{label}]: {normalized}"); - _manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label)); - return; - } - var kind = MediaDetector.Classify(link, "", -1, _config) ?? MediaKind.Direct; - _manager.LogPanel($"컨텍스트 메뉴 감지 [{kind}]: {link}"); - _manager.OnDetected(new DetectionContext(link, referer, kind, "")); - } - - // ==================== Phase C 기능 ==================== - - // ---- T2-1 명령 팔레트 ---- - private void BuildPalette() - { - _palette.Register("새 탭", "tab new", () => NewTabWithDial()); - _palette.Register("새 InPrivate 탭", "private incognito 사생활", OpenInPrivateTab); - _palette.Register("탭 닫기", "close", () => { if (_tabs.ActiveTab != null) _tabs.CloseTab(_tabs.ActiveTab); }); - _palette.Register("닫은 탭 복원", "reopen restore", () => _tabs.ReopenClosedTab()); - _palette.Register("탭 검색", "tab search find", () => ToggleTabSearch(this, new RoutedEventArgs())); - _palette.Register("다운로드", "downloads page", () => DownloadsPopup.IsOpen = true); - _palette.Register("북마크바 토글", "bookmark bar", ToggleBookmarkBar); - _palette.Register("전체화면", "fullscreen f11", ToggleFullscreen); - _palette.Register("리더 모드", "reader reading 읽기", async () => await ToggleReader()); - _palette.Register("강제 다크 모드 토글", "dark boost theme", ToggleBoostDark); - _palette.Register("세로 탭 토글", "vertical layout", ToggleVerticalTabs); - _palette.Register("분할 화면 토글", "split zen tile", ToggleSplit); - _palette.Register("그림 속 그림 (PiP)", "pip picture video", TogglePip); - _palette.Register("스크린샷", "screenshot capture", async () => await TakeScreenshot()); - _palette.Register("소스 보기", "source html", ViewSource); - _palette.Register("인쇄", "print pdf", () => { try { Cv?.ShowPrintUI(); } catch { } }); - _palette.Register("저장 폴더 열기", "folder open output", () => OpenInExplorer(null)); - _palette.Register("이 페이지 즐겨찾기", "bookmark add star", () => AddBookmark_Click(this, new RoutedEventArgs())); - _palette.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; }); - _palette.Register("재생목록 재생", "playlist play start", OpenPlayer); - _palette.Register("기기 연결 (QR 페어링)", "remote pair qr mobile phone", ShowPairing); - // ---- T3 잔여(데스크톱) ---- - _palette.Register("이 사이트 흑백", "grayscale filter page action 흑백", () => ApplyPageFilter(PageFilter.Grayscale)); // T3-6 - _palette.Register("이 사이트 세피아", "sepia filter page action 세피아", () => ApplyPageFilter(PageFilter.Sepia)); - _palette.Register("페이지 액션 끄기", "filter off reset", () => ApplyPageFilter(PageFilter.None)); - _palette.Register("노트 패널", "notes note 메모", ToggleNotes); // T3-12 - _palette.Register("노트: 현재 페이지 추가", "note add", () => NotesAddPage_Click(this, new RoutedEventArgs())); - _palette.Register("컴팩트 모드 토글", "compact chrome hide ui", ToggleCompactMode); // T3-13 - _palette.RegisterDynamic(() => _workspaces // T3-1: 사이트→워크스페이스 규칙 - .Select(w => new PaletteCommand($"라우팅 규칙: 이 사이트 → '{w.Name}'", "routing rule workspace", - () => AddRoutingRuleForCurrentSite(w.Name))).ToList()); - _palette.RegisterDynamic(() => _tabArchive.Search("") // T3-2: 아카이브 복구 - .Take(8) - .Select(a => new PaletteCommand($"아카이브에서 열기: {a.Title}", $"archive reopen {a.Url}", - () => { var t = _tabs.NewTab(); Cv2Navigate(t, a.Url); })).ToList()); - _palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess()); - _palette.RegisterSeparator(); - // ---- UI 복잡 버전 유즈케이스 명령(docs/UI_USECASES.md) ---- - _palette.Register("모두 일시정지", "pause all stop downloads 전체", PauseAllDownloads); // UC-K09 - _palette.Register("모두 재개", "resume all downloads 전체", ResumeAllDownloads); - _palette.Register("라이브러리", "library files 받은 파일", () => LibraryBtn_Click(this, new RoutedEventArgs())); // UC-M01 - _palette.Register("영역 캡처", "region capture screenshot 영역", async () => await RegionCapture()); // UC-K18 - _palette.Register("프로필 전환", "profile switch session 세션", SwitchProfile); // UC-K13 - _palette.Register("즐겨찾기 HTML 내보내기", "bookmark export html", () => ExportBookmarks()); // UC-M19 - _palette.Register("단축키 목록", "shortcut help keys", () => ShortcutHelpPopup.IsOpen = true); // UC-K04 - _palette.RegisterSeparator(); - _palette.RegisterDynamic(() => _tabs.Tabs - .Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t))) - .ToList()); - _palette.RegisterDynamic(() => _workspaces - .Select(w => new PaletteCommand($"워크스페이스 전환: {w.Name}", "workspace switch", () => SwitchWorkspace(w))) - .ToList()); - } - - private void TogglePalette() - { - var open = !PalettePopup.IsOpen; - PalettePopup.IsOpen = open; - if (open) { PaletteBox.Clear(); PaletteBox.Focus(); } - } - - private void PaletteBox_TextChanged(object sender, TextChangedEventArgs e) - { - var results = _palette.Search(PaletteBox.Text); - PaletteResults.ItemsSource = results; - PaletteResults.SelectedIndex = results.Count > 0 ? 0 : -1; - } - - private void PaletteBox_PreviewKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Escape) { PalettePopup.IsOpen = false; e.Handled = true; } - else if (e.Key == Key.Down && PaletteResults.HasItems) { PaletteResults.SelectedIndex = Math.Min(PaletteResults.SelectedIndex + 1, PaletteResults.Items.Count - 1); e.Handled = true; } - else if (e.Key == Key.Up && PaletteResults.HasItems) { PaletteResults.SelectedIndex = Math.Max(PaletteResults.SelectedIndex - 1, 0); e.Handled = true; } - else if (e.Key == Key.Enter) - { - if (PaletteResults.SelectedItem is PaletteCommand cmd) { PalettePopup.IsOpen = false; cmd.Execute(); } - e.Handled = true; - } - } - - private void PaletteResult_Click(object sender, MouseButtonEventArgs e) - { - if (PaletteResults.SelectedItem is PaletteCommand cmd) { PalettePopup.IsOpen = false; cmd.Execute(); } - } - - // ---- T2-4 리더 모드 ---- - private async Task ToggleReader() - { - var tab = _tabs.ActiveTab; - if (tab == null) return; - var result = await ReaderMode.ToggleAsync(tab, App.IsLightTheme); - if (result == null) Log("이 페이지에서는 리더 모드를 사용할 수 없습니다"); - } - - // ---- T2-7 부스트(강제 다크) ---- - private void ToggleBoostDark() - { - var url = Cv?.Source; - if (string.IsNullOrEmpty(url)) { Log("페이지가 없습니다"); return; } - var cur = _boostStore.Get(url); - _boostStore.Set(url, !cur.ForceDark, cur.CustomCss); - _views?.ApplyBoostNow(_tabs.ActiveTab!); - Log(!cur.ForceDark ? "이 사이트 강제 다크 켜짐" : "이 사이트 강제 다크 꺼짐"); - } - - // ---- T3-6 페이지 액션(흑백/세피아 — Boost 통합) ---- - internal void ApplyPageFilter(PageFilter filter) - { - var url = Cv?.Source; - if (string.IsNullOrEmpty(url)) { Log("페이지가 없습니다"); return; } - var cur = _boostStore.Get(url); - _boostStore.Set(url, cur.ForceDark, cur.CustomCss, filter); - _views?.ApplyBoostNow(_tabs.ActiveTab!); - Log(filter switch - { - PageFilter.Grayscale => "이 사이트 흑백 적용", - PageFilter.Sepia => "이 사이트 세피아 적용", - _ => "페이지 액션 해제", - }); - } - - // ---- T3-1 도메인 라우팅 규칙 ---- - private void AddRoutingRuleForCurrentSite(string workspaceName) - { - var url = Cv?.Source; - if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var u)) - { Log("페이지가 없습니다"); return; } - var host = u.Host; - if (host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) host = host[4..]; - _routingRules.Add(host, workspaceName); - Log($"라우팅 규칙 추가: {host} → '{workspaceName}' (다음 이동부터 자동 전환)"); - } - - // ---- T3-2 아카이브 복구 보조 ---- - private static void Cv2Navigate(TabItem tab, string url) - { - try { tab.View?.CoreWebView2?.Navigate(url); } catch { } - } - - // ---- T3-12 노트 패널 ---- - internal void ToggleNotes() - { - RefreshNotesList(); - NotesPopup.IsOpen = true; - } - - private void RefreshNotesList() - { - NotesList.ItemsSource = _notes.List() - .Select(n => $"[{n.CreatedAt:MM-dd HH:mm}] {n.Title} — {n.Body}") - .ToList(); - NotesList.DisplayMemberPath = null; - } - - internal void NotesAddPage_Click(object sender, RoutedEventArgs e) - { - var url = Cv?.Source ?? ""; - var title = _tabs.ActiveTab?.Title ?? url; - _notes.Add(title, url); - Log("노트에 페이지 추가됨"); - RefreshNotesList(); - } - - private void NotesDelete_Click(object sender, RoutedEventArgs e) - { - var idx = NotesList.SelectedIndex; - var list = _notes.List(); - if (idx >= 0 && idx < list.Count) - { - _notes.Delete(list[idx].Id); - RefreshNotesList(); - } - } - - // ---- T3-13 컴팩트 모드(크롬 숨김 + 상단 엣지 호버 복원) ---- - internal void ToggleCompactMode() - { - _compactMode = !_compactMode; - _chromeRevealed = false; - ApplyChromeVisibility(); - Log(_compactMode ? "컴팩트 모드: 크롤 숨김 (상단 가장자리에 마우스를 올리면 복원)" : "컴팩트 모드 해제"); - } - - private void ApplyChromeVisibility() - { - var chromeRows = new[] { 0, 1, 2 }; // 툴바/북마크바/탭스트립 - foreach (var i in chromeRows) - MainGrid.RowDefinitions[i].Height = (_compactMode && !_chromeRevealed) ? new GridLength(0) : GridLength.Auto; - if (!_compactMode) _chromeRevealed = false; - } - - private System.Windows.Threading.DispatcherTimer? _edgeRevealTimer; - private bool _edgePending; - - protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e) - { - if (_compactMode) - { - var p = e.GetPosition(this); - var wantReveal = p.Y <= 4; // 상단 엣지 4px - // UC-L24 EdgeRevealDelayMs: 엣지에 머무는 동안만 복원(순간 지나침 무시) - if (wantReveal && !_chromeRevealed && !_edgePending) - { - _edgePending = true; - _edgeRevealTimer ??= new System.Windows.Threading.DispatcherTimer(); - _edgeRevealTimer.Interval = TimeSpan.FromMilliseconds(Math.Max(0, _config.EdgeRevealDelayMs)); - _edgeRevealTimer.Tick -= EdgeRevealTick; - _edgeRevealTimer.Tick += EdgeRevealTick; - _edgeRevealTimer.Stop(); - _edgeRevealTimer.Start(); - } - else if (!wantReveal) - { - _edgePending = false; - _edgeRevealTimer?.Stop(); - if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김 - { - _chromeRevealed = false; - ApplyChromeVisibility(); - } - } - } - base.OnPreviewMouseMove(e); - } - - private void EdgeRevealTick(object? sender, EventArgs e) - { - _edgeRevealTimer?.Stop(); - _edgePending = false; - if (!_chromeRevealed) - { - _chromeRevealed = true; - ApplyChromeVisibility(); - } - } - - // ---- T2-9 스크린샷 ---- - private async Task TakeScreenshot() - { - var cv = Cv; - if (cv == null) return; - try - { - using var ms = new MemoryStream(); - await cv.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png, ms); - var path = MakeUniqueDownloadName(Path.GetFullPath(_config.OutputFolder), - $"screenshot_{DateTime.Now:yyyyMMdd_HHmmss}.png"); - await File.WriteAllBytesAsync(path, ms.ToArray()); - Log($"스크린샷 저장: {Path.GetFileName(path)}"); - } - catch (Exception ex) { Log($"스크린샷 실패: {ex.Message}"); } - } - - // ---- T2-5 세로 탭 ---- - private void ToggleVerticalTabs() - { - _verticalTabs = !_verticalTabs; - if (_verticalTabs) - { - TabStripRow.Visibility = Visibility.Collapsed; - SideStripCol.Width = new GridLength(210); - var sv = new ScrollViewer - { - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, - Padding = new Thickness(4, 8, 4, 8) - }; - sv.Content = TabList; - TabSideHost.Child = sv; - SetTabListOrientation(Orientation.Vertical); - } - else - { - SetTabListOrientation(Orientation.Horizontal); - TabSideHost.Child = null; - // 원래 스트립의 DockPanel 로 복귀 (NewTabBtn 옆 ScrollViewer) - var sv = new ScrollViewer - { - HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden, - VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, - VerticalAlignment = VerticalAlignment.Bottom - }; - sv.Content = TabList; - // 기존 DockPanel: 스트립의 유일한 DockPanel - var dock = TabStripRow.Child as DockPanel; - dock?.Children.Add(sv); - SideStripCol.Width = new GridLength(0); - TabStripRow.Visibility = Visibility.Visible; - } - if (_fullscreen) TabStripRow.Visibility = Visibility.Collapsed; // 전체화면 유지 - } - - private void SetTabListOrientation(Orientation o) - { - var factory = new FrameworkElementFactory(typeof(StackPanel)); - factory.SetValue(StackPanel.OrientationProperty, o); - TabList.ItemsPanel = new ItemsPanelTemplate(factory); - } - - // ---- T2-3 분할 화면 (활성 + 다음 탭 나란히) ---- - private void ToggleSplit() - { - if (_splitTab != null) { ExitSplit(); return; } - var active = _tabs.ActiveTab; - if (active == null) return; - var next = _tabs.Tabs.FirstOrDefault(t => !ReferenceEquals(t, active)) ?? _tabs.NewTab(activate: false); - if (next.View?.Parent is Grid host) host.Children.Remove(next.View); - _splitTab = next; - SplitRightHost.Child = next.View; - next.View!.Visibility = Visibility.Visible; - var total = TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth; - var ratio = _config.SplitViewRatio is > 0.1 and < 0.9 ? _config.SplitViewRatio : 0.5; // UC-L10 복원 - SplitCol.Width = new GridLength(Math.Max(280, total * ratio)); - SplitRightHost.Visibility = Visibility.Visible; - SplitSplitter.Visibility = Visibility.Visible; - Log($"분할 화면: {Trunc(next.Title, 30)}"); - } - - private void ExitSplit() - { - if (_splitTab?.View != null) - { - SplitRightHost.Child = null; - TabHost.Children.Add(_splitTab.View); - _splitTab.View.Visibility = ReferenceEquals(_splitTab, _tabs.ActiveTab) ? Visibility.Visible : Visibility.Collapsed; - } - _splitTab = null; - SplitCol.Width = new GridLength(0); - SplitRightHost.Visibility = Visibility.Collapsed; - SplitSplitter.Visibility = Visibility.Collapsed; - } - - // ---- T2-6 Glance (Alt+클릭 링크 미리보기) ---- - private void OpenGlance(string url) - { - try - { - var glance = new GlanceWindow(_env!, _config, url) - { - Owner = this, - ShowInTaskbar = false, - WindowStyle = WindowStyle.None, - AllowsTransparency = false, - Background = System.Windows.Media.Brushes.Black - }; - glance.ExpandRequested += g => - { - g.Close(); - _tabs.NewTab(g.Url); // 탭으로 승격 - }; - glance.Show(); - } - catch (Exception ex) { Log($"미리보기 실패: {ex.Message}"); } - } - - // ---- T3-9 낭독(TTS) ---- - private void SpeakText(string text) - { - try - { - _speech?.Dispose(); - _speech = new System.Speech.Synthesis.SpeechSynthesizer(); - if (text.Length > 4000) text = text[..4000]; - _speech.SpeakAsync(text); - } - catch { } - } - - // ---- T3-3 InPrivate 탭 ---- - private void OpenInPrivateTab() - { - _views!.NextTabIsPrivate = true; - var t = _tabs.NewTab(); - t.Title = "InPrivate 탭"; - Log("InPrivate 탭 열림 — 이 탭은 디스크에 저장되지 않습니다"); - } - - // ---- T3-10 소스 보기 ---- - private async void ViewSource() - { - var cv = Cv; - if (cv == null) return; - try - { - var html = await cv.ExecuteScriptAsync("document.documentElement.outerHTML"); - var decoded = System.Text.Json.JsonSerializer.Deserialize(html) ?? ""; - var wrapped = "" - + System.Net.WebUtility.HtmlEncode(decoded) + ""; - cv.NavigateToString(wrapped); - } - catch { } - } - - // ---- T3-7 PiP ---- - private void TogglePip() - { - var cv = Cv; - if (cv == null) return; - _ = cv.ExecuteScriptAsync(""" -(function(){ try { - var v = Array.from(document.querySelectorAll('video')).find(x => !x.paused && !x.disabled) || document.querySelector('video'); - if (!v) { return 'NO_VIDEO'; } - if (document.pictureInPictureElement) { document.exitPictureInPicture(); return 'EXITED'; } - v.disablePictureInPicture = false; - v.requestPictureInPicture(); return 'PIP'; -} catch(e){ return 'ERR:'+e.message; } })(); -"""); - Log("그림 속 그림 전환 (재생 중인 영상이 없으면 첫 번째 영상)"); - } - - // ---- T3-6 새 탭 스피드 다이얼 ---- - private void NewTabWithDial() - { - var tab = _tabs.NewTab(); - _ = ShowDialAsync(tab); - } - - private async Task ShowDialAsync(TabItem tab) - { - await WaitTabReadyLocal(tab); - var cv = tab.Core; - if (cv == null) return; - // UC-F18: 폰트 하드코딩 대신 앱 폰트 스택 주입(App.ApplyFontSettings 가 관리) - var fontStack = (FontFamily)Application.Current.FindResource("UiFont"); - var fontCss = string.Join(",", fontStack.Source.Split(',').Select(f => $"'{f.Trim()}'")); - var tiles = _bookmarks.Take(18).Select(b => - $"
🌐
{System.Net.WebUtility.HtmlEncode(b.Title)}
").ToList(); - var dial = "

새 탭

" + string.Join("", tiles) + $"
주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시
"; - cv.NavigateToString(dial); - } - - private async Task WaitTabReadyLocal(TabItem tab) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - while (tab.Core == null && sw.ElapsedMilliseconds < 15000) - await Task.Delay(50); - } - - // ---- T2-11/T3-1 탭 컨텍스트 메뉴 + 워크스페이스 ---- - // UC-B17: 그룹 색 팔레트를 테마 리소스(Shared.xaml GroupPalette)에서 가져온다. - private string[] GroupColors => (TryFindResource("GroupPalette") as Array)? - .OfType().Select(b => $"#{b.Color.R:X2}{b.Color.G:X2}{b.Color.B:X2}").ToArray() - ?? Array.Empty(); - - private void Tab_ContextMenu(object sender, MouseButtonEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is not TabItem tab) return; - e.Handled = true; - var menu = new ContextMenu(); - - var pin = new MenuItem { Header = tab.IsPinned ? "고정 해제" : "탭 고정" }; - pin.Click += (_, _) => _tabs.SetPinned(tab, !tab.IsPinned); - menu.Items.Add(pin); - - var dup = new MenuItem { Header = "탭 복제" }; - dup.Click += (_, _) => _tabs.NewTab(tab.Url); - menu.Items.Add(dup); - - var mute = new MenuItem { Header = tab.Core is { IsMuted: true } ? "음소거 해제" : "사이트 음소거" }; - mute.Click += (_, _) => { try { if (tab.Core != null) { tab.Core.IsMuted = !(tab.Core?.IsMuted == true); tab.IsMuted = tab.Core.IsMuted; } } catch { } }; - menu.Items.Add(mute); - - // UC-B23 탭 볼륨 - var volume = new MenuItem { Header = "볼륨 조절" }; - volume.Click += (_, _) => OpenTabVolume(tab); - menu.Items.Add(volume); - - menu.Items.Add(new Separator()); - - // UC-B01 탭 잠금 - var lockItem = new MenuItem { Header = tab.IsLocked ? "잠금 해제" : "탭 잠금(닫기 방지)" }; - lockItem.Click += (_, _) => { tab.IsLocked = !tab.IsLocked; Log(tab.IsLocked ? "탭을 잠갔습니다" : "탭 잠금을 해제했습니다"); }; - menu.Items.Add(lockItem); - - // UC-B02 사용자 지정 탭 색 - var color = new MenuItem { Header = "탭 색상" }; - var noColor = new MenuItem { Header = "색 없음" }; - noColor.Click += (_, _) => tab.CustomColor = null; - color.Items.Add(noColor); - foreach (var c in GroupColors) - { - var mi = new MenuItem { Header = c }; - var swatch = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 }; - swatch.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(c)); - mi.Icon = swatch; - mi.Click += (_, _) => tab.CustomColor = c; - color.Items.Add(mi); - } - menu.Items.Add(color); - - var group = new MenuItem { Header = "그룹" }; - var noGroup = new MenuItem { Header = "그룹 없음" }; - noGroup.Click += (_, _) => _tabs.SetGroup(tab, null, null); - group.Items.Add(noGroup); - for (var i = 0; i < GroupColors.Length; i++) - { - var c = GroupColors[i]; - var mi = new MenuItem { Header = $"그룹 {i + 1}" }; - var sw = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 }; - sw.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(c)); - mi.Icon = sw; - mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", c); - group.Items.Add(mi); - } - menu.Items.Add(group); - - if (tab.GroupName != null) - { - var rename = new MenuItem { Header = $"그룹 이름 바꾸기 ({tab.GroupName})" }; // UC-B04 - rename.Click += (_, _) => - { - var name = Prompt("그룹 이름 바꾸기", "새 그룹 이름", tab.GroupName!); - if (name != null && _tabs.RenameGroup(tab.GroupName!, name.Trim())) - Log($"그룹 이름 변경: {name.Trim()}"); - }; - menu.Items.Add(rename); - - var collapse = new MenuItem { Header = tab.IsGroupCollapsed ? "그룹 펼치기" : "그룹 접기" }; // UC-B03 - collapse.Click += (_, _) => _tabs.ToggleGroupCollapsed(tab.GroupName!); - menu.Items.Add(collapse); - - var closeG = new MenuItem { Header = $"그룹 전체 닫기 ({tab.GroupName})" }; - closeG.Click += (_, _) => - { - var n = _tabs.Tabs.Count(t => t.GroupName == tab.GroupName && !t.IsLocked); - // UC-B09 다중 닫기 확인 - if (_config.ConfirmCloseMultipleTabs && n > 1 && - MessageBox.Show(this, $"그룹 탭 {n}개를 닫을까요?", "탭 닫기", MessageBoxButton.OKCancel) != MessageBoxResult.OK) - return; - _tabs.CloseGroup(tab.GroupName!); - }; - menu.Items.Add(closeG); - - var bmG = new MenuItem { Header = "그룹 즐겨찾기에 저장" }; // UC-B24 - bmG.Click += (_, _) => BookmarkGroup(tab.GroupName); - menu.Items.Add(bmG); - } - - menu.Items.Add(new Separator()); - var close = new MenuItem { Header = "탭 닫기" }; - close.Click += (_, _) => - { - if (!_tabs.CloseTab(tab)) Log("잠긴 탭은 닫을 수 없습니다 (탭 잠금 해제 후 시도)"); // UC-B01 - }; - menu.Items.Add(close); - - menu.PlacementTarget = sender as UIElement; - menu.IsOpen = true; - } - - private void SaveWorkspace(string name) - { - _workspaces.RemoveAll(w => w.Name == name); - _workspaces.Insert(0, _tabs.CaptureWorkspace(name)); - if (_workspaces.Count > 6) _workspaces.RemoveAt(_workspaces.Count - 1); - try - { - Directory.CreateDirectory(AppDataDir); - File.WriteAllText(Path.Combine(AppDataDir, "workspaces.json"), - System.Text.Json.JsonSerializer.Serialize(_workspaces)); - } - catch { } - Log($"워크스페이스 '{name}' 저장 ({_tabs.Tabs.Count} 탭)"); - } - - private void SwitchWorkspace(WorkspaceSnapshot ws) - { - ExitSplit(); - _tabs.SwitchWorkspace(ws); - _currentWorkspaceName = ws.Name; // T3-1 라우팅 기준 - Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)"); - } - - // ---- M0 원격 접근(모바일 연동) ---- - private async Task ToggleRemoteAccess() - { - if (_remoteServer != null) - { - await _remoteServer.DisposeAsync(); - _remoteServer = null; - _config.RemoteAccessEnabled = false; - ConfigStore.Save(_config); - Log("원격 접근을 껐습니다"); - UpdateServerStatusIcon(); // §J 서버 상태 아이콘 - return; - } - - var token = _config.RemoteAccessToken; - if (string.IsNullOrEmpty(token)) - { - token = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(24)) - .TrimEnd('=').Replace('+', '-').Replace('/', '_'); - _config.RemoteAccessToken = token; - } - _config.RemoteAccessEnabled = true; - ConfigStore.Save(_config); - - try - { - _remoteServer = await Server.ServerHost.StartAsync(new Server.ServerOptions - { - Port = 0, - Token = token, - LibraryFolder = Path.GetFullPath(_config.OutputFolder), - Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher), - EnableMdns = true, // MOBILE_PLAN §3: _videodl._tcp 발견 보조 (실패해도 서버는 뜬다) - }); - Log($"원격 접근 켜짐 — 포트 {_remoteServer.Port}"); - UpdateServerStatusIcon(); // §J 서버 상태 아이콘 - var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this }; - pair.Show(); - } - catch (Exception ex) - { - _remoteServer = null; - Log($"원격 접근 시작 실패(방화벽 확인): {ex.Message}"); - } - } - - private async void ShowPairing() - { - if (_remoteServer == null) { await ToggleRemoteAccess(); return; } - var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this }; - pair.Show(); - } - - // ---- T2-10 미디어 재생목록 ---- - private void AddToPlaylist(string url, string title, string kind, string? referer) - { - _playlist.Add(new MediaTrack(url, Trunc(title, 70), kind, DateTime.UtcNow, Referer: referer)); - try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } - PlaylistEmptyHint.Visibility = _playlist.Tracks.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - } - - private static string GuessTitle(string url) - { - try - { - var name = Path.GetFileName(new Uri(url).AbsolutePath); - return string.IsNullOrWhiteSpace(name) ? url : Uri.UnescapeDataString(name); - } - catch { return url; } - } - - private void TogglePlaylistPanel(object sender, RoutedEventArgs e) - { - PlaylistPopup.IsOpen = !PlaylistPopup.IsOpen; - } - - private void PlaylistPlay_Click(object sender, RoutedEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is MediaTrack t) - { - _playlist.CurrentIndex = _playlist.Tracks.IndexOf(t); - OpenPlayer(); - } - } - - private void PlaylistRemove_Click(object sender, RoutedEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is MediaTrack t) _playlist.Remove(t); - } - - private void PlaylistClear_Click(object sender, RoutedEventArgs e) => _playlist.Clear(); - - private void PlaylistPlayAll_Click(object sender, RoutedEventArgs e) => OpenPlayer(); - - private void OpenPlayer() - { - if (_player == null || _player.IsLoaded == false) - { - _player = new PlayerWindow(_env!, _config, _playlist) { Owner = this }; - _player.Closed += (_, _) => _player = null; - _player.Show(); - } - else { _player.Activate(); _player.PlayCurrent(); } - } - - // ---- 카드 액션 ---- - private void CardDownload_Click(object sender, RoutedEventArgs e) - { - if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) - { - if (!ConfirmDuplicateDownload(it)) return; // UC-D25 중복 확인 - _ = _manager.StartDownload(it); - } - } - - // 소셜 카드: yt-dlp -J 로 사용 가능한 화질 조회 → 드롭다운 교체 - private void CardFetchFormats_Click(object sender, RoutedEventArgs e) - { - if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) - _ = _manager.FetchFormatsAsync(it); - } - - private void CardRetry_Click(object sender, RoutedEventArgs e) - { - if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) - _ = _manager.RetryDownload(it); - } - - // #1 다운로드 취소 - private void CardCancel_Click(object sender, RoutedEventArgs e) - { - if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) - { - _manager.Cancel(it); - Log($"취소 요청: {it.Title}"); - } - } - - private void CardFolder_Click(object sender, RoutedEventArgs e) - { - if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) - OpenInExplorer(it.OutputPath); - } - - private void OpenFolder_Click(object sender, RoutedEventArgs e) => OpenInExplorer(null); - - // #3 explorer 인자 인젝션 방지: 경로 정규화 + 존재 검증 + ArgumentList/UseShellExecute - private void OpenInExplorer(string? selectFile) - { - try - { - var folder = Path.GetFullPath(_config.OutputFolder); - if (!Directory.Exists(folder)) return; - if (!string.IsNullOrEmpty(selectFile) && File.Exists(selectFile)) - { - var psi = new System.Diagnostics.ProcessStartInfo("explorer.exe") { UseShellExecute = false }; - psi.ArgumentList.Add($"/select,{selectFile}"); - System.Diagnostics.Process.Start(psi); - } - else - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = folder, - UseShellExecute = true, - }); - } - } - catch { } - } - - private void Settings_Click(object sender, RoutedEventArgs e) - { - var w = new SettingsWindow(_config, ClearBrowsingDataAsync) { Owner = this }; - if (w.ShowDialog() == true) - { - _config = w.Config; - ConfigStore.Save(_config); - _resolver = new SearchEngineResolver(_config.SearchEngine); // T1-4 즉시 반영 - _manager.UpdateMaxConcurrent(_config.MaxConcurrentDownloads); // #19 런타임 반영 - ApplyTheme(_config.Theme); // §A 라이브 테마(재시작 없음) - UpdateShortcutHelp(); // §H 단축키 재배치 반영 - } - } - - /// 테마 변경은 리소스를 DynamicResource 로 물려 런타임 전환이 가능해졌다(UC-T04). - /// 프록시·프로필 등 재시작이 필요한 변경은 설정창의 RestartBanner 가 안내한다. - internal static void RestartApp() - { - var exe = Environment.ProcessPath; - if (!string.IsNullOrEmpty(exe)) - System.Diagnostics.Process.Start(exe); - Application.Current.Shutdown(); - } - - private static string Trunc(string s, int n) => string.IsNullOrEmpty(s) ? "" : (s.Length <= n ? s : s[..n] + "…"); - // ==================== UI 유즈케이스 핸들러(소스 계약상 MainWindow.xaml.cs 배치) ==================== - - private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) - { - if (e.Category != UserPreferenceCategory.General) return; - Dispatcher.BeginInvoke(new Action(() => - { - // 고대비 켜/끄 → 설정 테마 무시하고 즉시 따라감(UC-A04) - if (SystemParameters.HighContrast && App.CurrentTheme != "highcontrast") ApplyTheme("highcontrast"); - else if (!SystemParameters.HighContrast && App.CurrentTheme == "highcontrast") ApplyTheme(_config.Theme); - })); - } - - private void CopyStatus_Click(object sender, MouseButtonEventArgs e) - { - try { Clipboard.SetText(StatusText.Text); _toast?.Show("상태 메시지를 복사했습니다", "info"); } catch { } - } - - /// UC-K19 노트 편집 내용 저장. - private void NotesSave_Click(object sender, RoutedEventArgs e) - { - _notes.Save(); - _toast?.Show("노트를 저장했습니다", "success"); - } - - /// UC-M17 즐겨찾기 편집(이름·URL). - private void EditBookmark_Click(object sender, RoutedEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return; - var title = Prompt("즐겨찾기 편집", "이름", b.Title); - if (title == null) return; - var url = Prompt("즐겨찾기 편집", "URL", b.Url); - if (url == null) return; - var idx = _bookmarks.IndexOf(b); - if (idx >= 0) _bookmarks[idx] = new Bookmark(title.Trim(), url.Trim(), b.Folder); - BrowserStore.SaveBookmarks(_bookmarks.ToList()); - RefreshBookmarkBar(); - } - - /// UC-M16 즐겨찾기 폴더로 정리 — 항목을 지정 폴더로 이동. - private void BookmarkFolder_Click(object sender, RoutedEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return; - var folder = Prompt("폴더 지정", "폴더 이름(비우면 최상위)", b.Folder ?? ""); - if (folder == null) return; - var idx = _bookmarks.IndexOf(b); - if (idx >= 0) _bookmarks[idx] = new Bookmark(b.Title, b.Url, string.IsNullOrWhiteSpace(folder) ? null : folder.Trim()); - BrowserStore.SaveBookmarks(_bookmarks.ToList()); - _toast?.Show($"'{Trunc(b.Title, 24)}' 를 '{(string.IsNullOrWhiteSpace(folder) ? "최상위" : folder.Trim())}' 로 이동", "info"); - } - - private void LibraryPlay_Click(object sender, RoutedEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is LibraryEntry entry) PlayLibraryEntry(entry); - } - - /// UC-M8 삭제(휴지통 아님 — 확인 후 영구 삭제). - private void LibraryDelete_Click(object sender, RoutedEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is not LibraryEntry entry) return; - if (MessageBox.Show(this, $"'{entry.Name}' 파일을 삭제할까요?", "라이브러리", - MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK) return; - try { File.Delete(entry.Path); } catch (Exception ex) { Log($"삭제 실패: {ex.Message}"); } - RefreshLibrary(); - } - - /// UC-M9 포함 폴더 열기(파일 선택 상태). - private void LibraryOpenFolder_Click(object sender, RoutedEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is LibraryEntry entry) OpenInExplorer(entry.Path); - } - - /// UC-M13 재생목록 파일 내보내기(M3U). - private void PlaylistExport_Click(object sender, RoutedEventArgs e) - { - var dlg = new SaveFileDialog { Filter = "M3U 재생목록|*.m3u", FileName = "playlist.m3u" }; - if (dlg.ShowDialog() != true) return; - var sb = new System.Text.StringBuilder("#EXTM3U\n"); - foreach (var t in _playlist.Tracks) - sb.AppendLine($"#EXTINF:-1,{t.Title}\n{t.Url}"); - File.WriteAllText(dlg.FileName, sb.ToString()); - } - - /// UC-A09: 메인 창 자동화 피어 — 창 이름에 현재 상태 메시지를 노출해 낭독기가 상태를 읽게 한다. - protected override AutomationPeer OnCreateAutomationPeer() - => new MainWindowAutomationPeer(this); - - private sealed class MainWindowAutomationPeer : WindowAutomationPeer - { - public MainWindowAutomationPeer(Window owner) : base(owner) { } - - protected override string GetNameCore() => "MediaBrowser 메인 창"; - } - -} diff --git a/VideoDownloader.App/Services/TabViewService.cs b/VideoDownloader.App/Services/TabViewService.cs deleted file mode 100644 index 48e5a16..0000000 --- a/VideoDownloader.App/Services/TabViewService.cs +++ /dev/null @@ -1,314 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Threading; -using Microsoft.Web.WebView2.Core; -using VideoDownloader.Browser; -using VideoDownloader.Browser.Tabs; -using VideoDownloader.Core.Config; -using Wv2 = Microsoft.Web.WebView2.Wpf; -using TabItem = VideoDownloader.Browser.Tabs.TabItem; - -namespace VideoDownloader.App.Services; - -/// -/// 탭별 WebView2 생명주기·이벤트 배선 전담(재구성 ④ — MainWindow 갓-클래스 해체). -/// 일반 브라우저 동작(제목/URL/로딩/오디오/검색세션/줌 적용/초기 탐색/뉴윈도우→탭)은 -/// 이 서비스가, 앱 고유 기능(미디어 감지/다운로드 라우팅/소셜/기록/주소창 반영)은 -/// 이벤트 훅으로 호스트(MainWindow)가 주입한다. -/// -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 _init = new(); - - /// 뉴윈도우(팝업) 요청 시 새 탭을 만들어 반환하는 공급자(호스트가 TabManager 에 연결). - public Func>? NewTabProvider { get; set; } - - /// 탭 CoreWebView2 초기화 완료(호스트가 감지 등 탭별 구독 부착). - public event Action? TabCoreReady; - - /// 페이지 내 a[download] 등 다운로드 시작(호스트가 저장 폴더로 라우팅). - public event Action? DownloadStarting; - - /// URL 확정(SourceChanged). 인자: (탭, url). - public event Action? UrlChanged; - - /// 문서 타이틀 확정(DocumentTitleChanged). 인자: (탭, title). - public event Action? TitleChanged; - - /// 탭 뒤로/앞으로 가능 상태 변화(네비 버튼 갱신용). - public event Action? HistoryChanged; - - /// 우클릭 메뉴 요청 — 호스트가 WPF ContextMenu 로 재구성해 표시(Handled=true). - public event Action? ContextMenuBuild; - - /// T2-7 부스트: URL → 주입할 JS(강제 다크/커스텀 CSS). 빈 문자열이면 미적용. - public Func? BoostScriptProvider { get; set; } - - /// T2-6 Glance: Alt+클릭한 링크 URL 요청. - public event Action? GlanceRequested; - - /// 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; } - - 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(); - - /// 탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드) - public void AttachView(TabItem tab) - { - var view = new Wv2.WebView2 { DefaultBackgroundColor = System.Drawing.Color.Black }; - tab.View = view; - _host.Children.Add(view); - view.Visibility = tab.IsActive ? Visibility.Visible : Visibility.Collapsed; - _init[tab] = InitCoreAsync(tab); - } - - /// 부스트 즉시 재적용(토글 직후). 비동기 fire-and-forget. - 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){}})();"); - } - - /// 탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용). - 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); - } - - /// 탭 제거 시 뷰를 호스트에서 떼고 재오픈 가능 상태로(null) 만든다. - public void RemoveView(TabItem tab) - { - if (tab.View != null) - { - _host.Children.Remove(tab.View); - 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 && NextTabIsPrivate) tab.IsPrivate = true; - NextTabIsPrivate = false; - await tab.View!.EnsureCoreWebView2Async(_env, options); - } - catch (Exception) - { - return; // 초기화 실패 탭 — 호스트가 로그 표시는 TitleChanged 로 알림 없이 둔다(기존 동작) - } - - var cv = tab.Core!; - cv.Settings.AreDevToolsEnabled = true; - - // 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 { } - - // WebMessage 라우팅 (Glance / 리더 낭독) - try - { - 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("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() ?? ""); - } - 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 += (_, _) => _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) 적용 - { - var boostJs = BoostScriptProvider?.Invoke(cv.Source) ?? ""; - if (boostJs.Length > 0 && !tab.IsReader) _ = cv.ExecuteScriptAsync(boostJs); - } - 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; - } - }; - - // 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 ?? ""; - tab.Url = url; - UrlChanged?.Invoke(tab, url); - })); - cv.DocumentTitleChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() => - { - TitleChanged?.Invoke(tab, cv.DocumentTitle ?? ""); - })); - - // 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 { } - } - }; - - // 페이지 내 다운로드 → 호스트 훅 - cv.DownloadStarting += (_, de) => DownloadStarting?.Invoke(cv, de); - - // 우클릭 메뉴 → 호스트가 커스텀 메뉴 구성(기본 메뉴 대체, T2-12/T3-5) - cv.ContextMenuRequested += (_, ce) => - { - ce.Handled = true; - ContextMenuBuild?.Invoke(cv, ce); - }; - - // 초기 URL (세션 복원) - if (!string.IsNullOrEmpty(tab.Url) && string.IsNullOrEmpty(cv.Source)) - { - try { cv.Navigate(tab.Url); } catch { } - } - - TabCoreReady?.Invoke(tab, cv); - } - - /// 활성 탭 검색 상태 표시 요청(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.App/SettingsWindow.xaml b/VideoDownloader.App/SettingsWindow.xaml deleted file mode 100644 index 54302a9..0000000 --- a/VideoDownloader.App/SettingsWindow.xaml +++ /dev/null @@ -1,487 +0,0 @@ - - - - - - - - - - - - - - - - - - - - -
- 다운로드 큐 -
불러오는 중…
-
- -
- PC 라이브러리 탭하여 스트리밍 재생 -
불러오는 중…
-
- -
같은 Wi-Fi 에서만 접속됩니다 · LAN 직결(계정 없음)
- - - - -"""; -} diff --git a/VideoDownloader.Tests/Browser/MediaPlaylistTests.cs b/VideoDownloader.Tests/Browser/MediaPlaylistTests.cs deleted file mode 100644 index 013a7d4..0000000 --- a/VideoDownloader.Tests/Browser/MediaPlaylistTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -using VideoDownloader.App.Browser.Features; - -namespace VideoDownloader.Tests.Browser; - -// ── T2-10 미디어 재생목록 (Brave Playlist 패턴): 감지된 미디어를 쌓고 재생/제거/영속 ── -public class MediaPlaylistTests : IDisposable -{ - private readonly string _path = Path.Combine(Path.GetTempPath(), $"playlist_{Guid.NewGuid():N}.json"); - private readonly MediaPlaylist _pl = new(); - - private MediaTrack Mk(string url = "https://v.test/a.mp4", string title = "트랙A", string kind = "Direct") - => new(url, title, kind, AddedAt: DateTime.UtcNow); - - [Fact] - public void Add는_재생목록_끝에_추가하고_중복_URL은_무시한다() - { - var t = Mk(); - _pl.Add(t); - _pl.Add(Mk(url: "https://v.test/b.mp4", title: "트랙B")); - _pl.Add(Mk()); // 동일 URL 중복 - Assert.Equal(2, _pl.Tracks.Count); - Assert.Equal("트랙A", _pl.Tracks[0].Title); - } - - [Fact] - public void Remove와_Clear() - { - var a = Mk(); var b = Mk("https://v.test/b.mp4", "B"); - _pl.Add(a); _pl.Add(b); - _pl.Remove(a); - Assert.Single(_pl.Tracks); - _pl.Clear(); - Assert.Empty(_pl.Tracks); - } - - [Fact] - public void 다음_이전_트랙은_순환한다() - { - _pl.Add(Mk("u1", "1")); - _pl.Add(Mk("u2", "2")); - _pl.Add(Mk("u3", "3")); - _pl.CurrentIndex = 0; - - Assert.Equal(1, _pl.Next()); - Assert.Equal(2, _pl.Next()); - Assert.Equal(0, _pl.Next()); // 순환 - Assert.Equal(2, _pl.Previous()); - } - - [Fact] - public void 현재_트랙은_CurrentIndex로_얻는다() - { - _pl.Add(Mk("u1", "1")); - _pl.Add(Mk("u2", "2")); - _pl.CurrentIndex = 1; - Assert.Equal("u2", _pl.Current?.Url); - } - - [Fact] - public void 영속_라운드트립() - { - _pl.Add(Mk("u1", "제목1", "Hls")); - _pl.Add(Mk("u2", "제목2", "Direct")); - _pl.CurrentIndex = 1; - _pl.Save(_path); - - var loaded = new MediaPlaylist(); - Assert.Equal(0, loaded.Tracks.Count); - loaded.Load(_path); - Assert.Equal(2, loaded.Tracks.Count); - Assert.Equal("제목1", loaded.Tracks[0].Title); - Assert.Equal("Hls", loaded.Tracks[0].Kind); - Assert.Equal(1, loaded.CurrentIndex); - } - - [Fact] - public void 손상된_JSON은_빈_목록으로_시작() - { - File.WriteAllText(_path, "{broken"); - var pl = new MediaPlaylist(); - pl.Load(_path); - Assert.Empty(pl.Tracks); - } - - [Fact] - public void 재생완료_표시와_완료필터() - { - _pl.Add(Mk("u1", "1")); - _pl.Add(Mk("u2", "2")); - _pl.MarkPlayed(_pl.Tracks[0]); - Assert.True(_pl.Tracks[0].Played); - Assert.False(_pl.Tracks[1].Played); - } - - public void Dispose() { try { File.Delete(_path); } catch { } } -} diff --git a/VideoDownloader.Tests/Browser/PageDownloadTrackerTests.cs b/VideoDownloader.Tests/Browser/PageDownloadTrackerTests.cs deleted file mode 100644 index bf4ac80..0000000 --- a/VideoDownloader.Tests/Browser/PageDownloadTrackerTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using VideoDownloader.App.Browser.Features; - -namespace VideoDownloader.Tests.Browser; - -/// 페이지 다운로드 추상화(다운로드 매니저 UI 의 로직 코어) — fake op 로 검증. -public interface IFakeOpHost { } - -public class PageDownloadTrackerTests -{ - private sealed class FakeOp : IDownloadOperation - { - public string Uri { get; set; } = "https://x.test/file.zip"; - public string FileName { get; set; } = "file.zip"; - public long BytesReceived { get; set; } - public long TotalBytesToReceive { get; set; } = 1000; - public string State { get; set; } = "InProgress"; - public bool Paused, Resumed, Cancelled; - - public event Action? Changed; - public void Raise() => Changed?.Invoke(); - - public void Pause() { Paused = true; State = "Paused"; Raise(); } - public void Resume() { Resumed = true; State = "InProgress"; Raise(); } - public void Cancel() { Cancelled = true; State = "Cancelled"; Raise(); } - } - - [Fact] - public void Add는_항목을_등록하고_최근순_정렬한다() - { - var t = new PageDownloadTracker(); - var a = new FakeOp { FileName = "a.zip" }; - var b = new FakeOp { FileName = "b.zip" }; - t.Add(a, a.FileName); - t.Add(b, b.FileName); - Assert.Equal(2, t.Items.Count); - Assert.Equal("b.zip", t.Items[0].FileName); // 최신 우선 - } - - [Fact] - public void 진행률과_속도를_계산한다() - { - var t = new PageDownloadTracker(); - var op = new FakeOp { TotalBytesToReceive = 1000 }; - var item = t.Add(op, "f.zip"); - - item.UpdateSpeed(500, TimeSpan.FromSeconds(2)); // 500B/2s = 250 B/s - Assert.Equal(50.0, item.Percent, 2); - Assert.Equal(250, item.BytesPerSecond); - Assert.Equal("250.0 B/s", item.SpeedText); - } - - [Fact] - public void 속도_단위는_KB_MB로_표시된다() - { - var t = new PageDownloadTracker(); - var item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "f"); - - item.UpdateSpeed(0, TimeSpan.FromSeconds(1)); // 초기 - Assert.Equal("—", item.SpeedText); - - item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "g"); - item.UpdateSpeed(2048 * 100, TimeSpan.FromSeconds(1)); // 200 KB/s - Assert.Equal("200.0 KB/s", item.SpeedText); - - item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "h"); - item.UpdateSpeed(3 * 1024 * 1024L, TimeSpan.FromSeconds(1)); // 3 MB/s - Assert.Equal("3.0 MB/s", item.SpeedText); - } - - [Fact] - public void Pause_Resume_Cancel_위임() - { - var t = new PageDownloadTracker(); - var op = new FakeOp(); - var item = t.Add(op, "f"); - - t.Pause(item); t.Resume(item); t.Cancel(item); - Assert.True(op.Paused && op.Resumed && op.Cancelled); - } - - [Fact] - public void 완료_항목은_ClearCompleted로_일괄_제거() - { - var t = new PageDownloadTracker(); - var done = new FakeOp { State = "Completed" }; - var running = new FakeOp { State = "InProgress" }; - t.Add(done, "d"); t.Add(running, "r"); - - t.ClearCompleted(); - Assert.Single(t.Items); - Assert.Equal("r", t.Items[0].FileName); - } - - [Fact] - public void 취소된_항목도_ClearCompleted로_제거된다() - { - var t = new PageDownloadTracker(); - var c = new FakeOp { State = "Interrupted" }; - t.Add(c, "c"); - t.ClearCompleted(); - Assert.Empty(t.Items); - } -} diff --git a/VideoDownloader.Tests/Browser/PermissionGateTests.cs b/VideoDownloader.Tests/Browser/PermissionGateTests.cs deleted file mode 100644 index 3b3c9c1..0000000 --- a/VideoDownloader.Tests/Browser/PermissionGateTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -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/SiteZoomStoreTests.cs b/VideoDownloader.Tests/Browser/SiteZoomStoreTests.cs deleted file mode 100644 index d8c8ddd..0000000 --- a/VideoDownloader.Tests/Browser/SiteZoomStoreTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using VideoDownloader.Browser; - -namespace VideoDownloader.Tests.Browser; - -// T1-7 사이트별 줌 영속: 도메인→배율 저장, 범위 클램프, JSON 영속, 최대 항목 제한. -public class SiteZoomStoreTests : IDisposable -{ - private readonly string _path; - private readonly SiteZoomStore _store; - - public SiteZoomStoreTests() - { - _path = Path.Combine(Path.GetTempPath(), $"zoomstore_{Guid.NewGuid():N}.json"); - _store = new SiteZoomStore(_path); - } - - public void Dispose() - { - try { File.Delete(_path); } catch { } - } - - [Fact] - public void 미등록_사이트는_기본값_1_0을_반환한다() - { - Assert.Equal(1.0, _store.Get("https://example.com/page")); - } - - [Fact] - public void 배율을_저장하면_같은_호스트의_다른_경로에서도_읽힌다() - { - _store.Set("https://example.com/a", 1.5); - Assert.Equal(1.5, _store.Get("https://example.com/b/c/d")); - } - - [Fact] - public void www_접두사는_동일_호스트로_정규화된다() - { - _store.Set("https://www.example.com/a", 2.0); - Assert.Equal(2.0, _store.Get("https://example.com/b")); - } - - [Theory] - [InlineData(0.05, 0.1)] // 하한 클램프 - [InlineData(9.0, 5.0)] // 상한 클램프 - [InlineData(1.25, 1.25)] // 정상 범위 - public void 배율은_0_1에서_5_0으로_클램프된다(double input, double expected) - { - _store.Set("https://clamp.com/", input); - Assert.Equal(expected, _store.Get("https://clamp.com/")); - } - - [Fact] - public void 저장_즉시_파일에_영속되어_새_인스턴스가_읽는다() - { - _store.Set("https://persist.com/", 1.75); - Assert.True(File.Exists(_path)); - var reloaded = new SiteZoomStore(_path); - Assert.Equal(1.75, reloaded.Get("https://persist.com/other")); - } - - [Fact] - public void 최대_300개_초과시_가장_오래_사용하지_한_항목부터_제거된다() - { - // 300개 채우기 - for (int i = 0; i < 300; i++) - _store.Set($"https://site{i}.com/", 1.0 + i * 0.001); - // 가장 먼저 넣은 site0 을 최근에 다시 설정(LRU 갱신) → site1 이 제거 후보가 됨 - _store.Set("https://site0.com/", 1.9); - // 하나 더 추가 → 초과분 제거 - _store.Set("https://new.com/", 2.0); - - Assert.Equal(300, _store.Count); - Assert.Equal(1.0, _store.Get("https://site1.com/")); // 제거됨 → 기본값 - Assert.Equal(2.0, _store.Get("https://new.com/")); // 유지 - Assert.Equal(1.9, _store.Get("https://site0.com/")); // LRU 갱신됨 → 유지 - } - - [Fact] - public void Clear는_모든_항목과_파일을_지운다() - { - _store.Set("https://x.com/", 2.0); - _store.Clear(); - Assert.Equal(0, _store.Count); - Assert.Equal(1.0, _store.Get("https://x.com/")); - Assert.False(File.Exists(_path)); - } - - [Fact] - public void 손상된_JSON은_무시하고_빈_상태로_시작한다() - { - File.WriteAllText(_path, "{ not json !!"); - var store = new SiteZoomStore(_path); - Assert.Equal(0, store.Count); - } -} diff --git a/VideoDownloader.Tests/Browser/TabFeaturesTests.cs b/VideoDownloader.Tests/Browser/TabFeaturesTests.cs deleted file mode 100644 index 5b307fb..0000000 --- a/VideoDownloader.Tests/Browser/TabFeaturesTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using VideoDownloader.App.Browser.Features; -using VideoDownloader.Browser.Tabs; - -namespace VideoDownloader.Tests.Browser; - -// ── T2-2 탭 절전: 유휴 비활성 탭 자동 서스펜드 ── -public class TabSuspendServiceTests -{ - [Fact] - public void 활성_탭은_절전하지_않는다() - { - var m = new TabManager(() => new TabItem()); - var active = m.NewTab("https://a.com"); - var svc = new TabSuspendService(TimeSpan.FromMinutes(10), - t => throw new InvalidOperationException("suspend should not be called"), - t => t == active); - - svc.SuspendIdleTabs(m.Tabs, active, now: active.LastActivated.AddMinutes(30)); - Assert.False(active.IsSuspended); - } - - [Fact] - public void 유휴_비활성_탭은_절전한다() - { - var m = new TabManager(() => new TabItem()); - var old = m.NewTab("https://a.com"); - var active = m.NewTab("https://b.com"); - var suspended = new List(); - var svc = new TabSuspendService(TimeSpan.FromMinutes(10), - t => { suspended.Add(t); return true; }, - t => t != active); - - var now = old.LastActivated.AddMinutes(11); - svc.SuspendIdleTabs(m.Tabs, active, now); - Assert.Contains(old, suspended); - Assert.DoesNotContain(active, suspended); - } - - [Fact] - public void 이미_절전된_탭은_중복_서스펜드_하지_않는다() - { - var m = new TabManager(() => new TabItem()); - var old = m.NewTab("https://a.com"); - var active = m.NewTab("https://b.com"); - old.IsSuspended = true; - var calls = 0; - var svc = new TabSuspendService(TimeSpan.FromMinutes(10), t => { calls++; return true; }, t => t != active); - - svc.SuspendIdleTabs(m.Tabs, active, old.LastActivated.AddHours(1)); - Assert.Equal(0, calls); - } - - [Fact] - public void 최근에_활성화된_탭은_절전하지_않는다() - { - var m = new TabManager(() => new TabItem()); - var recent = m.NewTab("https://a.com"); - var active = m.NewTab("https://b.com"); - m.Activate(recent); // 방금 사용 - var calls = 0; - var svc = new TabSuspendService(TimeSpan.FromMinutes(10), t => { calls++; return true; }, t => t != active); - - svc.SuspendIdleTabs(m.Tabs, active, recent.LastActivated.AddMinutes(1)); - Assert.Equal(0, calls); - } -} - -// ── T2-8 탭 검색(Ctrl+Shift+A): 열린+닫힌 탭 필터 ── -public class TabSearchFilterTests -{ - [Fact] - public void 제목과_URL_부분일치로_필터링한다() - { - var m = new TabManager(() => new TabItem()); - m.NewTab("https://youtube.com/watch?v=1"); - m.Tabs[0].Title = "My Favorite Video"; - m.NewTab("https://news.example.com"); - m.Tabs[1].Title = "News"; - - var hits = TabSearchFilter.Search(m.Tabs, "favorite"); - Assert.Single(hits); - Assert.Equal("My Favorite Video", hits[0].Title); - - hits = TabSearchFilter.Search(m.Tabs, "youtube"); - Assert.Single(hits); - - hits = TabSearchFilter.Search(m.Tabs, "EXAMPLE"); // 대소문자 무시 - Assert.Single(hits); - } - - [Fact] - public void 닫힌_탭도_검색_대상에_포함된다() - { - var m = new TabManager(() => new TabItem()); - var t = m.NewTab("https://github.com/x"); - m.Tabs[0].Title = "GitHub Repo"; - m.CloseTab(t); - - var hits = TabSearchFilter.Search(m.Tabs, m.ClosedStack, "github"); - Assert.Single(hits); - Assert.Equal("GitHub Repo", hits[0].Title); - Assert.True(hits[0].IsClosed); - } - - [Fact] - public void 빈_질의는_빈_결과() - { - var m = new TabManager(() => new TabItem()); - m.NewTab("https://a.com"); - Assert.Empty(TabSearchFilter.Search(m.Tabs, "")); - Assert.Empty(TabSearchFilter.Search(m.Tabs, " ")); - } -} diff --git a/VideoDownloader.Tests/Browser/TabGroupsWorkspacesTests.cs b/VideoDownloader.Tests/Browser/TabGroupsWorkspacesTests.cs deleted file mode 100644 index ceda150..0000000 --- a/VideoDownloader.Tests/Browser/TabGroupsWorkspacesTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using VideoDownloader.Browser.Tabs; - -namespace VideoDownloader.Tests.Browser; - -// ── T2-11 탭 그룹: 색상 라벨 + 그룹 단위 이동/닫기 ── -public class TabGroupsTests -{ - private static TabManager NewManager() => new(() => new TabItem()); - - [Fact] - public void 그룹_지정과_해제() - { - var m = NewManager(); - var t = m.NewTab("https://a.com"); - m.SetGroup(t, "Work", "#e11"); - Assert.Equal("Work", t.GroupName); - Assert.Equal("#e11", t.GroupColor); - m.SetGroup(t, null, null); - Assert.Null(t.GroupName); - } - - [Fact] - public void 같은_그룹_탭은_서로_인접한다() - { - var m = NewManager(); - var a = m.NewTab("a"); m.SetGroup(a, "G", "#111"); - m.NewTab("b"); // 비그룹 - var c = m.NewTab("c"); m.SetGroup(c, "G", "#111"); // 그룹으로 → a 옆으로 이동 - Assert.Equal(1, m.Tabs.IndexOf(c)); - Assert.Equal(0, m.Tabs.IndexOf(a)); - } - - [Fact] - public void 그룹_탭_전체_닫기() - { - var m = NewManager(); - var a = m.NewTab("a"); m.SetGroup(a, "G", "#111"); - var b = m.NewTab("b"); m.SetGroup(b, "G", "#111"); - m.NewTab("solo"); - var closed = m.CloseGroup("G"); - Assert.Equal(2, closed); - Assert.Single(m.Tabs); - } -} - -// ── T3-1 워크스페이스: 탭 세트 저장/전환 ── -public class WorkspaceTests -{ - private static TabManager NewManager() => new(() => new TabItem()); - - [Fact] - public void 워크스페이스_저장후_스왑하면_탭_세트가_교체된다() - { - var m = NewManager(); - m.NewTab("https://work1.com"); - m.NewTab("https://work2.com"); - var ws = m.CaptureWorkspace("Work"); - - m.NewTab("https://personal.com"); - Assert.Equal(3, m.Tabs.Count); - - m.SwitchWorkspace(ws); - Assert.Equal(2, m.Tabs.Count); - Assert.Equal("https://work1.com", m.Tabs[0].Url); - Assert.Equal("https://work2.com", m.Tabs[1].Url); - Assert.Equal("Work", ws.Name); - } - - [Fact] - public void 워크스페이스_캡처는_현재_탭을_그대로_유지한다() - { - var m = NewManager(); - m.NewTab("https://a.com"); - var ws = m.CaptureWorkspace("X"); - Assert.Equal(1, m.Tabs.Count); // 캡처 자체는 현재 상태 유지(스냅샷) - Assert.Single(ws.Urls); - } -} diff --git a/VideoDownloader.Tests/Browser/TabManagerTests.cs b/VideoDownloader.Tests/Browser/TabManagerTests.cs deleted file mode 100644 index 1426b89..0000000 --- a/VideoDownloader.Tests/Browser/TabManagerTests.cs +++ /dev/null @@ -1,228 +0,0 @@ -using VideoDownloader.Browser.Tabs; - -namespace VideoDownloader.Tests.Browser; - -// Phase A 탭 아키텍처: TabManager 순수 로직(뷰 팩토리 주입 — WebView2 없이 검증). -public class TabManagerTests -{ - private static TabManager NewManager() => new(() => new TabItem()); - - [Fact] - public void NewTab은_탭을_추가하고_활성화한다() - { - var m = NewManager(); - var t = m.NewTab("https://a.com"); - Assert.Single(m.Tabs); - Assert.Same(t, m.ActiveTab); - Assert.Equal("https://a.com", t.Url); - } - - [Fact] - public void 두_번째_탭은_이전_탭을_비활성화하고_활성화된다() - { - var m = NewManager(); - var t1 = m.NewTab("https://a.com"); - var t2 = m.NewTab("https://b.com"); - Assert.Same(t2, m.ActiveTab); - Assert.False(t1.IsActive); - Assert.True(t2.IsActive); - } - - [Fact] - public void Activate는_활성_탭을_바꾸고_이벤트를_발생시킨다() - { - var m = NewManager(); - var t1 = m.NewTab(); - var t2 = m.NewTab(); - var fired = 0; - m.ActiveChanged += _ => fired++; - m.Activate(t1); - Assert.Same(t1, m.ActiveTab); - Assert.True(t1.IsActive); - Assert.False(t2.IsActive); - Assert.Equal(1, fired); - } - - [Fact] - public void CloseTab은_제거하고_이웃_탭을_활성화한다() - { - var m = NewManager(); - m.NewTab("https://a.com"); // index 0 - var t2 = m.NewTab("https://b.com"); // index 1 (active) - var t3 = m.NewTab("https://c.com"); // index 2 - m.Activate(t2); - - m.CloseTab(t2); - Assert.Equal(2, m.Tabs.Count); - Assert.DoesNotContain(t2, m.Tabs); - // 닫힌 탭 오른쪽이 있으면 그 탭(t3) 활성화 (Chrome 동작) - Assert.Same(t3, m.ActiveTab); - } - - [Fact] - public void 마지막_탭을_닫으면_새_빈_탭으로_대체된다() - { - var m = NewManager(); - var only = m.NewTab("https://solo.com"); - m.CloseTab(only); - Assert.Single(m.Tabs); - Assert.NotSame(only, m.ActiveTab); - Assert.Same(m.ActiveTab, m.Tabs[0]); - } - - [Fact] - public void 닫힌_탭은_스택에_쌓이고_ReopenClosedTab으로_원래_위치에_복원된다() - { - var m = NewManager(); - var t1 = m.NewTab("https://a.com"); - m.NewTab("https://b.com"); - m.NewTab("https://c.com"); // index 2, active - m.CloseTab(m.Tabs[2]); // 마지막 인덱스 탭 닫기 - - var reopened = m.ReopenClosedTab(); - Assert.NotNull(reopened); - Assert.Equal("https://c.com", reopened!.Url); - Assert.Equal(3, m.Tabs.Count); - Assert.Equal(2, m.Tabs.IndexOf(reopened)); // 원래 인덱스 복원 - Assert.Same(reopened, m.ActiveTab); - Assert.Empty(m.ClosedStack); - } - - [Fact] - public void 닫힌_탭_스택은_최대_25개다() - { - var m = NewManager(); - for (int i = 0; i < 30; i++) - { - var t = m.NewTab($"https://x{i}.com"); - m.CloseTab(t); - } - Assert.Equal(25, m.ClosedStack.Count); - Assert.Equal("https://x29.com", m.ClosedStack.First().Url); // LIFO 최신 우선 - } - - [Fact] - public void ActivateNext_Previous는_순환한다() - { - var m = NewManager(); - var t1 = m.NewTab("https://a.com"); - var t2 = m.NewTab("https://b.com"); - m.Activate(t1); - - m.ActivateNext(); - Assert.Same(t2, m.ActiveTab); - m.ActivateNext(); // 순환 - Assert.Same(t1, m.ActiveTab); - m.ActivatePrevious(); - Assert.Same(t2, m.ActiveTab); - } - - [Fact] - public void ActivateAt와_ActivateLast가_동작한다() - { - var m = NewManager(); - m.NewTab("a"); m.NewTab("b"); var last = m.NewTab("c"); - m.ActivateAt(0); - Assert.Same(m.Tabs[0], m.ActiveTab); - m.ActivateLast(); - Assert.Same(last, m.ActiveTab); - } - - [Fact] - public void Move는_탭_순서를_바꾼다() - { - var m = NewManager(); - var t1 = m.NewTab("a"); var t2 = m.NewTab("b"); var t3 = m.NewTab("c"); - m.Move(t1, 2); // a 를 끝으로 - Assert.Equal(2, m.Tabs.IndexOf(t1)); - Assert.Equal(0, m.Tabs.IndexOf(t2)); - Assert.Equal(1, m.Tabs.IndexOf(t3)); - } - - [Fact] - public void Pin은_탭을_좌측_끝으로_이동시키고_핀_표시한다() - { - var m = NewManager(); - m.NewTab("a"); m.NewTab("b"); - var t3 = m.NewTab("c"); - m.SetPinned(t3, true); - Assert.True(t3.IsPinned); - Assert.Equal(0, m.Tabs.IndexOf(t3)); // 핀 탭은 좌측 정렬 - m.SetPinned(t3, false); - Assert.False(t3.IsPinned); - } - - [Fact] - public void CaptureSession은_URL과_활성_인덱스와_핀을_담는다() - { - var m = NewManager(); - m.NewTab("https://a.com"); - var t2 = m.NewTab("https://b.com"); - m.NewTab("https://c.com"); - m.SetPinned(t2, true); // b 가 좌측으로 이동 → 순서: b,a,c - m.Activate(t2); - - var s = m.CaptureSession(); - Assert.Equal(new[] { "https://b.com", "https://a.com", "https://c.com" }, s.Urls); - Assert.Equal(0, s.ActiveIndex); - Assert.Equal(new[] { true, false, false }, s.Pinned); - } - - [Fact] - public void RestoreSession은_저장된_탭들을_재생성한다() - { - var m = NewManager(); - // CaptureSession 불변식: 핀 탭이 먼저 오는 순서로 저장됨 - var snap = new SessionSnapshot( - new() { "https://b.com", "https://a.com" }, 0, new() { true, false }); - m.RestoreSession(snap); - Assert.Equal(2, m.Tabs.Count); - Assert.Equal("https://b.com", m.ActiveTab!.Url); - Assert.True(m.Tabs[0].IsPinned); // 핀 탭이 좌측 - Assert.False(m.Tabs[1].IsPinned); - } - - [Fact] - public void RestoreSession은_기존_탭을_치환한다() - { - var m = NewManager(); - m.NewTab("https://old.com"); - m.RestoreSession(new SessionSnapshot(new() { "https://new.com" }, 0, new() { false })); - Assert.Single(m.Tabs); - Assert.Equal("https://new.com", m.Tabs[0].Url); - } -} - -// 세션 JSON 저장소(T1-10) 분리 테스트 -public class TabSessionStoreTests : IDisposable -{ - private readonly string _path = Path.Combine(Path.GetTempPath(), $"session_{Guid.NewGuid():N}.json"); - - [Fact] - public void 저장_후_로드_라운드트립() - { - var snap = new SessionSnapshot( - new() { "https://a.com", "https://b.com", "https://c.com" }, 2, new() { true, false, false }); - TabSessionStore.Save(_path, snap); - var loaded = TabSessionStore.Load(_path); - Assert.NotNull(loaded); - Assert.Equal(snap.Urls, loaded!.Urls); - Assert.Equal(2, loaded.ActiveIndex); - Assert.Equal(snap.Pinned, loaded.Pinned); - } - - [Fact] - public void 파일_없으면_null() - { - Assert.Null(TabSessionStore.Load(_path)); - } - - [Fact] - public void 손상된_JSON이면_null() - { - File.WriteAllText(_path, "{ broken"); - Assert.Null(TabSessionStore.Load(_path)); - } - - public void Dispose() { try { File.Delete(_path); } catch { } } -} diff --git a/VideoDownloader.Tests/Browser/TrackerBlockTests.cs b/VideoDownloader.Tests/Browser/TrackerBlockTests.cs deleted file mode 100644 index aaa490b..0000000 --- a/VideoDownloader.Tests/Browser/TrackerBlockTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index a704dcb..0000000 --- a/VideoDownloader.Tests/Browser/UrlCleanerTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -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/IconContentFontTests.cs b/VideoDownloader.Tests/E2E/IconContentFontTests.cs deleted file mode 100644 index df1fdef..0000000 --- a/VideoDownloader.Tests/E2E/IconContentFontTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using VideoDownloader.App; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// 아이콘 tofu 실결함(사용자 보고 "아이콘은 절대 안고쳐지네") 회귀 테스트 — -/// 근본 원인: Shared.xaml 의 암시적 TextBlock 스타일이 FontFamily 를 UiFont 로 강제해, -/// 버튼 ContentPresenter 가 만드는 자동 TextBlock 이 IconFont 상속을 잃고 PUA 글리프가 notdef 가 됐다. -/// 아이콘 버튼의 실제 콘텐츠 TextBlock 이 아이콘 폰트로 해석되는지 실창에서 검증한다. -/// -[Collection("E2E")] -public class IconContentFontTests -{ - private static TextBlock? FindTextBlock(DependencyObject root) - { - if (root is TextBlock tb) return tb; - for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) - { - if (FindTextBlock(VisualTreeHelper.GetChild(root, i)) is { } found) return found; - } - return null; - } - - [Fact] - public async Task 아이콘_버튼의_콘텐츠_TextBlock_은_아이콘폰트로_렌더된다() - { - var dataDir = Path.Combine(Path.GetTempPath(), $"vd_iconfont_{Guid.NewGuid():N}"); - Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir); - try - { - using var sta = StaDispatcher.Start(); - WpfTestApp.Ensure(sta.Dispatcher); - var (navFamily, captionFamily, bodyFamily) = await sta.Dispatcher.InvokeAsync(() => - { - // 앱과 동일 조건: 절대 pack URI 테마 재로드 후 실창 - VideoDownloader.App.App.ApplyTheme("dark"); - var win = new MainWindow { Left = 20, Top = 700, WindowStartupLocation = WindowStartupLocation.Manual }; - win.Show(); - win.UpdateLayout(); - - var nav = (Button)win.FindName("NavBack")!; - var navText = FindTextBlock(nav)!; - - // 캡션(최대화/복원) 버튼 — CaptionButton 스타일 경유 - var caption = win.FindName("MaxRestoreBtn") as Button; - var captionText = caption != null ? FindTextBlock(caption) : null; - - // 일반 본문 TextBlock 은 여전히 UI 폰트를 따라야 한다(암시 스타일의 원래 목적) - var status = (TextBlock)win.FindName("StatusText")!; - - var result = (navText.FontFamily.Source, captionText?.FontFamily.Source ?? "(캡션 버튼 없음)", status.FontFamily.Source); - win.Close(); - return result; - }); - - Assert.Equal("Segoe Fluent Icons", navFamily); - if (captionFamily != "(캡션 버튼 없음)") - Assert.Equal("Segoe Fluent Icons", captionFamily); - Assert.Contains("Pretendard", bodyFamily); // 본문은 UI 폰트 유지 - } - finally - { - Environment.SetEnvironmentVariable("VD_DATA_DIR", null); - try { Directory.Delete(dataDir, true); } catch { } - } - } -} diff --git a/VideoDownloader.Tests/E2E/IconGlyphRenderTests.cs b/VideoDownloader.Tests/E2E/IconGlyphRenderTests.cs deleted file mode 100644 index 023bc85..0000000 --- a/VideoDownloader.Tests/E2E/IconGlyphRenderTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// 아이콘 tofu 실결함(사용자 보고: "아이콘은 절대 안고쳐지네") 재현 시도 — -/// 테마 사전을 앱과 같은 방식으로 로드했을 때 FluentIcon/IconButton 경유 글리프가 -/// 명시적 Segoe Fluent Icons 렌더와 픽셀 단위로 같은지 검증한다. -/// 같지 않고 본문 폰트 렌더(tofu)와 같다면 리소스 해석이 깨진 것이다. -/// -[Collection("E2E")] -public class IconGlyphRenderTests -{ - private static byte[] Render(FrameworkElement el) - { - el.Measure(new Size(48, 48)); - el.Arrange(new Rect(0, 0, 48, 48)); - el.UpdateLayout(); - var rtb = new RenderTargetBitmap(48, 48, 96, 96, PixelFormats.Pbgra32); - rtb.Render(el); - var px = new byte[48 * 48 * 4]; - rtb.CopyPixels(px, 48 * 4, 0); - return px; - } - - private static int Lit(byte[] px) - { - var lit = 0; - for (var i = 3; i < px.Length; i += 4) if (px[i] > 60) lit++; - return lit; - } - - [Fact] - public async Task FluentIcon_스타일_글리프는_명시적_아이콘폰트_렌더와_동일하다() - { - using var sta = StaDispatcher.Start(); - WpfTestApp.Ensure(sta.Dispatcher); - var (family, litStyled, litExplicit, litBody, sameAsExplicit, sameAsBody) = await sta.Dispatcher.InvokeAsync(() => - { - TextBlock Make() => new() - { - Text = "", FontSize = 14, Foreground = Brushes.White, - Background = Brushes.Black, Width = 48, Height = 48, - }; - - var styled = Make(); - styled.Style = (Style)Application.Current.FindResource("FluentIcon"); - - var explicitIcon = Make(); - explicitIcon.FontFamily = new FontFamily("Segoe Fluent Icons"); - - var body = Make(); - body.FontFamily = (FontFamily)Application.Current.FindResource("UiFont"); - - var pStyled = Render(styled); - var pExplicit = Render(explicitIcon); - var pBody = Render(body); - - return (styled.FontFamily.Source, Lit(pStyled), Lit(pExplicit), Lit(pBody), - pStyled.SequenceEqual(pExplicit), pStyled.SequenceEqual(pBody)); - }); - - Assert.True(litExplicit > 0, "기준(명시적 Segoe Fluent Icons) 렌더 자체가 비어 있음 — 폰트 미설치?"); - Assert.True(sameAsExplicit && !sameAsBody, - $"FluentIcon 경유 글리프가 아이콘 폰트로 렌더되지 않음 — 해석된 폰트: '{family}', " + - $"픽셀(스타일/명시/본문): {litStyled}/{litExplicit}/{litBody}, 본문과 동일: {sameAsBody}"); - } - - [Fact] - public async Task 앱_시작_시퀀스를_그대로_태워도_FluentIcon_글리프가_렌더된다() - { - // 실제 App.OnStartup 과 동일한 순서: ApplyTheme(절대 pack URI 재로드) → 강조색 → 폰트 → 모션 - var dataDir = Path.Combine(Path.GetTempPath(), $"vd_icon_{Guid.NewGuid():N}"); - Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir); - try - { - using var sta = StaDispatcher.Start(); - WpfTestApp.Ensure(sta.Dispatcher); - var (family, same, litStyled, litExplicit) = await sta.Dispatcher.InvokeAsync(() => - { - VideoDownloader.App.App.ApplyTheme("dark"); - VideoDownloader.App.App.ApplyAccent(null); - VideoDownloader.App.App.ApplyFontSettings(); - VideoDownloader.App.App.ApplyReduceMotion(false); - - var styled = new TextBlock - { - Text = "\uE76B", FontSize = 14, Foreground = Brushes.White, - Background = Brushes.Black, Width = 48, Height = 48, - Style = (Style)Application.Current.FindResource("FluentIcon"), - }; - var explicitIcon = new TextBlock - { - Text = "\uE76B", FontSize = 14, Foreground = Brushes.White, - Background = Brushes.Black, Width = 48, Height = 48, - FontFamily = new FontFamily("Segoe Fluent Icons"), - }; - var pStyled = Render(styled); - var pExplicit = Render(explicitIcon); - return (styled.FontFamily.Source, pStyled.SequenceEqual(pExplicit), Lit(pStyled), Lit(pExplicit)); - }); - - Assert.True(same, - $"앱 시작 시퀀스 후 FluentIcon 글리프 깨짐 — 해석된 폰트: '{family}', 픽셀(스타일/명시): {litStyled}/{litExplicit}"); - } - finally - { - Environment.SetEnvironmentVariable("VD_DATA_DIR", null); - try { Directory.Delete(dataDir, true); } catch { } - } - } -} diff --git a/VideoDownloader.Tests/E2E/PermissionTrackerE2ETests.cs b/VideoDownloader.Tests/E2E/PermissionTrackerE2ETests.cs deleted file mode 100644 index 8f493cd..0000000 --- a/VideoDownloader.Tests/E2E/PermissionTrackerE2ETests.cs +++ /dev/null @@ -1,109 +0,0 @@ -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/E2E/ReaderModeE2ETests.cs b/VideoDownloader.Tests/E2E/ReaderModeE2ETests.cs deleted file mode 100644 index 3b9f622..0000000 --- a/VideoDownloader.Tests/E2E/ReaderModeE2ETests.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Windows; -using Microsoft.Web.WebView2.Core; -using VideoDownloader.App.Browser.Features; -using VideoDownloader.Browser.Tabs; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// E2E (T2-4): 실제 WebView2 + 번들된 Mozilla Readability.js 로 -/// 기사 페이지에서 본문을 추출해 리더 뷰로 교체하고 원본 URL 복귀까지 검증. -/// -[Collection("E2E")] -public class ReaderModeE2ETests -{ - private const string ArticleHtml = """ -테스트 기사 - -
-

리더 모드 E2E 기사

-

첫 번째 문단입니다. 이 문단은 Readability 가 본문으로 추출해야 합니다. 충분한 길이의 텍스트가 필요하므로 몇 번 더 반복합니다. 본문 본문 본문.

-

두 번째 문단입니다. 리더 모드는 광고와 내비게이션을 제거하고 본문만 남깁니다. 추출 결과에 이 텍스트가 포함되어야 합니다.

-

세 번째 문단입니다. 문단이 세 개 이상이면 Readability 의 휴리스틱이 본문으로 판정합니다. 마커텍스트-고유문구-레인보우4747.

-
-
푸터 링크 모음
- -"""; - - [Fact] - public async Task 기사를_리더로_추출하고_원본으로_복귀한다() - { - using var sta = StaDispatcher.Start(); - var udf = Path.Combine(Path.GetTempPath(), $"vd_reader_{Guid.NewGuid():N}"); - var ok = 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; - - cv.NavigateToString(ArticleHtml); - await WaitNav(cv); - - var tab = new TabItem { View = browser }; - - // 1) 리더 진입 - var entered = await ReaderMode.ToggleAsync(tab, lightTheme: false); - await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""), t => t.Contains("테스트 기사"), 15000); - var titleAfter = cv.DocumentTitle ?? ""; - var bodyText = await cv.ExecuteScriptAsync("document.body.innerText"); - var hasMarker = bodyText.Contains("마커텍스트-고유문구-레인보우4747"); - var hasNav = bodyText.Contains("광고 광고 광고"); - var isReader = tab.IsReader; - var originalSaved = tab.ReaderOriginalUrl; - - // 2) 복귀 - var exited = await ReaderMode.ToggleAsync(tab, lightTheme: false); - var restoredReader = tab.IsReader == false; - - window.Close(); - return (entered: entered == true, titleAfter, hasMarker, navRemoved: !hasNav, isReader, originalSaved, exited: exited == false, restoredReader); - }); - - try { Directory.Delete(udf, true); } catch { } - Assert.True(ok.entered, "리더 진입 실패"); - Assert.Contains("테스트 기사", ok.titleAfter); - Assert.True(ok.hasMarker, "본문 마커 누락"); - Assert.True(ok.navRemoved, "내비/광고 제거 안됨"); - Assert.True(ok.isReader); - Assert.True(ok.exited); - Assert.True(ok.restoredReader); - } - - private static Task WaitNav(CoreWebView2 cv) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - EventHandler? h = null; - h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); }; - cv.NavigationCompleted += h; - return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15)); - } -} diff --git a/VideoDownloader.Tests/E2E/SettingsUpdateUiE2ETests.cs b/VideoDownloader.Tests/E2E/SettingsUpdateUiE2ETests.cs deleted file mode 100644 index a7236c0..0000000 --- a/VideoDownloader.Tests/E2E/SettingsUpdateUiE2ETests.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Windows; -using VideoDownloader.App; -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// E2E: 설정창 "앱 업데이트" 섹션(DEPLOYMENT_PLAN §6 자동 업데이트 v1) — -/// 실제 SettingsWindow 를 띄워 설정값 로드 배선과 잘못된 저장소 형식의 즉시 안내(네트워크 불필요 경로)를 검증. -/// -[Collection("E2E")] -public class SettingsUpdateUiE2ETests -{ - [Fact] - public void 설정창_업데이트_저장소_로드와_형식오류_안내() - { - // VD_DATA_DIR 격리: SettingsWindow 가 참조하는 프로필 경로를 실사용자와 분리 - var dataDir = Path.Combine(Path.GetTempPath(), $"vd_set_{Guid.NewGuid():N}"); - Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir); - try - { - using var sta = StaDispatcher.Start(); - WpfTestApp.Ensure(sta.Dispatcher); - sta.Invoke(() => - { - var config = new AppConfig { UpdateRepo = "user/videodownloader" }; - var win = new SettingsWindow(config); - - // 설정 → UI 로드 배선 - Assert.Equal("user/videodownloader", win.UpdateRepoBox.Text); - - // owner/repo 형식이 아니면 네트워크 호출 없이 즉시 안내 - win.UpdateRepoBox.Text = "invalid-format"; - win.CheckUpdate_Click(null, new RoutedEventArgs()); - Assert.Contains("owner/repo", win.UpdateResultText.Text); - - win.Close(); - }); - } - finally - { - Environment.SetEnvironmentVariable("VD_DATA_DIR", null); - try { Directory.Delete(dataDir, recursive: true); } catch (DirectoryNotFoundException) { } - } - } -} diff --git a/VideoDownloader.Tests/E2E/UiUsecaseAccessibilityRedTests.cs b/VideoDownloader.Tests/E2E/UiUsecaseAccessibilityRedTests.cs deleted file mode 100644 index 5f0064a..0000000 --- a/VideoDownloader.Tests/E2E/UiUsecaseAccessibilityRedTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// UI 복잡 버전 RED 스펙 — §G 접근성 유즈케이스(docs/UI_USECASES.md §G, UC-A01~A20). -/// RED 단계: 전부 실패하는 것이 정상이다. -/// -public class UiUsecaseAccessibilityRedTests -{ - [Fact] - public void UC_A01_자동화_속성이_하나라도_사용된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("AutomationProperties"), - "UC-A01", "화면 낭독기 지원을 위한 AutomationProperties 사용이 필요하다"); - - [Fact] - public void UC_A02_주요_컨트롤에_자동화_이름이_부여된다() - { - var count = System.Text.RegularExpressions.Regex.Matches(SourceTree.MainWindowXaml, "AutomationProperties\\.Name").Count; - SourceTree.Usecase( - count >= 15, - $"UC-A02", $"주요 컨트롤 15개 이상에 AutomationProperties.Name 이 필요하다 (현재 {count}개)"); - } - - [Fact] - public void UC_A03_상태_변화가_낭독기에_자동_전달된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("LiveSetting"), - "UC-A03", "상태바·진행률의 LiveSetting 선언이 필요하다"); - - [Fact] - public void UC_A04_시스템_고대비_모드를_감지한다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("HighContrast"), - "UC-A04", "SystemParameters.HighContrast 감지 처리가 필요하다"); - - [Fact] - public void UC_A05_키보드_포커스_비주얼이_정의된다() - => SourceTree.Usecase( - SourceTree.SharedXaml.Contains("FocusVisualStyle"), - "UC-A05", "공용 FocusVisualStyle 정의가 필요하다"); - - [Fact] - public void UC_A06_Tab_순서가_지정된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("TabIndex"), - "UC-A06", "논리적 Tab 이동을 위한 TabIndex 지정이 필요하다"); - - [Fact] - public void UC_A07_ToolTipService_설정이_사용된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("ToolTipService"), - "UC-A07", "툴팁 표시 시간 등 ToolTipService 설정이 필요하다"); - - [Fact] - public void UC_A08_접근_키_AccessText가_사용된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("AccessText"), - "UC-A08", "Alt 접근 키를 위한 AccessText 사용이 필요하다"); - - [Fact] - public void UC_A09_사용자_지정_자동화_피어가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("AutomationPeer"), - "UC-A09", "목록·탭 컨트롤용 AutomationPeer 구현이 필요하다"); - - [Fact] - public void UC_A10_HelpText가_제공된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("AutomationProperties.HelpText"), - "UC-A10", "컨트롤 추가 설명 AutomationProperties.HelpText 가 필요하다"); - - [Fact] - public void UC_A11_완료_상태가_색과_무관하게_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("SuccessIcon"), - "UC-A11", "완료 상태 아이콘(SuccessIcon)으로 색 무의존 표시가 필요하다"); - - [Fact] - public void UC_A12_접근성_설정_섹션이_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("접근성"), - "UC-A12", "설정창에 '접근성' 섹션이 필요하다"); - - [Fact] - public void UC_A13_UI_확대_배율을_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "UiFontScale"), - "UC-A13", "저시력 지원용 AppConfig.UiFontScale 속성이 필요하다"); - - [Fact] - public void UC_A14_팝업_키보드_탐색이_설정된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("KeyboardNavigation.TabNavigation"), - "UC-A14", "팝업 내 키보드 탐색(KeyboardNavigation) 설정이 필요하다"); - - [Fact] - public void UC_A15_자막_표시_옵션이_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "ShowCaptions"), - "UC-A15", "AppConfig.ShowCaptions 속성이 필요하다"); - - [Fact] - public void UC_A16_애니메이션_감소_설정이_스타일에_반영된다() - => SourceTree.Usecase( - SourceTree.SharedXaml.Contains("ReduceMotion"), - "UC-A16", "Shared.xaml 이 ReduceMotion 설정을 스타일에 반영해야 한다"); - - [Fact] - public void UC_A17_리더_모드_HTML에_언어가_선언된다() - => SourceTree.Usecase( - SourceTree.ReaderModeCs.Contains("lang="), - "UC-A17", "리더 모드 HTML 에 lang=\"ko\" 선언이 필요하다"); - - [Fact] - public void UC_A18_접근성_선언_문서가_있다() - => SourceTree.Usecase( - SourceTree.DocFileExists("ACCESSIBILITY.md"), - "UC-A18", "접근성 지원 범위 문서(docs/ACCESSIBILITY.md)가 필요하다"); - - [Fact] - public void UC_A19_라이트_진행_트랙이_배경과_구별된다() - { - var fg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "TrackBrush"); - var bg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "PanelBg"); - Assert.False(fg is null || bg is null, "UC-A19 — 테마 브러시 파싱 실패"); - var ratio = SourceTree.ContrastRatio(fg!, bg!); - Assert.True(ratio >= 3.0, $"UC-A19 RED — 라이트 TrackBrush/PanelBg 대비 {ratio:F2}:1 < 3:1 (비텍스트 구성요소 기준)"); - } - - [Fact] - public void UC_A20_버튼_테두리가_카드_배경과_구별된다() - { - var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "BorderBrush"); - var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBg"); - Assert.False(fg is null || bg is null, "UC-A20 — 테마 브러시 파싱 실패"); - var ratio = SourceTree.ContrastRatio(fg!, bg!); - Assert.True(ratio >= 3.0, $"UC-A20 RED — 다크 BorderBrush/CardBg 대비 {ratio:F2}:1 < 3:1 (클릭 대상 식별 기준)"); - } -} diff --git a/VideoDownloader.Tests/E2E/UiUsecaseDownloadsRedTests.cs b/VideoDownloader.Tests/E2E/UiUsecaseDownloadsRedTests.cs deleted file mode 100644 index 70b0c65..0000000 --- a/VideoDownloader.Tests/E2E/UiUsecaseDownloadsRedTests.cs +++ /dev/null @@ -1,194 +0,0 @@ -using VideoDownloader.App; -using VideoDownloader.App.Browser.Features; -using VideoDownloader.App.ViewModels; -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// UI 복잡 버전 RED 스펙 — §E 다운로드 관리자 유즈케이스(docs/UI_USECASES.md §E, UC-D01~D30). -/// RED 단계: 전부 실패하는 것이 정상이다. -/// -public class UiUsecaseDownloadsRedTests -{ - [Fact] - public void UC_D01_다운로드_정렬_순서를_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "DownloadSortOrder"), - "UC-D01", "AppConfig.DownloadSortOrder 속성이 필요하다"); - - [Fact] - public void UC_D02_진행중_완료_실패_필터_탭이_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("DownloadFilterTabs"), - "UC-D02", "다운로드 필터 탭(DownloadFilterTabs) 요소가 필요하다"); - - [Fact] - public void UC_D03_다운로드_목록을_검색할_수_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("DownloadSearchBox"), - "UC-D03", "다운로드 검색 상자(DownloadSearchBox)가 필요하다"); - - [Fact] - public void UC_D04_남은_시간_ETA가_카드에_표시된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(DownloadItemViewModel), "RemainingText"), - "UC-D04", "다운로드 카드 VM 에 RemainingText(남은 시간) 속성이 필요하다"); - - [Fact] - public void UC_D05_현재_속도가_카드에_표시된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(DownloadItemViewModel), "SpeedText"), - "UC-D05", "다운로드 카드 VM 에 SpeedText 속성이 필요하다"); - - [Fact] - public void UC_D06_전체_일시정지를_할_수_있다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(MainWindow), "PauseAllDownloads"), - "UC-D06", "MainWindow.PauseAllDownloads() 가 필요하다"); - - [Fact] - public void UC_D07_전체_재개를_할_수_있다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(MainWindow), "ResumeAllDownloads"), - "UC-D07", "MainWindow.ResumeAllDownloads() 가 필요하다"); - - [Fact] - public void UC_D08_완료_항목_자동_치움을_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "AutoClearCompleted"), - "UC-D08", "AppConfig.AutoClearCompleted 속성이 필요하다"); - - [Fact] - public void UC_D09_재시도_횟수가_카드에_표시된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(DownloadItemViewModel), "RetryCount"), - "UC-D09", "다운로드 카드 VM 에 RetryCount 속성이 필요하다"); - - [Fact] - public void UC_D10_세그먼트_진행이_시각화된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(DownloadItemViewModel), "SegmentMap"), - "UC-D10", "세그먼트별 진행 맵(SegmentMap) 속성이 필요하다"); - - [Fact] - public void UC_D11_토스트_알림_컴포넌트가_있다() - => SourceTree.Usecase( - SourceTree.FindType("ToastNotifier") is not null, - "UC-D11", "다운로드 완료 알림을 담당하는 ToastNotifier 컴포넌트가 필요하다"); - - [Fact] - public void UC_D12_알림_소리를_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "NotifySound"), - "UC-D12", "AppConfig.NotifySound 속성이 필요하다"); - - [Fact] - public void UC_D13_완료_시_폴더_자동_열기를_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "OpenFolderOnComplete"), - "UC-D13", "AppConfig.OpenFolderOnComplete 속성이 필요하다"); - - [Fact] - public void UC_D14_카드_우클릭_컨텍스트_메뉴가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("CardContextMenu"), - "UC-D14", "다운로드 카드 컨텍스트 메뉴(CardContextMenu)가 필요하다"); - - [Fact] - public void UC_D15_카드를_펼쳐_상세_정보를_본다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("CardDetailExpander"), - "UC-D15", "카드 상세 펼침(CardDetailExpander) 요소가 필요하다"); - - [Fact] - public void UC_D16_대기_현황_요약이_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("QueueSummaryText"), - "UC-D16", "패널의 대기 현황 요약(QueueSummaryText)이 필요하다"); - - [Fact] - public void UC_D17_속도_스파크라인이_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("SpeedSparkline"), - "UC-D17", "속도 추이 스파크라인(SpeedSparkline) 요소가 필요하다"); - - [Fact] - public void UC_D18_전체_속도_합계가_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("GlobalSpeedText"), - "UC-D18", "전체 다운로드 속도(GlobalSpeedText) 표시가 필요하다"); - - [Fact] - public void UC_D19_파일_총_크기가_카드에_표시된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(DownloadItemViewModel), "TotalSizeText"), - "UC-D19", "카드 VM 에 TotalSizeText 속성이 필요하다"); - - [Fact] - public void UC_D20_자막_언어를_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("SubtitleLangBox"), - "UC-D20", "설정창에 자막 언어 선택(SubtitleLangBox)이 필요하다"); - - [Fact] - public void UC_D21_다운로드_스케줄러가_있다() - => SourceTree.Usecase( - SourceTree.FindType("DownloadScheduler") is not null, - "UC-D21", "시간대 예약을 담당하는 DownloadScheduler 컴포넌트가 필요하다"); - - [Fact] - public void UC_D22_속도_상한을_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "MaxDownloadSpeedKbps"), - "UC-D22", "AppConfig.MaxDownloadSpeedKbps 속성이 필요하다"); - - [Fact] - public void UC_D23_목록을_CSV로_내보낼_수_있다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(MainWindow), "ExportDownloads"), - "UC-D23", "MainWindow.ExportDownloads() 내보내기 기능이 필요하다"); - - [Fact] - public void UC_D24_항목_우선순위를_정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(DownloadItemViewModel), "Priority"), - "UC-D24", "카드 VM 에 대기열 우선순위(Priority) 속성이 필요하다"); - - [Fact] - public void UC_D25_중복_다운로드_경고를_받는다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(MainWindow), "ConfirmDuplicateDownload"), - "UC-D25", "MainWindow.ConfirmDuplicateDownload() 확인 절차가 필요하다"); - - [Fact] - public void UC_D26_최종_저장_파일명이_카드에_표시된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(DownloadItemViewModel), "FinalFileName"), - "UC-D26", "카드 VM 에 충돌 회피 후 파일명(FinalFileName)이 필요하다"); - - [Fact] - public void UC_D27_오류_메시지_복사_버튼이_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("CopyErrorButton"), - "UC-D27", "오류 복사 버튼(CopyErrorButton) 요소가 필요하다"); - - [Fact] - public void UC_D28_페이지_다운로드도_전체_일시정지가_된다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(PageDownloadTracker), "PauseAll"), - "UC-D28", "PageDownloadTracker.PauseAll() 메서드가 필요하다"); - - [Fact] - public void UC_D29_미디어_종류_배지가_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("MediaKindBadge"), - "UC-D29", "감지 미디어 종류 배지(MediaKindBadge) 요소가 필요하다"); - - [Fact] - public void UC_D30_사이트별_그룹_헤더가_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("SiteGroupHeader"), - "UC-D30", "사이트별 묶음 헤더(SiteGroupHeader) 요소가 필요하다"); -} diff --git a/VideoDownloader.Tests/E2E/UiUsecaseInteractionRedTests.cs b/VideoDownloader.Tests/E2E/UiUsecaseInteractionRedTests.cs deleted file mode 100644 index 60627aa..0000000 --- a/VideoDownloader.Tests/E2E/UiUsecaseInteractionRedTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// UI 복잡 버전 RED 스펙 — §H 키보드·마우스 인터랙션 유즈케이스(docs/UI_USECASES.md §H, UC-K01~K20). -/// RED 단계: 전부 실패하는 것이 정상이다. -/// -public class UiUsecaseInteractionRedTests -{ - [Fact] - public void UC_K01_단축키_설정_항목이_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("단축키"), - "UC-K01", "설정창에 '단축키' 항목이 필요하다"); - - [Fact] - public void UC_K02_단축키를_사용자가_바꿀_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "Shortcuts"), - "UC-K02", "AppConfig.Shortcuts 사용자 지정 단축키 맵이 필요하다"); - - [Fact] - public void UC_K03_단축키_관리_컴포넌트가_있다() - => SourceTree.Usecase( - SourceTree.FindType("ShortcutManager") is not null, - "UC-K03", "충돌 검증을 포함한 ShortcutManager 컴포넌트가 필요하다"); - - [Fact] - public void UC_K04_단축키_전체_목록_팝업이_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("ShortcutHelpPopup"), - "UC-K04", "단축키 도움말 팝업(ShortcutHelpPopup)이 필요하다"); - - [Fact] - public void UC_K05_마우스_제스처로_탐색할_수_있다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("MouseGesture") || SourceTree.MainWindowCs.Contains("XButton"), - "UC-K05", "마우스 제스처(XButton 등) 뒤로/앞처 처리가 필요하다"); - - [Fact] - public void UC_K06_휠_클릭으로_새_탭을_연다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("MiddleButton"), - "UC-K06", "링크 휠 클릭(MiddleButton) 새 탭 처리가 필요하다"); - - [Fact] - public void UC_K07_붙여넣기_즉시_이동_옵션이_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "PasteAndGo"), - "UC-K07", "AppConfig.PasteAndGo 속성이 필요하다"); - - [Fact] - public void UC_K08_주소창_포커스_시_전체_선택_옵션이_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "FocusSelectsAddress"), - "UC-K08", "AppConfig.FocusSelectsAddress 속성이 필요하다"); - - [Fact] - public void UC_K09_모두_일시정지_빠른_명령이_있다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("모두 일시정지"), - "UC-K09", "팔레트/버튼의 '모두 일시정지' 명령이 필요하다"); - - [Fact] - public void UC_K10_Esc로_모든_팝업을_한번에_닫는다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("CloseAllPopups") || SourceTree.MainWindowCs.Contains("CloseAllOverlays"), - "UC-K10", "열린 팝업 일괄 닫기(CloseAllPopups) 처리가 필요하다"); - - [Fact] - public void UC_K11_다운로드_중_종료_확인을_받는다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("종료하시겠습니까"), - "UC-K11", "다운로드 진행 중 종료 확인 대화상자가 필요하다"); - - [Fact] - public void UC_K12_F12_개발자_도구를_연다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("OpenDevTools"), - "UC-K12", "F12 개발자 도구(OpenDevTools) 처리가 필요하다"); - - [Fact] - public void UC_K13_프로필_전환_기능이_있다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("프로필 전환"), - "UC-K13", "세션 프로필 전환 기능이 필요하다"); - - [Fact] - public void UC_K14_현재_줌_배율이_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("ZoomIndicator"), - "UC-K14", "줌 배율 표시(ZoomIndicator) 요소가 필요하다"); - - [Fact] - public void UC_K15_기본_줌을_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "DefaultZoomFactor"), - "UC-K15", "AppConfig.DefaultZoomFactor 속성이 필요하다"); - - [Fact] - public void UC_K16_즐겨찾기_관리_창이_있다() - => SourceTree.Usecase( - SourceTree.FindType("BookmarkManagerWindow") is not null, - "UC-K16", "즐겨찾기 관리 창(BookmarkManagerWindow)이 필요하다"); - - [Fact] - public void UC_K17_방문_기록_개별_삭제가_된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("DeleteHistory_Click"), - "UC-K17", "방문 기록 개별 삭제(DeleteHistory_Click) 처리기가 필요하다"); - - [Fact] - public void UC_K18_영역을_지정해_캡처할_수_있다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("RegionCapture"), - "UC-K18", "영역 지정 캡처(RegionCapture) 기능이 필요하다"); - - [Fact] - public void UC_K19_노트를_Ctrl_S로_저장한다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("NotesSave_Click"), - "UC-K19", "노트 저장(NotesSave_Click) 처리기가 필요하다"); - - [Fact] - public void UC_K20_링크_우클릭으로_바로_받는다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("링크 다운로드"), - "UC-K20", "페이지 링크 우클릭 '링크 다운로드' 메뉴가 필요하다"); -} diff --git a/VideoDownloader.Tests/E2E/UiUsecaseLayoutRedTests.cs b/VideoDownloader.Tests/E2E/UiUsecaseLayoutRedTests.cs deleted file mode 100644 index 958f4f5..0000000 --- a/VideoDownloader.Tests/E2E/UiUsecaseLayoutRedTests.cs +++ /dev/null @@ -1,168 +0,0 @@ -using VideoDownloader.App; -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// UI 복잡 버전 RED 스펙 — §C 레이아웃·밀도·창 상태 유즈케이스(docs/UI_USECASES.md §C, UC-L01~L26). -/// RED 단계: 전부 실패하는 것이 정상이다. -/// -public class UiUsecaseLayoutRedTests -{ - [Fact] - public void UC_L01_UI_밀도_설정이_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "UiDensity"), - "UC-L01", "컴팩트/보통/여유 밀도를 고르는 AppConfig.UiDensity 가 필요하다"); - - [Fact] - public void UC_L02_설정창에서_밀도를_바꿀_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("밀도"), - "UC-L02", "설정창에 '밀도' 항목이 노출되어야 한다"); - - [Fact] - public void UC_L03_작은_간격_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "Spacing.S"), - "UC-L03", "간격 토큰 Spacing.S 가 필요하다"); - - [Fact] - public void UC_L04_중간_간격_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "Spacing.M"), - "UC-L04", "간격 토큰 Spacing.M 이 필요하다"); - - [Fact] - public void UC_L05_큰_간격_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "Spacing.L"), - "UC-L05", "간격 토큰 Spacing.L 이 필요하다"); - - [Fact] - public void UC_L06_카드_모서리_둥글기_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "CornerRadius.Card"), - "UC-L06", "카드 둥근 모서리 토큰 CornerRadius.Card 가 필요하다"); - - [Fact] - public void UC_L07_컴팩트_모드_상태가_저장된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "CompactMode"), - "UC-L07", "AppConfig.CompactMode 로 재시작 후에도 컴팩트 상태가 유지되어야 한다"); - - [Fact] - public void UC_L08_툴바에_컴팩트_토글_버튼이_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("Name=\"CompactModeBtn\""), - "UC-L08", "툴바의 CompactModeBtn 로 컴팩트 모드를 빠르게 켜고 끌 수 있어야 한다"); - - [Fact] - public void UC_L09_세로_탭_기본_여부를_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "VerticalTabsDefault"), - "UC-L09", "AppConfig.VerticalTabsDefault 속성이 필요하다"); - - [Fact] - public void UC_L10_분할_화면_비율이_저장된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "SplitViewRatio"), - "UC-L10", "분할 화면 비율(AppConfig.SplitViewRatio)이 저장·복원되어야 한다"); - - [Fact] - public void UC_L11_창_위치_크기가_저장된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "WindowBounds"), - "UC-L11", "AppConfig.WindowBounds 로 창 위치·크기가 복원되어야 한다"); - - [Fact] - public void UC_L12_최대화_상태가_저장된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "WindowMaximized"), - "UC-L12", "AppConfig.WindowMaximized 로 최대화 여부가 복원되어야 한다"); - - [Fact] - public void UC_L13_루트_요소가_레이아웃_반올림을_켠다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("UseLayoutRounding"), - "UC-L13", "MainWindow 가 UseLayoutRounding 으로 고DPI 흐림을 방지해야 한다"); - - [Fact] - public void UC_L14_공용_스타일이_픽셀_정렬을_켠다() - => SourceTree.Usecase( - SourceTree.SharedXaml.Contains("SnapsToDevicePixels"), - "UC-L14", "Shared.xaml 스타일이 SnapsToDevicePixels 로 선명도를 보장해야 한다"); - - [Fact] - public void UC_L15_상태바_자동_숨김을_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "StatusBarAutoHide"), - "UC-L15", "AppConfig.StatusBarAutoHide 속성이 필요하다"); - - [Fact] - public void UC_L16_툴바_버튼_노출을_직접_고른다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "ToolbarButtons"), - "UC-L16", "AppConfig.ToolbarButtons 로 버튼 표시 여부를 지정할 수 있어야 한다"); - - [Fact] - public void UC_L17_북마크바_표시_여부가_저장된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "BookmarkBarVisible"), - "UC-L17", "AppConfig.BookmarkBarVisible 속성이 필요하다"); - - [Fact] - public void UC_L18_탭_스트립_위치를_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "TabStripPosition"), - "UC-L18", "AppConfig.TabStripPosition 로 탭 스트립 위치를 고를 수 있어야 한다"); - - [Fact] - public void UC_L19_패널_폭을_더블클릭으로_초기화한다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(MainWindow), "ResetPanelWidth"), - "UC-L19", "MainWindow.ResetPanelWidth 로 패널 폭을 기본값으로 되돌릴 수 있어야 한다"); - - [Fact] - public void UC_L20_패널_최소_폭을_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "RightPanelMinWidth"), - "UC-L20", "AppConfig.RightPanelMinWidth 속성이 필요하다"); - - [Fact] - public void UC_L21_애니메이션_축소_설정이_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "ReduceMotion"), - "UC-L21", "AppConfig.ReduceMotion 로 애니메이션을 줄일 수 있어야 한다"); - - [Fact] - public void UC_L22_빠른_애니메이션_지속시간_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "Duration.Fast"), - "UC-L22", "지속 시간 토큰 Duration.Fast 가 필요하다"); - - [Fact] - public void UC_L23_일반_애니메이션_지속시간_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "Duration.Normal"), - "UC-L23", "지속 시간 토큰 Duration.Normal 이 필요하다"); - - [Fact] - public void UC_L24_컴팩트_엣지_호버_반응_속도를_설정한다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "EdgeRevealDelayMs"), - "UC-L24", "AppConfig.EdgeRevealDelayMs 속성이 필요하다"); - - [Fact] - public void UC_L25_UI_전체_배율을_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "UiScale"), - "UC-L25", "AppConfig.UiScale 로 UI 전체 배율을 조절할 수 있어야 한다"); - - [Fact] - public void UC_L26_전체화면_여부가_저장된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "FullScreen"), - "UC-L26", "AppConfig.FullScreen 속성이 필요하다"); -} diff --git a/VideoDownloader.Tests/E2E/UiUsecaseLibraryRedTests.cs b/VideoDownloader.Tests/E2E/UiUsecaseLibraryRedTests.cs deleted file mode 100644 index 780ab4d..0000000 --- a/VideoDownloader.Tests/E2E/UiUsecaseLibraryRedTests.cs +++ /dev/null @@ -1,143 +0,0 @@ -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// UI 복잡 버전 RED 스펙 — §I 라이브러리·재생목록·즐겨찾기·기록·노트 유즈케이스(docs/UI_USECASES.md §I, UC-M01~M22). -/// 데스크톱 앱에는 아직 라이브러리 뷰가 없으므로 전부 RED 다. -/// -public class UiUsecaseLibraryRedTests -{ - [Fact] - public void UC_M01_라이브러리_뷰가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("LibraryPopup") || SourceTree.MainWindowXaml.Contains("LibraryBtn"), - "UC-M01", "받은 파일을 보는 라이브러리 뷰(LibraryPopup/LibraryBtn)가 필요하다"); - - [Fact] - public void UC_M02_라이브러리를_그리드_리스트로_전환한다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("LibraryViewToggle"), - "UC-M02", "그리드/리스트 전환(LibraryViewToggle)이 필요하다"); - - [Fact] - public void UC_M03_라이브러리를_검색할_수_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("LibrarySearchBox"), - "UC-M03", "라이브러리 검색 상자(LibrarySearchBox)가 필요하다"); - - [Fact] - public void UC_M04_라이브러리를_정렬할_수_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("LibrarySortMenu"), - "UC-M04", "라이브러리 정렬 메뉴(LibrarySortMenu)가 필요하다"); - - [Fact] - public void UC_M05_항목에_추가일이_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("AddedDateText"), - "UC-M05", "라이브러리 항목의 추가일 표시(AddedDateText)가 필요하다"); - - [Fact] - public void UC_M06_항목에_썸네일이_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("LibraryThumbnail"), - "UC-M06", "라이브러리 썸네일(LibraryThumbnail)이 필요하다"); - - [Fact] - public void UC_M07_항목을_더블클릭해_재생한다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("LibraryPlay_Click"), - "UC-M07", "라이브러리 재생(LibraryPlay_Click) 처리기가 필요하다"); - - [Fact] - public void UC_M08_항목을_삭제할_수_있다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("LibraryDelete_Click"), - "UC-M08", "라이브러리 삭제(LibraryDelete_Click) 처리기가 필요하다"); - - [Fact] - public void UC_M09_항목_폴더를_열_수_있다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("LibraryOpenFolder_Click"), - "UC-M09", "라이브러리 폴더 열기(LibraryOpenFolder_Click)가 필요하다"); - - [Fact] - public void UC_M10_라이브러리_빈_상태_안내가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("LibraryEmptyHint"), - "UC-M10", "라이브러리 빈 상태 안내(LibraryEmptyHint)가 필요하다"); - - [Fact] - public void UC_M11_재생목록_셔플이_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("PlaylistShuffle"), - "UC-M11", "재생목록 셔플(PlaylistShuffle) 요소가 필요하다"); - - [Fact] - public void UC_M12_반복_모드가_저장된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "PlaylistLoopMode"), - "UC-M12", "AppConfig.PlaylistLoopMode 속성이 필요하다"); - - [Fact] - public void UC_M13_재생목록을_파일로_저장_불러온다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("PlaylistExport"), - "UC-M13", "재생목록 내보내기/불러오기(PlaylistExport)가 필요하다"); - - [Fact] - public void UC_M14_재생목록_순서를_바꾼다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("PlaylistMoveUp"), - "UC-M14", "재생목록 순서 변경(PlaylistMoveUp) 요소가 필요하다"); - - [Fact] - public void UC_M15_재생목록을_검색한다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("PlaylistSearchBox"), - "UC-M15", "재생목록 검색 상자(PlaylistSearchBox)가 필요하다"); - - [Fact] - public void UC_M16_즐겨찾기를_폴더로_정리한다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("BookmarkFolder"), - "UC-M16", "즐겨찾기 폴더(BookmarkFolder) 기능이 필요하다"); - - [Fact] - public void UC_M17_즐겨찾기를_편집한다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("EditBookmark"), - "UC-M17", "즐겨찾기 편집(EditBookmark) 기능이 필요하다"); - - [Fact] - public void UC_M18_즐겨찾기를_검색한다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("BookmarkSearchBox"), - "UC-M18", "즐겨찾기 검색 상자(BookmarkSearchBox)가 필요하다"); - - [Fact] - public void UC_M19_즐겨찾기를_HTML로_내보낸다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("ExportBookmarks"), - "UC-M19", "즐겨찾기 내보내기(ExportBookmarks) 기능이 필요하다"); - - [Fact] - public void UC_M20_방문_기록을_날짜별로_묶는다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("GroupHistoryByDate"), - "UC-M20", "방문 기록 날짜 묶음(GroupHistoryByDate) 기능이 필요하다"); - - [Fact] - public void UC_M21_방문_기록을_검색한다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("HistorySearchBox"), - "UC-M21", "방문 기록 검색 상자(HistorySearchBox)가 필요하다"); - - [Fact] - public void UC_M22_노트를_검색한다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("NotesSearchBox"), - "UC-M22", "노트 검색 상자(NotesSearchBox)가 필요하다"); -} diff --git a/VideoDownloader.Tests/E2E/UiUsecaseSettingsRedTests.cs b/VideoDownloader.Tests/E2E/UiUsecaseSettingsRedTests.cs deleted file mode 100644 index a683846..0000000 --- a/VideoDownloader.Tests/E2E/UiUsecaseSettingsRedTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using VideoDownloader.App; -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// UI 복잡 버전 RED 스펙 — §F 설정 창 유즈케이스(docs/UI_USECASES.md §F, UC-S01~S20). -/// RED 단계: 전부 실패하는 것이 정상이다. -/// -public class UiUsecaseSettingsRedTests -{ - [Fact] - public void UC_S01_설정을_검색으로_찾을_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("SettingsSearchBox"), - "UC-S01", "설정 검색 상자(SettingsSearchBox)가 필요하다"); - - [Fact] - public void UC_S02_카테고리_내비게이션이_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("SettingsNavList"), - "UC-S02", "좌측 카테고리 내비게이션(SettingsNavList)이 필요하다"); - - [Fact] - public void UC_S03_재시작_필요_변경을_배너로_알린다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("RestartBanner"), - "UC-S03", "재시작 필요 알림 배너(RestartBanner)가 필요하다"); - - [Fact] - public void UC_S04_기본값으로_한번에_되돌릴_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("ResetDefaultsButton"), - "UC-S04", "기본값 복원 버튼(ResetDefaultsButton)이 필요하다"); - - [Fact] - public void UC_S05_설정을_내보낼_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("ExportSettingsButton"), - "UC-S05", "설정 내보내기 버튼(ExportSettingsButton)이 필요하다"); - - [Fact] - public void UC_S06_설정을_가져올_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("ImportSettingsButton"), - "UC-S06", "설정 가져오기 버튼(ImportSettingsButton)이 필요하다"); - - [Fact] - public void UC_S07_고급_항목을_접을_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("AdvancedSection"), - "UC-S07", "고급 섹션 접힘(AdvancedSection)이 필요하다"); - - [Fact] - public void UC_S08_Ctrl_S로_저장할_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowCs.Contains("Key.S"), - "UC-S08", "설정창 코드비하인드가 Ctrl+S(Key.S) 저장을 처리해야 한다"); - - [Fact] - public void UC_S09_취소_시_폐기_확인을_받는다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(SettingsWindow), "ConfirmDiscardChanges"), - "UC-S09", "SettingsWindow.ConfirmDiscardChanges() 확인 절차가 필요하다"); - - [Fact] - public void UC_S10_설정창_크기_위치가_기억된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "SettingsWindowBounds"), - "UC-S10", "AppConfig.SettingsWindowBounds 속성이 필요하다"); - - [Fact] - public void UC_S11_재시도_횟수를_UI에서_바꾼다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Name=\"RetriesBox\""), - "UC-S11", "재시도 횟수 입력(RetriesBox)이 필요하다"); - - [Fact] - public void UC_S12_UserAgent를_UI에서_바꾼다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Name=\"UserAgentBox\""), - "UC-S12", "User-Agent 입력(UserAgentBox)이 필요하다"); - - [Fact] - public void UC_S13_프록시를_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Name=\"ProxyBox\""), - "UC-S13", "프록시 설정(ProxyBox)이 필요하다"); - - [Fact] - public void UC_S14_UI_언어를_고를_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Name=\"LanguageBox\""), - "UC-S14", "언어 선택(LanguageBox)이 필요하다"); - - [Fact] - public void UC_S15_자동_실행을_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Name=\"AutoStartBox\""), - "UC-S15", "시작 시 자동 실행(AutoStartBox)이 필요하다"); - - [Fact] - public void UC_S16_트레이로_최소화할_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("MinimizeToTrayBox"), - "UC-S16", "트레이 최소화 옵션(MinimizeToTrayBox)이 필요하다"); - - [Fact] - public void UC_S17_알림_설정_섹션이_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("알림"), - "UC-S17", "설정창에 '알림' 섹션이 필요하다"); - - [Fact] - public void UC_S18_실험적_기능_섹션이_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("실험적"), - "UC-S18", "설정창에 '실험적' 기능 섹션이 필요하다"); - - [Fact] - public void UC_S19_실시간_검증_힌트가_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("ValidationHint"), - "UC-S19", "입력 검증 힌트(ValidationHint) 요소가 필요하다"); - - [Fact] - public void UC_S20_버전_정보가_표시된다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Name=\"VersionText\""), - "UC-S20", "앱 버전 표시(VersionText)가 필요하다"); -} diff --git a/VideoDownloader.Tests/E2E/UiUsecaseStatusRedTests.cs b/VideoDownloader.Tests/E2E/UiUsecaseStatusRedTests.cs deleted file mode 100644 index 0e9bbc3..0000000 --- a/VideoDownloader.Tests/E2E/UiUsecaseStatusRedTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// UI 복잡 버전 RED 스펙 — §J 상태 표시·알림·피드백 유즈케이스(docs/UI_USECASES.md §J, UC-N01~N20). -/// RED 단계: 전부 실패하는 것이 정상이다. -/// -public class UiUsecaseStatusRedTests -{ - [Fact] - public void UC_N01_상태바에_전체_다운로드_진행률이_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("AggregateProgress"), - "UC-N01", "상태바 통합 진행률(AggregateProgress) 요소가 필요하다"); - - [Fact] - public void UC_N02_토스트_호스트가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("ToastHost"), - "UC-N02", "알림 토스트 컨테이너(ToastHost)가 필요하다"); - - [Fact] - public void UC_N03_토스트_표시_시간을_설정한다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "ToastDurationMs"), - "UC-N03", "AppConfig.ToastDurationMs 속성이 필요하다"); - - [Fact] - public void UC_N04_치명_오류_배너가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("ErrorBanner"), - "UC-N04", "상단 고정 오류 배너(ErrorBanner)가 필요하다"); - - [Fact] - public void UC_N05_오류_상세를_펼쳐_볼_수_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("ErrorDetailExpander"), - "UC-N05", "오류 상세 펼침(ErrorDetailExpander) 요소가 필요하다"); - - [Fact] - public void UC_N06_클립보드_URL_감지_제안이_뜬다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("ClipboardHint"), - "UC-N06", "클립보드 URL 감지 배지(ClipboardHint)가 필요하다"); - - [Fact] - public void UC_N07_원격_서버_상태가_상시_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("ServerStatusIcon"), - "UC-N07", "서버 상태 아이콘(ServerStatusIcon)이 필요하다"); - - [Fact] - public void UC_N08_툴바에서_QR_페어링을_바로_연다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("PairingBtn"), - "UC-N08", "툴바 페어링 버튼(PairingBtn)이 필요하다"); - - [Fact] - public void UC_N09_업데이트_가능_배지가_뜬다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("UpdateBadge"), - "UC-N09", "새 버전 알림 배지(UpdateBadge)가 필요하다"); - - [Fact] - public void UC_N10_차단_도메인별_통계가_보인다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("ShieldDomainStats"), - "UC-N10", "도메인별 차단 통계(ShieldDomainStats)가 필요하다"); - - [Fact] - public void UC_N11_네트워크_끊김이_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("NetworkChange"), - "UC-N11", "NetworkChange 오프라인 감지 처리가 필요하다"); - - [Fact] - public void UC_N12_절전_모드_진입_시_일시정지_옵션이_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "PauseOnPowerSaver"), - "UC-N12", "AppConfig.PauseOnPowerSaver 속성이 필요하다"); - - [Fact] - public void UC_N13_상태바_텍스트를_클릭해_복사한다() - => SourceTree.Usecase( - SourceTree.MainWindowCs.Contains("CopyStatus_Click"), - "UC-N13", "상태 복사(CopyStatus_Click) 처리기가 필요하다"); - - [Fact] - public void UC_N14_알림_센터가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("NotificationCenter"), - "UC-N14", "지나간 알림 목록(NotificationCenter)이 필요하다"); - - [Fact] - public void UC_N15_방해_금지_모드를_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "DoNotDisturb"), - "UC-N15", "AppConfig.DoNotDisturb 속성이 필요하다"); - - [Fact] - public void UC_N16_온보딩_안내가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("Onboarding") || SourceTree.MainWindowCs.Contains("Onboarding"), - "UC-N16", "첫 실행 온보딩(Onboarding) 요소가 필요하다"); - - [Fact] - public void UC_N17_빈_상태_공용_스타일이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "EmptyStateStyle"), - "UC-N17", "빈 상태 공용 스타일(EmptyStateStyle) 토큰이 필요하다"); - - [Fact] - public void UC_N18_크래시_리포트_대화상자가_있다() - => SourceTree.Usecase( - SourceTree.FindType("CrashReportDialog") is not null, - "UC-N18", "충돌 리포트 대화상자(CrashReportDialog)가 필요하다"); - - [Fact] - public void UC_N19_다운로드_버튼에_진행_개수_배지가_뜬다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("DownloadsBtnBadge"), - "UC-N19", "툴바 다운로드 버튼 배지(DownloadsBtnBadge)가 필요하다"); - - [Fact] - public void UC_N20_상태바_우측_보조_정보_영역이_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("StatusRight"), - "UC-N20", "상태바 우측 정보 영역(StatusRight)이 필요하다"); -} diff --git a/VideoDownloader.Tests/E2E/UiUsecaseTabsRedTests.cs b/VideoDownloader.Tests/E2E/UiUsecaseTabsRedTests.cs deleted file mode 100644 index 969f502..0000000 --- a/VideoDownloader.Tests/E2E/UiUsecaseTabsRedTests.cs +++ /dev/null @@ -1,163 +0,0 @@ -using VideoDownloader.App; -using VideoDownloader.Browser; -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// UI 복잡 버전 RED 스펙 — §D 탭 고급 관리 유즈케이스(docs/UI_USECASES.md §D, UC-B01~B24). -/// RED 단계: 전부 실패하는 것이 정상이다. -/// -public class UiUsecaseTabsRedTests -{ - private static readonly Type TabItemType = - SourceTree.FindType("TabItem") ?? throw new InvalidOperationException("Browser TabItem 형식을 찾을 수 없습니다"); - - [Fact] - public void UC_B01_탭을_잠글_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(TabItemType, "IsLocked"), - "UC-B01", "탭 잠금(IsLocked)으로 실수로 닫는 것을 막을 수 있어야 한다"); - - [Fact] - public void UC_B02_탭마다_사용자_지정_색을_달_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(TabItemType, "CustomColor"), - "UC-B02", "탭 사용자 샆(CustomColor) 속성이 필요하다"); - - [Fact] - public void UC_B03_탭_그룹을_접을_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(TabItemType, "IsGroupCollapsed"), - "UC-B03", "그룹 접음(IsGroupCollapsed) 상태가 탭 항목에 있어야 한다"); - - [Fact] - public void UC_B04_탭_그룹_이름을_변경할_수_있다() - { - var ext = SourceTree.FindType("TabManagerGroupExtensions"); - SourceTree.Usecase( - ext is not null && SourceTree.MethodExists(ext, "RenameGroup"), - "UC-B04", "그룹 이름 변경(RenameGroup) API 가 필요하다"); - } - - [Fact] - public void UC_B05_탭_호버_미리보기_컴포넌트가_있다() - => SourceTree.Usecase( - SourceTree.FindType("TabPreview") is not null, - "UC-B05", "탭 호버 미리보기를 담당하는 TabPreview 컴포넌트가 필요하다"); - - [Fact] - public void UC_B06_잠자기_탭이_시각적으로_구분된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("IsSuspended"), - "UC-B06", "탭 템플릿이 잠자기 상태(IsSuspended)를 시각적으로 표시해야 한다"); - - [Fact] - public void UC_B07_탭_번호_배지가_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("TabIndexBadge"), - "UC-B07", "탭 번호 배지(TabIndexBadge) 요소가 필요하다"); - - [Fact] - public void UC_B08_탭_드래그_삽입_표시기가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("TabInsertIndicator"), - "UC-B08", "드래그 중 삽입 위치 표시기(TabInsertIndicator)가 필요하다"); - - [Fact] - public void UC_B09_다중_탭_닫기_확인_옵션이_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "ConfirmCloseMultipleTabs"), - "UC-B09", "AppConfig.ConfirmCloseMultipleTabs 속성이 필요하다"); - - [Fact] - public void UC_B10_휠로_탭을_전환하는_옵션이_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "TabWheelSwitch"), - "UC-B10", "AppConfig.TabWheelSwitch 속성이 필요하다"); - - [Fact] - public void UC_B11_탭_최대_개수를_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "MaxTabs"), - "UC-B11", "AppConfig.MaxTabs 속성이 필요하다"); - - [Fact] - public void UC_B12_세션_복원을_잠자기_상태로_할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "RestoreTabsSuspended"), - "UC-B12", "AppConfig.RestoreTabsSuspended 속성이 필요하다"); - - [Fact] - public void UC_B13_닫은_탭_아카이브_전용_팝업이_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("Name=\"ArchivePopup\""), - "UC-B13", "아카이브를 보여주는 ArchivePopup 이 필요하다"); - - [Fact] - public void UC_B14_아카이브를_검색할_수_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("ArchiveSearchBox"), - "UC-B14", "아카이브 검색 상자(ArchiveSearchBox)가 필요하다"); - - [Fact] - public void UC_B15_아카이브를_한번에_비울_수_있다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(TabArchive), "Clear"), - "UC-B15", "TabArchive.Clear() 메서드가 필요하다"); - - [Fact] - public void UC_B16_아카이브_항목을_복원할_수_있다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(TabArchive), "Restore"), - "UC-B16", "TabArchive.Restore() 메서드가 필요하다"); - - [Fact] - public void UC_B17_탭_그룹_색_팔레트가_테마로_관리된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "GroupPalette"), - "UC-B17", "그룹 색 팔레트(GroupPalette)가 공용 리소스로 정의되어야 한다"); - - [Fact] - public void UC_B18_현재_작업공간_이름이_표시된다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("WorkspaceIndicator"), - "UC-B18", "현재 작업공간 표시(WorkspaceIndicator) 요소가 필요하다"); - - [Fact] - public void UC_B19_작업공간_이름을_직접_지을_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "WorkspaceNames"), - "UC-B19", "AppConfig.WorkspaceNames 속성이 필요하다"); - - [Fact] - public void UC_B20_작업공간_전환_UI가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("WorkspaceSwitcher"), - "UC-B20", "작업공간 전환기(WorkspaceSwitcher) 요소가 필요하다"); - - [Fact] - public void UC_B21_새_탭_버튼에_보조_메뉴가_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("NewTabMore_Click"), - "UC-B21", "새 탭 버튼의 보조 메뉴(최근 페이지·즐겨찾기) 핸들러가 필요하다"); - - [Fact] - public void UC_B22_탭_제목을_두_줄로_볼_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "TabTitleMultiline"), - "UC-B22", "AppConfig.TabTitleMultiline 속성이 필요하다"); - - [Fact] - public void UC_B23_탭에서_볼륨_슬라이더를_쓸_수_있다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("TabVolumePopup"), - "UC-B23", "탭 볼륨 조절 팝업(TabVolumePopup)이 필요하다"); - - [Fact] - public void UC_B24_탭_그룹을_즐겨찾기로_저장할_수_있다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(MainWindow), "BookmarkGroup"), - "UC-B24", "그룹 전체를 즐겨찾기로 저장하는 MainWindow.BookmarkGroup() 이 필요하다"); -} diff --git a/VideoDownloader.Tests/E2E/UiUsecaseThemeRedTests.cs b/VideoDownloader.Tests/E2E/UiUsecaseThemeRedTests.cs deleted file mode 100644 index d0b0023..0000000 --- a/VideoDownloader.Tests/E2E/UiUsecaseThemeRedTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -using VideoDownloader.App; -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// UI 복잡 버전 RED 스펙 — §A 테마·색상·대비 유즈케이스(docs/UI_USECASES.md §A, UC-T01~T30). -/// RED 단계: 전부 실패하는 것이 정상이다. 계약은 해당 문서의 "계약" 열을 따른다. -/// -public class UiUsecaseThemeRedTests -{ - [Fact] - public void UC_T01_시스템_테마를_따라가는_옵션이_설정에_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Tag=\"system\""), - "UC-T01", "OS 테마(다크/라이트)를 자동으로 따라가는 'system' 옵션이 설정창에 노출된다"); - - [Fact] - public void UC_T02_테마를_콤보박스로_다중_옵션_중_고른다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Name=\"ThemeModeBox\""), - "UC-T02", "테마 선택이 체크박스(라이트만)가 아니라 다크/라이트/시스템/고대비 콤보박스여야 한다"); - - [Fact] - public void UC_T03_테마_전환에_재시작이_필요하다는_문구가_없어야_한다() - => SourceTree.Usecase( - !SourceTree.SettingsWindowXaml.Contains("재시작"), - "UC-T03", "테마는 즉시 적용되어야 하며 설정창에 '재시작' 안내가 남아 있지 않아야 한다"); - - [Fact] - public void UC_T04_MainWindow에_라이브_테마_적용_API가_있다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(MainWindow), "ApplyTheme"), - "UC-T04", "테마 전환 후 열려 있는 MainWindow 자체가 리소스를 다시 적용하는 API 가 필요하다"); - - [Fact] - public void UC_T05_고대비_테마_사전_파일이_존재한다() - => SourceTree.Usecase( - SourceTree.AppFileExists("Themes/HighContrast.xaml"), - "UC-T05", "고대비 사용자를 위한 Themes/HighContrast.xaml 리소스 사전이 필요하다"); - - [Fact] - public void UC_T06_설정에서_고대비_테마를_직접_고를_수_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Tag=\"highcontrast\""), - "UC-T06", "설정창 테마 옵션에 'highcontrast' 값이 제공되어야 한다"); - - [Fact] - public void UC_T07_사용자_지정_강조색이_설정에_저장된다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "AccentColor"), - "UC-T07", "AppConfig 에 강조색(AccentColor) 속성이 필요하다"); - - [Fact] - public void UC_T08_강조색을_즉시_적용하는_API가_있다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(VideoDownloader.App.App), "ApplyAccent"), - "UC-T08", "App.ApplyAccent 로 재시작 없이 강조색이 전체 컨트롤에 반영되어야 한다"); - - [Fact] - public void UC_T09_강조색_프리셋_스와치가_테마에_준비된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.ThemeDarkXaml, "Accent.Blue"), - "UC-T09", "다크 테마에 Accent.Blue 등 강조색 프리셋 키가 정의되어야 한다"); - - [Fact] - public void UC_T10_진행바_스타일에_하드코딩된_강조색이_없어야_한다() - => SourceTree.Usecase( - !SourceTree.SharedXaml.Contains("Color=\"#6366F1\""), - "UC-T10", "AccentProgressBar 템플릿이 하드코딩 색 대신 테마 강조색 리소스를 참조해야 한다"); - - [Fact] - public void UC_T11_대기_상태색_PendingBrush가_실제_UI에서_쓰인다() - => SourceTree.Usecase( - SourceTree.MainWindowXaml.Contains("PendingBrush"), - "UC-T11", "테마에 정의만 된 PendingBrush 가 대기 상태 표시에 실제로 사용되어야 한다"); - - [Fact] - public void UC_T12_사용자_테마_파일_경로를_설정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "CustomThemePath"), - "UC-T12", "AppConfig.CustomThemePath 로 사용자 테마 파일을 불러올 수 있어야 한다"); - - [Fact] - public void UC_T13_시스템_테마_판별_컴포넌트가_존재한다() - => SourceTree.Usecase( - SourceTree.FindType("ThemeResolver") is not null, - "UC-T13", "OS 테마 값을 판별하는 ThemeResolver 컴포넌트가 필요하다"); - - [Fact] - public void UC_T14_다크_TextMuted가_CardBg에서_AA대비를_만족한다() - { - var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "TextMuted"); - var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBg"); - Assert.False(fg is null || bg is null, "UC-T14 — 테마 브러시 파싱 실패(테마 파일 구조 변경?)"); - var ratio = SourceTree.ContrastRatio(fg!, bg!); - Assert.True(ratio >= 4.5, $"UC-T14 RED — 다크 TextMuted/CardBg 대비 {ratio:F2}:1 < 4.5:1 (WCAG AA)"); - } - - [Fact] - public void UC_T15_다크_TextMuted가_카드_호버_배경에서도_AA대비를_만족한다() - { - var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "TextMuted"); - var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBgHover"); - Assert.False(fg is null || bg is null, "UC-T15 — 테마 브러시 파싱 실패"); - var ratio = SourceTree.ContrastRatio(fg!, bg!); - Assert.True(ratio >= 4.5, $"UC-T15 RED — 다크 TextMuted/CardBgHover 대비 {ratio:F2}:1 < 4.5:1"); - } - - [Fact] - public void UC_T16_라이트_TextMuted가_CardBg에서_AA대비를_만족한다() - { - var fg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "TextMuted"); - var bg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "CardBg"); - Assert.False(fg is null || bg is null, "UC-T16 — 테마 브러시 파싱 실패"); - var ratio = SourceTree.ContrastRatio(fg!, bg!); - Assert.True(ratio >= 4.5, $"UC-T16 RED — 라이트 TextMuted/CardBg 대비 {ratio:F2}:1 < 4.5:1"); - } - - [Fact] - public void UC_T17_라이트_TextMuted가_GlassBg에서_AA대비를_만족한다() - { - var fg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "TextMuted"); - var bg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "GlassBg"); - Assert.False(fg is null || bg is null, "UC-T17 — 테마 브러시 파싱 실패"); - var ratio = SourceTree.ContrastRatio(fg!, bg!); - Assert.True(ratio >= 4.5, $"UC-T17 RED — 라이트 TextMuted/GlassBg 대비 {ratio:F2}:1 < 4.5:1"); - } - - [Fact] - public void UC_T18_다크_강조색_텍스트가_CardBg에서_AA대비를_만족한다() - { - var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "AccentBrush"); - var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBg"); - Assert.False(fg is null || bg is null, "UC-T18 — 테마 브러시 파싱 실패"); - var ratio = SourceTree.ContrastRatio(fg!, bg!); - Assert.True(ratio >= 4.5, $"UC-T18 RED — 다크 AccentBrush/CardBg 대비 {ratio:F2}:1 < 4.5:1 (포맷 배지 텍스트로 사용됨)"); - } - - [Fact] - public void UC_T19_비활성_텍스트_토큰이_양_테마에_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.ThemeDarkXaml, "TextDisabled") - && SourceTree.HasKey(SourceTree.ThemeLightXaml, "TextDisabled"), - "UC-T19", "비활성 컨트롤 텍스트 샆(TextDisabled) 토큰이 다크·라이트 모두에 필요하다"); - - [Fact] - public void UC_T20_선택_영역_브러시_토큰이_테마에_있다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.ThemeDarkXaml, "SelectionBrush"), - "UC-T20", "텍스트 선택 영역 색(SelectionBrush)이 테마 토큰으로 관리되어야 한다"); - - [Fact] - public void UC_T21_포커스_링_브러시_토큰이_테마에_있다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.ThemeDarkXaml, "FocusAdornerBrush"), - "UC-T21", "키보드 포커스 링 샆(FocusAdornerBrush) 토큰이 필요하다"); - - [Fact] - public void UC_T22_스크롤바_썸_전용_색_토큰이_있다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "ScrollBarThumbBrush"), - "UC-T22", "스크롤바 썸 샆(ScrollBarThumbBrush)이 공용 토큰으로 정의되어야 한다"); - - [Fact] - public void UC_T23_툴팁_그림자_리소스가_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "TooltipShadow"), - "UC-T23", "툴팁 그림자(TooltipShadow) 리소스가 필요하다"); - - [Fact] - public void UC_T24_팝업_그림자_리소스가_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "PopupShadow"), - "UC-T24", "플라이아웃 공용 그림자(PopupShadow) 리소스가 필요하다"); - - [Fact] - public void UC_T25_경고_상태색_토큰이_대기색과_구분돼_있다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.ThemeDarkXaml, "WarningBrush"), - "UC-T25", "경고 샆(WarningBrush) 토큰이 PendingBrush 와 별도로 필요하다"); - - [Fact] - public void UC_T26_링크_색_토큰이_테마에_있다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.ThemeDarkXaml, "LinkBrush"), - "UC-T26", "링크 텍스트 샆(LinkBrush) 토큰이 필요하다"); - - [Fact] - public void UC_T27_방문한_링크_색_토큰이_테마에_있다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.ThemeDarkXaml, "VisitedLinkBrush"), - "UC-T27", "방문 링크 샆(VisitedLinkBrush) 토큰이 필요하다"); - - [Fact] - public void UC_T28_정보_안내색_토큰이_테마에_있다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.ThemeDarkXaml, "InfoBrush"), - "UC-T28", "정보성 안내 샆(InfoBrush) 토큰이 필요하다"); - - [Fact] - public void UC_T29_강조_텍스트_전용_브러시_토큰이_있다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.ThemeDarkXaml, "AccentTextBrush"), - "UC-T29", "강조색 중 텍스트 용도(AccentTextBrush) 토큰이 별도로 필요하다"); - - [Fact] - public void UC_T30_타이틀바_테마를_즉시_갱신하는_API가_있다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(MainWindow), "UpdateTitleBarTheme"), - "UC-T30", "테마 전환 시 DWM 타이틀바 특성을 다시 적용하는 MainWindow.UpdateTitleBarTheme 이 필요하다"); -} diff --git a/VideoDownloader.Tests/E2E/UiUsecaseTypographyRedTests.cs b/VideoDownloader.Tests/E2E/UiUsecaseTypographyRedTests.cs deleted file mode 100644 index 9d9fb3a..0000000 --- a/VideoDownloader.Tests/E2E/UiUsecaseTypographyRedTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -using VideoDownloader.App; -using VideoDownloader.Core.Config; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// UI 복잡 버전 RED 스펙 — §B 타이포그래피·폰트 유즈케이스(docs/UI_USECASES.md §B, UC-F01~F22). -/// RED 단계: 전부 실패하는 것이 정상이다. -/// -public class UiUsecaseTypographyRedTests -{ - [Fact] - public void UC_F01_UI_기본_폰트를_설정으로_고를_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "UiFontFamily"), - "UC-F01", "AppConfig.UiFontFamily 속성이 필요하다"); - - [Fact] - public void UC_F02_UI_폰트_크기를_설정으로_바꿀_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "UiFontSize"), - "UC-F02", "AppConfig.UiFontSize 속성이 필요하다"); - - [Fact] - public void UC_F03_한국어_폴백_폰트를_직접_지정할_수_있다() - => SourceTree.Usecase( - SourceTree.PropertyExists(typeof(AppConfig), "UiFontKoreanFallback"), - "UC-F03", "AppConfig.UiFontKoreanFallback 속성이 필요하다"); - - [Fact] - public void UC_F04_기본_폰트_크기_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.Base"), - "UC-F04", "Shared.xaml 에 FontSize.Base 토큰이 필요하다"); - - [Fact] - public void UC_F05_작은_폰트_크기_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.Small"), - "UC-F05", "FontSize.Small 토큰이 필요하다"); - - [Fact] - public void UC_F06_큰_폰트_크기_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.Large"), - "UC-F06", "FontSize.Large 토큰이 필요하다"); - - [Fact] - public void UC_F07_초대형_폰트_크기_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.XLarge"), - "UC-F07", "FontSize.XLarge 토큰이 필요하다"); - - [Fact] - public void UC_F08_제목_H1_스타일이_공용으로_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "Type.H1"), - "UC-F08", "Type.H1 스타일 토큰이 필요하다"); - - [Fact] - public void UC_F09_본문_스타일이_공용으로_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "Type.Body"), - "UC-F09", "Type.Body 스타일 토큰이 필요하다"); - - [Fact] - public void UC_F10_캡션_스타일이_공용으로_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "Type.Caption"), - "UC-F10", "Type.Caption 스타일 토큰이 필요하다"); - - [Fact] - public void UC_F11_등폭_숫자_스타일이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "Type.Mono"), - "UC-F11", "진행률·속도 숫자용 Type.Mono 스타일이 필요하다"); - - [Fact] - public void UC_F12_한국어_가독성_프리셋_폰트가_후보에_포함된다() - => SourceTree.Usecase( - SourceTree.SharedXaml.Contains("Pretendard"), - "UC-F12", "UiFont 폰트 후보에 Pretendard 가 포함되어야 한다"); - - [Fact] - public void UC_F13_설정창에_글꼴_섹션이_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("글꼴"), - "UC-F13", "설정창에 '글꼴' 섹션이 필요하다"); - - [Fact] - public void UC_F14_글꼴_미리보기_컨트롤이_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Name=\"FontPreviewText\""), - "UC-F14", "설정창에 FontPreviewText 미리보기 컨트롤이 필요하다"); - - [Fact] - public void UC_F15_폰트_크기_프리셋_콤보박스가_있다() - => SourceTree.Usecase( - SourceTree.SettingsWindowXaml.Contains("Name=\"FontSizeBox\""), - "UC-F15", "설정창에 FontSizeBox 콤보박스가 필요하다"); - - [Fact] - public void UC_F16_폰트_설정을_즉시_적용하는_API가_있다() - => SourceTree.Usecase( - SourceTree.MethodExists(typeof(VideoDownloader.App.App), "ApplyFontSettings"), - "UC-F16", "App.ApplyFontSettings 로 폰트 설정이 재시작 없이 반영되어야 한다"); - - [Fact] - public void UC_F17_리더_모드에_폰트가_하드코딩되지_않는다() - => SourceTree.Usecase( - !SourceTree.ReaderModeCs.Contains("Malgun Gothic"), - "UC-F17", "리더 모드 HTML 이 폰트를 하드코딩 대신 앱 설정에서 주입받아야 한다"); - - [Fact] - public void UC_F18_새_탭_다이얼에_폰트가_하드코딩되지_않는다() - => SourceTree.Usecase( - !SourceTree.MainWindowCs.Contains("Malgun Gothic"), - "UC-F18", "새 탭 다이얼 인라인 CSS 의 폰트 폴백이 앱 공용 설정으로 통일되어야 한다"); - - [Fact] - public void UC_F19_탭_제목_공용_스타일이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "TabTitleStyle"), - "UC-F19", "탭 제목(말줄임 등)의 공용 스타일 TabTitleStyle 이 필요하다"); - - [Fact] - public void UC_F20_상태바_텍스트_공용_스타일이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "StatusTextStyle"), - "UC-F20", "상태바 텍스트 공용 스타일 StatusTextStyle 이 필요하다"); - - [Fact] - public void UC_F21_아이콘_크기_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "IconSize.Small"), - "UC-F21", "아이콘 크기 토큰 IconSize.Small 이 필요하다"); - - [Fact] - public void UC_F22_줄간격_토큰이_정의된다() - => SourceTree.Usecase( - SourceTree.HasKey(SourceTree.SharedXaml, "Type.LineHeight"), - "UC-F22", "본문 줄간격 토큰 Type.LineHeight 가 필요하다"); -} diff --git a/VideoDownloader.Tests/E2E/WebView2FindE2ETests.cs b/VideoDownloader.Tests/E2E/WebView2FindE2ETests.cs deleted file mode 100644 index ff2a95d..0000000 --- a/VideoDownloader.Tests/E2E/WebView2FindE2ETests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Windows; -using System.Windows.Threading; -using Microsoft.Web.WebView2.Core; -using VideoDownloader.Tests.Infrastructure; - -namespace VideoDownloader.Tests.E2E; - -/// -/// E2E: 실제 WebView2 + 네이티브 Find API(CoreWebView2.Find) 검증(T1-3). -/// WebView2 런타임 위에서 매치 카운트/활성 인덱스가 실제로 발생하는지 확인한다. -/// -[Collection("E2E")] -public class WebView2FindE2ETests -{ - [Fact] - public async Task 네이티브_Find로_매치_수와_활성_인덱스를_얻는다() - { - 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; - cv.Settings.AreDefaultScriptDialogsEnabled = false; - - var html = "

hello world hello brave hello

"; - cv.NavigateToString(html); - await WaitUntilNavCompleted(cv); - - // 네이티브 Find API — SuppressDefaultFindDialog 로 자체 UI 전제 검증 - int matchCount = -1, activeIndex = -1; - cv.Find.MatchCountChanged += (_, _) => matchCount = cv.Find.MatchCount; - cv.Find.ActiveMatchIndexChanged += (_, _) => activeIndex = cv.Find.ActiveMatchIndex; - - var options = env.CreateFindOptions(); - options.FindTerm = "hello"; - options.ShouldHighlightAllMatches = true; - options.SuppressDefaultFindDialog = true; - await cv.Find.StartAsync(options); - - await Wait.Until(() => Task.FromResult(matchCount), c => c > 0, 10000); - var countAfterStart = matchCount; - - cv.Find.FindNext(); - await Wait.Until(() => Task.FromResult(activeIndex), i => i >= 1, 10000); - - cv.Find.Stop(); - window.Close(); - return (countAfterStart, activeIndex); - }); - - try { Directory.Delete(udf, true); } catch { } - Assert.Equal(3, result.countAfterStart); - Assert.True(result.activeIndex >= 1, $"활성 인덱스가 진행되지 않음: {result.activeIndex}"); - } - - private static Task WaitUntilNavCompleted(CoreWebView2 cv) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - EventHandler? h = null; - h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); }; - cv.NavigationCompleted += h; - return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15)); - } -} diff --git a/VideoDownloader.Tests/GlobalUsings.cs b/VideoDownloader.Tests/GlobalUsings.cs deleted file mode 100644 index 8bc619f..0000000 --- a/VideoDownloader.Tests/GlobalUsings.cs +++ /dev/null @@ -1,2 +0,0 @@ -global using Xunit; -global using System.IO; diff --git a/VideoDownloader.Tests/Infrastructure/IconGlyphXamlTests.cs b/VideoDownloader.Tests/Infrastructure/IconGlyphXamlTests.cs deleted file mode 100644 index 0ae0e4f..0000000 --- a/VideoDownloader.Tests/Infrastructure/IconGlyphXamlTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Text.RegularExpressions; - -namespace VideoDownloader.Tests.Infrastructure; - -/// -/// 아이콘 글리프(PUA, Segoe Fluent Icons)를 본문 폰트 요소에 넣으면 tofu(□)로 렌더된다 -/// (2026-08-18 실행 캡처로 확인 — "□ 모두 일시정지"). XAML/코드 정적 검사로 재발을 막는다. -/// 규칙: -/// ① PUA 를 쓰는 Content/Text 는 IconFont 보장 스타일(IconFont 를 설정하는 스타일 또는 그 BasedOn 파생)에 -/// 있거나 요소에 FontFamily=IconFont 를 명시한다. -/// ② 한 문자열에 PUA 와 일반 텍스트를 섞지 않는다 — 폰트가 하나뿐이라 어느 한쪽은 반드시 깨진다. -/// -public class IconGlyphXamlTests -{ - private static readonly Regex PuaEntity = new(@"&#x(E[0-9A-Fa-f]{3});", RegexOptions.Compiled); - - /// Shared.xaml 에서 IconFont 를 보장하는 스타일 키 집합(BasedOn 파생 포함). - private static HashSet IconFontStyles() - { - var shared = SourceTree.SharedXaml; - var direct = new HashSet(); - var basedOn = new Dictionary(); - foreach (Match style in Regex.Matches(shared, "", RegexOptions.Singleline)) - { - var key = style.Groups[1].Value; - if (style.Groups[3].Value.Contains("IconFont")) direct.Add(key); - var b = Regex.Match(style.Groups[2].Value, "BasedOn=\"\\{StaticResource (\\w+)\\}\""); - if (b.Success) basedOn[key] = b.Groups[1].Value; - } - // BasedOn 체인 전개 (파생이 부모의 FontFamily 세터를 상속) - bool grew; - do - { - grew = false; - foreach (var (child, parent) in basedOn) - if (!direct.Contains(child) && direct.Contains(parent)) { direct.Add(child); grew = true; } - } while (grew); - return direct; - } - - private static IEnumerable Violations(string xaml, string fileName) - { - var iconStyles = IconFontStyles(); - // 여는 태그 단위로 검사 — Button/TextBlock/Run 등 텍스트 표시 요소 - foreach (Match tag in Regex.Matches(xaml, @"<(Button|TextBlock|Run|Label)\b[^>]*?>", RegexOptions.Singleline)) - { - var open = tag.Value; - foreach (Match attr in Regex.Matches(open, "(?:Content|Text)=\"([^\"]*)\"")) - { - var value = attr.Groups[1].Value; - if (!PuaEntity.IsMatch(value)) continue; - var line = xaml[..tag.Index].Count(c => c == '\n') + 1; - - var stripped = PuaEntity.Replace(value, "").Trim(); - if (stripped.Length > 0) - { - yield return $"{fileName} L{line}: PUA+일반 텍스트 혼합 \"{value}\" — 한 폰트로는 둘 다 못 그린다"; - continue; - } - - var styleMatch = Regex.Match(open, "Style=\"\\{(?:StaticResource|DynamicResource) (\\w+)\\}\""); - var styled = styleMatch.Success && iconStyles.Contains(styleMatch.Groups[1].Value); - var explicitFont = open.Contains("FontFamily=\"{DynamicResource IconFont}\"") - || open.Contains("FontFamily=\"{StaticResource IconFont}\""); - if (!styled && !explicitFont) - yield return $"{fileName} L{line}: PUA 글리프 \"{value}\" 가 IconFont 보장 없이 사용됨(본문 폰트 → tofu)"; - } - } - } - - [Fact] - public void XAML_의_PUA_글리프는_IconFont_보장_하에서만_쓴다() - { - var violations = Violations(SourceTree.MainWindowXaml, "MainWindow.xaml") - .Concat(Violations(SourceTree.SettingsWindowXaml, "SettingsWindow.xaml")) - .ToList(); - Assert.True(violations.Count == 0, "tofu 유발 지점:\n" + string.Join("\n", violations)); - } - - [Fact] - public void 코드비하인드도_PUA_와_일반_텍스트를_섞지_않는다() - { - // 예: LibraryViewToggle.Content = "; 그리드" — 본문 폰트 버튼이라 tofu + 잉여 세미콜론 노출 - var offenders = Regex.Matches(SourceTree.MainWindowCs + File.ReadAllText(SourceTree.AppPath("MainWindow.Usecases.cs")), - @"""\\uE[0-9A-Fa-f]{3}[^""]*[가-힣A-Za-z][^""]*""") - .Select(m => m.Value).ToList(); - Assert.True(offenders.Count == 0, "코드 내 혼합 문자열:\n" + string.Join("\n", offenders)); - } -} diff --git a/VideoDownloader.Tests/Infrastructure/WpfTestApp.cs b/VideoDownloader.Tests/Infrastructure/WpfTestApp.cs deleted file mode 100644 index 736a779..0000000 --- a/VideoDownloader.Tests/Infrastructure/WpfTestApp.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Windows; -using VideoDownloader.App; - -namespace VideoDownloader.Tests.Infrastructure; - -/// -/// 테스트 프로세스에는 App.xaml 의 리소스가 없으므로, App 어셈블리의 테마 사전을 -/// 테스트 전용 Application 에 로드한다(앱의 App.ApplyTheme 과 동일 순서: 색 → 스타일). -/// -public static class WpfTestApp -{ - private static bool _initialized; - - public static void Ensure(System.Windows.Threading.Dispatcher dispatcher) - { - dispatcher.Invoke(() => - { - if (_initialized) return; - if (Application.Current == null) - new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; - var md = Application.Current.Resources.MergedDictionaries; - if (md.Count == 0) - { - const string asm = "/VideoDownloader;component"; - md.Add(new ResourceDictionary { Source = new Uri(asm + "/Themes/Dark.xaml", UriKind.Relative) }); - md.Add(new ResourceDictionary { Source = new Uri(asm + "/Themes/Shared.xaml", UriKind.Relative) }); - } - if (!Application.Current.Resources.Contains("InverseCountToVisibility")) - Application.Current.Resources["InverseCountToVisibility"] = new InverseCountToVisibilityConverter(); - _initialized = true; - }); - } -} diff --git a/VideoDownloader.Tests/Mobile/MobileCoreTests.cs b/VideoDownloader.Tests/Mobile/MobileCoreTests.cs deleted file mode 100644 index ef78392..0000000 --- a/VideoDownloader.Tests/Mobile/MobileCoreTests.cs +++ /dev/null @@ -1,124 +0,0 @@ -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)); - - - // ── M1 공유 타겟(U1): 공유 텍스트 → URL 추출 ── - - [Theory] - [InlineData("https://youtu.be/abc123", "https://youtu.be/abc123")] // URL 만 - [InlineData("재밌는 영상\nhttps://youtu.be/abc123", "https://youtu.be/abc123")] // 제목+URL (유튜브 공유 형식) - [InlineData("http://x.com/a?b=1 입니다", "http://x.com/a?b=1")] // http + 쿼리 - [InlineData("a https://1.com b https://2.com", "https://1.com")] // 첫 번째 우선 - public void 공유_텍스트에서_URL_추출(string text, string expected) - => Assert.Equal(expected, SharePayload.ExtractUrl(text)); - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("URL 없는 그냥 텍스트")] - public void URL_없으면_null(string? text) - => Assert.Null(SharePayload.ExtractUrl(text)); - - // ── 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/Platform/HlsMuxerWiringTests.cs b/VideoDownloader.Tests/Platform/HlsMuxerWiringTests.cs deleted file mode 100644 index 2cf2f63..0000000 --- a/VideoDownloader.Tests/Platform/HlsMuxerWiringTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Net; -using System.Net.Http; -using VideoDownloader.Core.Config; -using VideoDownloader.Core.Hls; -using VideoDownloader.Core.Models; -using VideoDownloader.Core.Platform; - -namespace VideoDownloader.Tests.Platform; - -/// HlsDownloader 가 주입된 IMediaMuxer 를 실제 리먹스 경로에서 사용하는지 검증(RED: ctor 미할당 회귀 방지). -public class HlsMuxerWiringTests -{ - private sealed class RecordingMuxer : IMediaMuxer - { - public int RemuxCalls; - public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct) - { - RemuxCalls++; - File.WriteAllText(output, "remuxed"); // 성공 시뮬레이션 - return Task.CompletedTask; - } - public Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec, - HttpClient http, IProgress? progress, CancellationToken ct) => Task.CompletedTask; - public string? ResolveFfmpeg(AppConfig config) => "ffmpeg"; - } - - [Fact] - public async Task HlsDownloader_주입된_뮥서로_리먹스한다() - { - if (!OperatingSystem.IsWindows()) return; - using var server = new HttpListener(); - var port = Random.Shared.Next(20000, 60000); - server.Prefixes.Add($"http://127.0.0.1:{port}/"); - server.Start(); - - var seg = new byte[16]; - using var listenerTask = Task.Run(async () => - { - for (var i = 0; i < 3; i++) - { - var ctx = await server.GetContextAsync(); - var path = ctx.Request.Url!.AbsolutePath; - var body = path.EndsWith(".m3u8") - ? "#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2.0,\nseg0.ts\n#EXTINF:2.0,\nseg1.ts\n#EXT-X-ENDLIST\n" - : null; - var buf = body != null ? System.Text.Encoding.UTF8.GetBytes(body) : seg; - ctx.Response.ContentType = body != null ? "application/vnd.apple.mpegurl" : "video/mp2t"; - await ctx.Response.OutputStream.WriteAsync(buf); - ctx.Response.Close(); - } - }); - - var muxer = new RecordingMuxer(); - var config = new AppConfig(); - var dl = new HlsDownloader(new HttpClient(), config, muxer); - var outBase = Path.Combine(Path.GetTempPath(), $"hls-{Guid.NewGuid():N}"); - try - { - var result = await dl.DownloadAsync(new Uri($"http://127.0.0.1:{port}/index.m3u8"), outBase); - Assert.True(muxer.RemuxCalls > 0, "주입한 IMediaMuxer.RemuxAsync 가 호출되어야 한다"); - Assert.EndsWith(".mp4", result); - } - finally - { - server.Stop(); - try { await listenerTask; } catch { } - foreach (var f in Directory.GetFiles(Path.GetTempPath(), Path.GetFileName(outBase) + "*")) - File.Delete(f); - if (Directory.Exists(outBase + ".parts")) Directory.Delete(outBase + ".parts", true); - } - } -} diff --git a/VideoDownloader.Tests/Platform/PlatformAbstractionTests.cs b/VideoDownloader.Tests/Platform/PlatformAbstractionTests.cs deleted file mode 100644 index 4331466..0000000 --- a/VideoDownloader.Tests/Platform/PlatformAbstractionTests.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System.Net.Http; -using VideoDownloader.Core.Platform; -using VideoDownloader.Core.Config; -using VideoDownloader.Core.Social; - -namespace VideoDownloader.Tests.Platform; - -// ── Fake ───────────────────────────────────────────────────────────── -public sealed class FakeProcessRunner : IProcessRunner -{ - public ProcessSpec? LastSpec; - public Func?, CancellationToken, Task> Behavior = - (_, _, _) => Task.FromResult(new ProcessResult(0, "", "")); - - public Task RunAsync(ProcessSpec spec, Action? onLine, CancellationToken ct) - { - LastSpec = spec; - return Behavior(spec, onLine, ct); - } -} - -// 재구성 ②: Core Platform 추상화 — 모바일(M0) 전제가 되는 프로세스/먹서 주입 검증. -public class ProcessRunnerAbstractionTests -{ - [Fact] - public async Task DesktopProcessRunner_명령_실행과_캡처_Windows() - { - if (!OperatingSystem.IsWindows()) return; - var runner = new DesktopProcessRunner(); - var res = await runner.RunAsync( - new ProcessSpec("cmd.exe", new[] { "/c", "echo hello-vd" }), null, CancellationToken.None); - Assert.Equal(0, res.ExitCode); - Assert.Contains("hello-vd", res.Stdout); - } - - [Fact] - public async Task DesktopProcessRunner_종료코드_전파() - { - if (!OperatingSystem.IsWindows()) return; - var runner = new DesktopProcessRunner(); - var res = await runner.RunAsync( - new ProcessSpec("cmd.exe", new[] { "/c", "exit 3" }), null, CancellationToken.None); - Assert.Equal(3, res.ExitCode); - } - - [Fact] - public async Task DesktopProcessRunner_라인_탭_스트림_구분() - { - if (!OperatingSystem.IsWindows()) return; - // 인라인 리다이렉션 인용 문제를 피해 스크립트 파일로 stdout/stderr 분리 생성 - var script = Path.Combine(Path.GetTempPath(), $"vd_tap_{Guid.NewGuid():N}.cmd"); - File.WriteAllText(script, "@echo off\r\necho out-line\r\necho err-line 1>&2\r\n"); - try - { - var runner = new DesktopProcessRunner(); - var taps = new System.Collections.Concurrent.ConcurrentQueue<(string, string)>(); - var res = await runner.RunAsync( - new ProcessSpec("cmd.exe", new[] { "/c", script }), - (s, l) => taps.Enqueue((s, l)), CancellationToken.None); - Assert.Equal(0, res.ExitCode); - Assert.Contains(taps.ToArray(), t => t.Item1 == "out" && t.Item2.Contains("out-line")); - Assert.Contains(taps.ToArray(), t => t.Item1 == "err" && t.Item2.Contains("err-line")); - Assert.Contains("err-line", res.Stderr); - } - finally { try { File.Delete(script); } catch { } } - } - - [Fact] - public async Task DesktopProcessRunner_EnvPrefix로_PATH_선두_주입() - { - if (!OperatingSystem.IsWindows()) return; - var runner = new DesktopProcessRunner(); - var marker = Path.GetTempPath(); // PATH 앞에 아무 값 — cmd 는 무시하지만 실행 자체는 검증 - var res = await runner.RunAsync( - new ProcessSpec("cmd.exe", new[] { "/c", "echo ok" }, - EnvPrefix: new Dictionary { ["PATH"] = marker }), - null, CancellationToken.None); - Assert.Equal(0, res.ExitCode); - } -} - -public class MediaMuxerAbstractionTests -{ - private static string Ffmpeg => OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg"; - - [Fact] - public async Task RemuxAsync는_copy_리먹스_인자를_만든다() - { - var fake = new FakeProcessRunner(); - var muxer = new DesktopMediaMuxer(fake); - await muxer.RemuxAsync(Ffmpeg, "in.ts", "out.mp4", CancellationToken.None); - - var spec = fake.LastSpec!; - Assert.Equal(Ffmpeg, spec.ExePath); - Assert.Equal(new[] { "-y", "-hide_banner", "-loglevel", "error", "-i", "in.ts", "-c", "copy", "out.mp4" }, - spec.Args); - } - - [Fact] - public async Task RemuxAsync_실패시_stderr_포함_예외() - { - var fake = new FakeProcessRunner - { Behavior = (_, _, _) => Task.FromResult(new ProcessResult(1, "", "boom-err")) }; - var muxer = new DesktopMediaMuxer(fake); - var ex = await Assert.ThrowsAsync( - () => muxer.RemuxAsync(Ffmpeg, "in.ts", "out.mp4", CancellationToken.None)); - Assert.Contains("boom-err", ex.Message); - } - - [Fact] - public async Task DownloadDashAsync는_진행률을_파싱해_리포트한다() - { - var fake = new FakeProcessRunner - { - Behavior = (_, onLine, _) => - { - onLine?.Invoke("out", "out_time_ms=2500000"); // 2.5s / 10s = 25% - onLine?.Invoke("out", "progress=end"); - return Task.FromResult(new ProcessResult(0, "", "")); - } - }; - var muxer = new DesktopMediaMuxer(fake); - var progress = new SyncProgress(); - await muxer.DownloadDashAsync(Ffmpeg, new Uri("https://x.test/a.mpd"), "out.mp4", - durationSec: 10, http: new HttpClient(), progress: progress, CancellationToken.None); - - var spec = fake.LastSpec!; - Assert.Contains("-progress", spec.Args); - Assert.Contains("pipe:1", spec.Args); - Assert.Contains("https://x.test/a.mpd", spec.Args); - Assert.Contains("+faststart", spec.Args); - Assert.Contains(progress.Items, r => Math.Abs(r.Percent - 25.0) < 0.01); - } - - /// Progress<T> 는 SyncContext 에 큐잉되어 레이스가 나므로 동기 기록용 구현. - private sealed class SyncProgress : IProgress - { - public readonly List Items = new(); - public void Report(T value) => Items.Add(value); - } - - [Fact] - public async Task DownloadDashAsync_실패시_예외() - { - var fake = new FakeProcessRunner - { Behavior = (_, _, _) => Task.FromResult(new ProcessResult(2, "", "dash-fail")) }; - var muxer = new DesktopMediaMuxer(fake); - var ex = await Assert.ThrowsAsync( - () => muxer.DownloadDashAsync(Ffmpeg, new Uri("https://x.test/a.mpd"), "o.mp4", 10, - new HttpClient(), null, CancellationToken.None)); - Assert.Contains("dash-fail", ex.Message); - } -} - -public class YtDlpRunnerAbstractionTests -{ - [Fact] - public async Task GetVersionAsync은_주입된_러너로_version_인자와_출력을_해석한다() - { - var fake = new FakeProcessRunner - { Behavior = (spec, _, _) => Task.FromResult(new ProcessResult(0, "2026.07.04\n", "")) }; - var version = await YtDlpRunner.GetVersionAsync("yt-dlp.exe", CancellationToken.None, fake); - Assert.Equal("2026.07.04", version); - Assert.Contains("--version", fake.LastSpec!.Args); - Assert.Equal("yt-dlp.exe", fake.LastSpec.ExePath); - } - - [Fact] - public async Task RunCaptureAsync도_주입된_러너를_사용한다() - { - var fake = new FakeProcessRunner - { Behavior = (_, _, _) => Task.FromResult(new ProcessResult(0, "{\"id\":\"x\"}", "")) }; - var (exit, stdout, _) = await YtDlpRunner.RunCaptureAsync( - "yt-dlp.exe", new[] { "-J", "https://youtu.be/x" }, CancellationToken.None, fake); - Assert.Equal(0, exit); - Assert.Contains("{\"id\"", stdout); - } -} diff --git a/VideoDownloader.Tests/Platform/UpdateCheckerTests.cs b/VideoDownloader.Tests/Platform/UpdateCheckerTests.cs deleted file mode 100644 index 397c6b2..0000000 --- a/VideoDownloader.Tests/Platform/UpdateCheckerTests.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Net; -using System.Net.Http; -using VideoDownloader.Core.Update; - -namespace VideoDownloader.Tests.Platform; - -/// -/// DEPLOYMENT_PLAN §6 자동 업데이트 v1 — GitHub Releases latest 태그 비교 + 아키텍처별 설치 자산 선택. -/// 네트워크는 가짜 핸들러로 격리. -/// -public class UpdateCheckerTests -{ - private sealed class FakeHandler : HttpMessageHandler - { - private readonly HttpStatusCode _status; - private readonly string _json; - public HttpRequestMessage? LastRequest { get; private set; } - - public FakeHandler(HttpStatusCode status, string json) - { - _status = status; - _json = json; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - LastRequest = request; - return Task.FromResult(new HttpResponseMessage(_status) - { - Content = new StringContent(_json, System.Text.Encoding.UTF8, "application/json"), - }); - } - } - - private const string ReleaseJson = """ - { - "tag_name": "v1.2.0", - "html_url": "https://github.com/user/videodownloader/releases/tag/v1.2.0", - "assets": [ - { "name": "VideoDownloader-Setup-x64.exe", "browser_download_url": "https://github.com/user/videodownloader/releases/download/v1.2.0/VideoDownloader-Setup-x64.exe" }, - { "name": "VideoDownloader-Setup-arm64.exe", "browser_download_url": "https://github.com/user/videodownloader/releases/download/v1.2.0/VideoDownloader-Setup-arm64.exe" } - ] - } - """; - - [Theory] - [InlineData("1.0.0", "v1.2.0", true)] // 최신이 더 높음 - [InlineData("1.2.0", "v1.2.0", false)] // 동일 - [InlineData("2.0.0", "v1.9.9", false)] // 현재가 더 높음 - [InlineData("1.0.0.0", "v1.0.1", true)] // 어셈블리 4자리 버전 허용 - [InlineData("1.0.0", "garbage", false)] // 파싱 불가면 갱신 아님 - public void 버전비교(string current, string latest, bool expected) => - Assert.Equal(expected, UpdateChecker.IsNewer(current, latest)); - - [Fact] - public void 자산선택_x64와_arm64_구분() - { - var assets = new[] - { - new UpdateAsset("VideoDownloader-Setup-x64.exe", "https://dl/x64"), - new UpdateAsset("VideoDownloader-Setup-arm64.exe", "https://dl/arm64"), - }; - - Assert.Equal("https://dl/x64", UpdateChecker.SelectAsset(assets, "win-x64")!.DownloadUrl); - Assert.Equal("https://dl/arm64", UpdateChecker.SelectAsset(assets, "win-arm64")!.DownloadUrl); - Assert.Null(UpdateChecker.SelectAsset(assets, "linux-x64")); // 일치 없음 - } - - [Fact] - public async Task 최신확인_새버전이면_자산과_릴리스URL() - { - var handler = new FakeHandler(HttpStatusCode.OK, ReleaseJson); - var checker = new UpdateChecker(handler); - - var result = await checker.CheckAsync("user/videodownloader", "1.0.0", "win-x64"); - - Assert.NotNull(result); - Assert.True(result!.IsNewer); - Assert.Equal("1.2.0", result.LatestVersion); - Assert.Equal("https://github.com/user/videodownloader/releases/tag/v1.2.0", result.ReleaseUrl); - Assert.Equal("VideoDownloader-Setup-x64.exe", result.Asset!.Name); - - // 기본 채널(자체 Forgejo) 엔드포인트 + User-Agent(GitHub 채널 호환 규약 유지) - Assert.Equal("https://git.chanpaca.net/api/v1/repos/user/videodownloader/releases/latest", handler.LastRequest!.RequestUri!.ToString()); - Assert.NotEmpty(handler.LastRequest.Headers.UserAgent); - } - - [Fact] - public async Task 최신확인_이미_최신이면_IsNewer_false() - { - var checker = new UpdateChecker(new FakeHandler(HttpStatusCode.OK, ReleaseJson)); - var result = await checker.CheckAsync("user/videodownloader", "1.2.0", "win-x64"); - Assert.False(result!.IsNewer); - } - - [Fact] - public async Task 최신확인_API_실패는_예외로_드러남() - { - var checker = new UpdateChecker(new FakeHandler(HttpStatusCode.NotFound, """{"message":"Not Found"}""")); - await Assert.ThrowsAsync(() => checker.CheckAsync("user/none", "1.0.0", "win-x64")); - } - - // ── 배포 채널 전환 (DEPLOYMENT_PLAN §5-bis/§5-ter): 기본 = 자체 Forgejo 공개 릴리스 저장소 ── - - [Fact] - public async Task 최신확인_기본은_Forgejo_공개저장소_API() - { - var handler = new FakeHandler(HttpStatusCode.OK, ReleaseJson); - var checker = new UpdateChecker(handler); - - await checker.CheckAsync("Video-Downloader/releases", "1.0.0", "win-x64"); - - // Forgejo 는 /api/v1 접두가 필요 — GitHub 형태(api.github.com/repos/...)와 경로 규칙만 다르다 - Assert.Equal( - "https://git.chanpaca.net/api/v1/repos/Video-Downloader/releases/releases/latest", - handler.LastRequest!.RequestUri!.ToString()); - } - - [Fact] - public async Task 최신확인_apiBase_지정으로_GitHub_채널도_호환() - { - var handler = new FakeHandler(HttpStatusCode.OK, ReleaseJson); - var checker = new UpdateChecker(handler); - - var result = await checker.CheckAsync("user/videodownloader", "1.0.0", "win-x64", - apiBase: "https://api.github.com"); - - Assert.True(result!.IsNewer); - Assert.Equal("https://api.github.com/repos/user/videodownloader/releases/latest", - handler.LastRequest!.RequestUri!.ToString()); - } - - [Fact] - public void 자산선택_osx는_tar_gz_도_허용() - { - // macOS 프리뷰는 dmg 가 아니라 tar.gz 로 배포 중 — dmg 만 허용하면 mac 은 영원히 자산 없음 - var assets = new[] - { - new UpdateAsset("mediabrowser-osx-arm64.tar.gz", "https://dl/osx-arm64"), - new UpdateAsset("mediabrowser-osx-x64.tar.gz", "https://dl/osx-x64"), - }; - - Assert.Equal("https://dl/osx-arm64", UpdateChecker.SelectAsset(assets, "osx-arm64")!.DownloadUrl); - Assert.Equal("https://dl/osx-x64", UpdateChecker.SelectAsset(assets, "osx-x64")!.DownloadUrl); - } - - [Fact] - public void 업데이트_기본채널은_공개릴리스저장소() - { - var config = new VideoDownloader.Core.Config.AppConfig(); - Assert.Equal("Video-Downloader/releases", config.UpdateRepo); - Assert.Equal("https://git.chanpaca.net/api/v1", config.UpdateApiBase); - } -} diff --git a/VideoDownloader.Tests/Server/MdnsAdvertiserTests.cs b/VideoDownloader.Tests/Server/MdnsAdvertiserTests.cs deleted file mode 100644 index a1c5730..0000000 --- a/VideoDownloader.Tests/Server/MdnsAdvertiserTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Makaretu.Dns; -using VideoDownloader.Server; - -namespace VideoDownloader.Tests.Server; - -/// -/// MOBILE_PLAN §3 mDNS 광고(_videodl._tcp) — 서비스 프로필 구성(순수 로직)과 광고 생명주기. -/// 멀티캐스트 수신 단정은 네트워크/방화벽에 좌우되므로 두지 않는다(수동 증거로 확인). -/// -public class MdnsAdvertiserTests -{ - [Fact] - public void 프로필_서비스타입과_포트와_기기명_TXT() - { - var profile = MdnsAdvertiser.BuildProfile("데스크톱PC", 8642); - - Assert.Equal("_videodl._tcp", profile.ServiceName.ToString()); - var srv = Assert.Single(profile.Resources.OfType()); - Assert.Equal(8642, srv.Port); - // Makaretu 는 TXT 에 ASCII 만 허용 → 한글 기기명은 퍼센트 인코딩으로 무손실 보존 - var txt = Assert.Single(profile.Resources.OfType()); - Assert.Contains("device=" + Uri.EscapeDataString("데스크톱PC"), txt.Strings); - // 인스턴스 라벨은 ASCII 로 접힘(직렬화 가능해야 광고 자체가 나간다) - Assert.Equal("PC", profile.InstanceName.ToString()); - } - - [Fact] - public void 프로필_인스턴스명은_점을_대시로_정리() - { - var profile = MdnsAdvertiser.BuildProfile("my.host.name", 1234); - Assert.Equal("my-host-name", profile.InstanceName.ToString()); - } - - [Fact] - public void 프로필_빈_기기명은_기본값() - { - var profile = MdnsAdvertiser.BuildProfile("", 1234); - Assert.Equal("VideoDownloader", profile.InstanceName.ToString()); - } - - [Fact] - public void 광고_시작과_중복해제_안전() - { - using var adv = new MdnsAdvertiser("테스트PC", 8642); - adv.Start(); - adv.Dispose(); - adv.Dispose(); // 중복 Dispose 안전 - } -} diff --git a/VideoDownloader.Tests/Server/QueueHubE2ETests.cs b/VideoDownloader.Tests/Server/QueueHubE2ETests.cs deleted file mode 100644 index 242dbff..0000000 --- a/VideoDownloader.Tests/Server/QueueHubE2ETests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Net.Http; -using Microsoft.AspNetCore.SignalR.Client; -using VideoDownloader.Server; - -namespace VideoDownloader.Tests.Server; - -/// -/// E2E: SignalR /hubs/queue — MOBILE_PLAN §3 API 설계안의 실시간 큐 푸시. -/// 실제 Kestrel 기동 → 허브 접속(쿼리 토큰 인증) → 스냅샷 조회 + 큐 변경 시 서버 푸시 수신. -/// -public class QueueHubE2ETests : IDisposable -{ - private readonly string _libDir = Path.Combine(Path.GetTempPath(), $"vd_hub_{Guid.NewGuid():N}"); - - public QueueHubE2ETests() => Directory.CreateDirectory(_libDir); - - private sealed class FakeQueue : IQueueAdapter - { - public List Items { get; } = new(); - - public Task> GetItemsAsync() => Task.FromResult>(Items); - public Task AddAsync(string url, string? title) - { - var item = new QueueItemDto(Guid.NewGuid().ToString("N"), title ?? url, url, "대기", 0); - Items.Add(item); - Changed?.Invoke(); - return Task.FromResult(item.Id); - } - public Task PauseAsync(string id) => Task.CompletedTask; - public Task ResumeAsync(string id) => Task.CompletedTask; - public Task CancelAsync(string id) => Task.CompletedTask; - - public event Action? Changed; - } - - [Fact] - public async Task 큐허브_스냅샷_조회와_변경시_푸시_E2E() - { - var queue = new FakeQueue(); - await using var server = await ServerHost.StartAsync(new ServerOptions - { - Port = 0, - Token = "hub-token-123", - LibraryFolder = _libDir, - Queue = queue, - }); - - await using var conn = new HubConnectionBuilder() - .WithUrl($"http://127.0.0.1:{server.Port}/hubs/queue?t=hub-token-123") - .Build(); - - var pushed = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); - conn.On>("queueUpdated", items => pushed.TrySetResult(items)); - - await conn.StartAsync(); - - // 1) 허브 메서드로 현재 스냅샷 조회 - var snapshot = await conn.InvokeAsync>("GetQueue"); - Assert.Empty(snapshot); - - // 2) 큐 변경 → 폴링 없이 서버가 밀어줌 (2초 폴링의 실시간 대체) - await queue.AddAsync("https://x.test/push.mp4", "푸시 영상"); - var items = await pushed.Task.WaitAsync(TimeSpan.FromSeconds(10)); - Assert.Single(items); - Assert.Equal("푸시 영상", items[0].Title); - } - - [Fact] - public async Task 큐허브_잘못된_토큰은_401_거부() - { - await using var server = await ServerHost.StartAsync(new ServerOptions - { - Port = 0, - Token = "hub-token-123", - LibraryFolder = _libDir, - Queue = new FakeQueue(), - }); - - await using var conn = new HubConnectionBuilder() - .WithUrl($"http://127.0.0.1:{server.Port}/hubs/queue?t=wrong-token") - .Build(); - - await Assert.ThrowsAsync(() => conn.StartAsync()); - } - - public void Dispose() - { - try { Directory.Delete(_libDir, recursive: true); } catch (IOException) { } - } -} diff --git a/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs b/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs deleted file mode 100644 index cee6393..0000000 --- a/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using VideoDownloader.Server; - -namespace VideoDownloader.Tests.Server; - -/// -/// E2E (M0): 실제 Kestrel 서버 기동 → 인증/페어링/큐 제어/라이브러리/스트리밍/웹 컴패니언 검증. -/// -public class RemoteServerE2ETests : IDisposable -{ - private readonly string _libDir = Path.Combine(Path.GetTempPath(), $"vd_srv_{Guid.NewGuid():N}"); - - private sealed class FakeQueue : IQueueAdapter - { - public List Items { get; } = new(); - public List Actions { get; } = new(); - - public Task> GetItemsAsync() => Task.FromResult>(Items); - public Task AddAsync(string url, string? title) - { - var item = new QueueItemDto(Guid.NewGuid().ToString("N"), title ?? url, url, "대기", 0); - Items.Add(item); - Changed?.Invoke(); - return Task.FromResult(item.Id); - } - public Task PauseAsync(string id) { Actions.Add($"pause:{id}"); return Task.CompletedTask; } - public Task ResumeAsync(string id) { Actions.Add($"resume:{id}"); return Task.CompletedTask; } - public Task CancelAsync(string id) { Actions.Add($"cancel:{id}"); return Task.CompletedTask; } - - public event Action? Changed; - } - - [Fact] - public async Task 서버_전_API_E2E_인증_큐_라이브러리_스트리밍_웹컴패니언() - { - Directory.CreateDirectory(_libDir); - var mediaPath = Path.Combine(_libDir, "sample.mp4"); - File.WriteAllBytes(mediaPath, new byte[256 * 1024]); // 256KB 가짜 미디어 - - var queue = new FakeQueue(); - await using var server = await ServerHost.StartAsync(new ServerOptions - { - Port = 0, // 사용 가능한 포트 자동 할당 - Token = "test-token-123", - LibraryFolder = _libDir, - Queue = queue, - }); - - using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") }; - - // 1) 인증 없으면 401 - var anon = await http.GetAsync("/api/queue"); - Assert.Equal(System.Net.HttpStatusCode.Unauthorized, anon.StatusCode); - - // 2) 틀린 토큰도 401 - using var bad = new HttpRequestMessage(HttpMethod.Get, "/api/queue"); - bad.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "wrong"); - Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await http.SendAsync(bad)).StatusCode); - - // 3) 페어링(올바른 토큰) - var pair = await http.PostAsJsonAsync("/api/pair", new { token = "test-token-123", device = "E2E-Phone" }); - Assert.True(pair.IsSuccessStatusCode); - - // 4) 큐: URL 추가 → 목록 조회 - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token-123"); - var add = await http.PostAsJsonAsync("/api/queue", new { url = "https://x.test/v.mp4", title = "E2E 영상" }); - Assert.True(add.IsSuccessStatusCode); - var items = await http.GetFromJsonAsync>("/api/queue"); - Assert.NotNull(items); - Assert.Single(items!); - Assert.Equal("E2E 영상", items![0].Title); - var id = items[0].Id; - - // 5) 큐 제어 위임 - Assert.True((await http.PostAsync($"/api/queue/{id}/pause", null)).IsSuccessStatusCode); - Assert.True((await http.PostAsync($"/api/queue/{id}/resume", null)).IsSuccessStatusCode); - Assert.True((await http.PostAsync($"/api/queue/{id}/cancel", null)).IsSuccessStatusCode); - Assert.Equal(new[] { $"pause:{id}", $"resume:{id}", $"cancel:{id}" }, queue.Actions); - - // 6) 라이브러리 - var lib = await http.GetFromJsonAsync>("/api/library"); - Assert.NotNull(lib); - Assert.Single(lib!); - Assert.Equal("sample.mp4", lib![0].FileName); - - // 7) 스트리밍: Range 요청 → 206 부분 응답 - using var range = new HttpRequestMessage(HttpMethod.Get, $"/stream/{lib[0].Id}"); - range.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023); - range.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test-token-123"); - var partial = await http.SendAsync(range); - Assert.Equal(System.Net.HttpStatusCode.PartialContent, partial.StatusCode); - var bytes = await partial.Content.ReadAsByteArrayAsync(); - Assert.Equal(1024, bytes.Length); - - // 8) 파일 다운로드: 전체 길이 - var file = await http.GetByteArrayAsync($"/file/{lib[0].Id}"); - Assert.Equal(256 * 1024, file.Length); - - // 9) 웹 컴패니언 페이지 제공 - var page = await http.GetStringAsync("/"); - Assert.Contains("VideoDownloader", page); - Assert.Contains("api/queue", page); // 페이지가 API 를 호출하는 스크립트 포함 - } - - [Fact] - public async Task 이어보기_위치_동기화_E2E_저장_조회_재시작_유지() - { - Directory.CreateDirectory(_libDir); - File.WriteAllBytes(Path.Combine(_libDir, "sample.mp4"), new byte[1024]); - - var queue = new FakeQueue(); - var opts = new ServerOptions - { - Port = 0, - Token = "pos-token", - LibraryFolder = _libDir, - Queue = queue, - }; - await using var server = await ServerHost.StartAsync(opts); - 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/position/abc")).StatusCode); - - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "pos-token"); - var lib = (await http.GetFromJsonAsync>("/api/library"))!; - var id = lib[0].Id; - - // 2) 미저장 기본값 0 - var before = await http.GetFromJsonAsync($"/api/position/{id}"); - Assert.NotNull(before); - Assert.Equal(0, before!.PositionSec); - - // 3) 저장 → 조회 라운드트립 - var save = await http.PostAsJsonAsync($"/api/position/{id}", new PositionDto(93.5)); - Assert.True(save.IsSuccessStatusCode); - var after = await http.GetFromJsonAsync($"/api/position/{id}"); - Assert.Equal(93.5, after!.PositionSec); - - // 4) 존재하지 않는 항목도 기본값 0 (섹션 파일 무손상) - var unknown = await http.GetFromJsonAsync("/api/position/no-such-id"); - Assert.Equal(0, unknown!.PositionSec); - - // 5) 서버 재시작 후에도 유지(파일 영속) - await server.DisposeAsync(); - await using var server2 = await ServerHost.StartAsync(new ServerOptions - { - Port = 0, Token = "pos-token", LibraryFolder = _libDir, Queue = queue, - }); - using var http2 = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server2.Port}") }; - http2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "pos-token"); - var persisted = await http2.GetFromJsonAsync($"/api/position/{id}"); - 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, _, _) => - { - if (spec.Args[^1].EndsWith(".jpg", StringComparison.OrdinalIgnoreCase)) // 해상도 프로브(미디어 경로)는 미오염 - 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), - FfmpegPath = "ffmpeg", // 호스트 ffmpeg 무관(가짜 러너가 프로세스를 대신함) - }); - 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 deleted file mode 100644 index ada9fc2..0000000 --- a/VideoDownloader.Tests/Server/ThumbnailerTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 84ebf7c..0000000 --- a/VideoDownloader.Tests/VideoDownloader.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - net8.0-windows - enable - enable - false - true - true - - - - - - - - - - - - - - diff --git a/VideoDownloader.slnx b/VideoDownloader.slnx deleted file mode 100644 index fc8016b..0000000 --- a/VideoDownloader.slnx +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/build/Paca.wxs b/build/Paca.wxs new file mode 100644 index 0000000..d297990 --- /dev/null +++ b/build/Paca.wxs @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/deploy-site.ps1 b/build/deploy-site.ps1 index 79740b3..6450c0f 100644 --- a/build/deploy-site.ps1 +++ b/build/deploy-site.ps1 @@ -1,8 +1,8 @@ -# MEDIABROWSER 사이트(site/)를 Cloudflare Pages 에 배포한다. -# 사용: ./build/deploy-site.ps1 [-Project mediabrowser] +# Paca 사이트(site/)를 Cloudflare Pages 에 배포한다. +# 사용: ./build/deploy-site.ps1 [-Project Paca] # 필요: .env 의 CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID · node/npx(wrangler) # 절차: 공개 릴리스 저장소에서 releases.json 생성 → wrangler pages deploy -param([string]$Project = "mediabrowser") +param([string]$Project = $(if ($env:CF_PAGES_PROJECT) { $env:CF_PAGES_PROJECT } else { "mediabrowser" })) $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot diff --git a/build/installer.iss b/build/installer.iss index 794a98b..5458ecc 100644 --- a/build/installer.iss +++ b/build/installer.iss @@ -1,10 +1,10 @@ -; MediaBrowser(구 VideoDownloader) Windows 설치자 (Inno Setup) -; publish.ps1 이 /DARCH=x64 또는 /DARCH=arm64 로 2회 컴파일 → out/MediaBrowser-Setup-.exe -; 배포명만 MediaBrowser 로 개명 — AppId(GUID)·실행 파일명·데이터 경로는 유지해 기존 설치 업그레이드 호환. +; Paca(구 Paca) Windows 설치자 (Inno Setup) +; publish.ps1 이 /DARCH=x64 또는 /DARCH=arm64 로 2회 컴파일 → out/Paca-Setup-.exe +; 배포명만 Paca 로 개명 — AppId(GUID)·실행 파일명·데이터 경로는 유지해 기존 설치 업그레이드 호환. -#define APP_NAME "MediaBrowser" -#define APP_PUBLISHER "MediaBrowser" -#define APP_EXE "VideoDownloader.exe" +#define APP_NAME "Paca" +#define APP_PUBLISHER "Paca" +#define APP_EXE "Paca.exe" #ifndef APP_VERSION #define APP_VERSION "1.0.0" #endif @@ -33,12 +33,12 @@ ArchitecturesInstallIn64BitMode=x64compatible ; arm64 전용(네이티브). x64 머신에선 거부. ArchitecturesAllowed=arm64 AppId={{2E0ADFC1-2072-4457-8C50-ECFCEDECAD73} - OutputBaseFilename=MediaBrowser-Setup-arm64 + OutputBaseFilename=Paca-Setup-arm64 #else ; x64 + arm64(x64 에뮬레이션 폴백) 어디서든 설치 가능한 범용 설치자. ArchitecturesAllowed=x64compatible AppId={{65393C7D-17DC-4B9F-9D0B-F9911092C449} - OutputBaseFilename=MediaBrowser-Setup-x64 + OutputBaseFilename=Paca-Setup-x64 #endif [Languages] @@ -47,6 +47,13 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "gatewayservice"; Description: "모바일 원클릭 연결용 백그라운드 게이트웨이 서비스 자동 시작 (권장)"; GroupDescription: "원격 연결 설정"; Flags: checkedonce + +[Registry] +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "PacaGatewayDaemon"; ValueData: """{app}\{#APP_EXE}"" --gateway-daemon"; Flags: uninsdeletevalue; Tasks: gatewayservice +Root: HKCU; Subkey: "Software\Classes\paca"; ValueType: string; ValueName: ""; ValueData: "URL:Paca Protocol"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\paca"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\paca\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#APP_EXE}"" ""%1"""; Flags: uninsdeletekey [Files] ; self-contained publish 결과 전체를 앱 디렉터리로 복사 diff --git a/build/obtainium.json b/build/obtainium.json index ffaa5f7..740ec4a 100644 --- a/build/obtainium.json +++ b/build/obtainium.json @@ -1,8 +1,8 @@ -{ +{ "//": "Obtainium 원탭 등록용 앱 설정 (DEPLOYMENT_PLAN §3.1) — Forgejo 공개 릴리스 저장소 기준", - "id": "com.companyname.videodownloader.mobile", - "url": "https://git.chanpaca.net/Video-Downloader/releases", - "author": "Video-Downloader", - "name": "MediaBrowser", + "id": "com.companyname.Paca.Mobile", + "url": "https://git.chanpaca.net/Paca/releases", + "author": "Paca", + "name": "Paca", "additionalSettings": "{\"apkFilterRegEx\":\"apk$\",\"autoApkFilterByArch\":true,\"includePrereleases\":false}" } diff --git a/build/publish.ps1 b/build/publish.ps1 index 6f6ad9f..9b249c7 100644 --- a/build/publish.ps1 +++ b/build/publish.ps1 @@ -1,33 +1,48 @@ -# MediaBrowser(구 VideoDownloader) 다중 아키텍처 설치자 빌드 -# 사용: ./build/publish.ps1 [-Config Release] [-Version 1.0.0] -# 결과: out/MediaBrowser-Setup-x64.exe, out/MediaBrowser-Setup-arm64.exe +# Paca 다중 아키텍처 설치자 빌드 (Inno Setup EXE + WiX MSI) +# 사용: ./build/publish.ps1 [-Config Release] [-Version 1.1.0] [-Archs @("x64", "arm64")] +# 결과: out/Paca-Setup-x64.exe, out/Paca-Setup-x64.msi, out/Paca-Setup-arm64.exe, out/Paca-Setup-arm64.msi param( [string]$Config = "Release", - [string]$Version = "1.0.0", - [string[]]$Archs = @("x64", "arm64") + [string]$Version = "1.1.0", + [string[]]$Archs = @("x64", "arm64"), + [switch]$SkipExe, + [switch]$SkipMsi ) $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot # 레포 루트 -$app = Join-Path $root "VideoDownloader.App\VideoDownloader.App.csproj" +$app = Join-Path $root "src\Paca.App\Paca.App.csproj" $iss = Join-Path $PSScriptRoot "installer.iss" +$wxs = Join-Path $PSScriptRoot "Paca.wxs" $outDir = Join-Path $root "out" # --- ISCC(=Inno Setup 컴파일러) 위치 해결 --- $iscc = $null -foreach ($cand in @( - "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", - "$env:ProgramFiles\Inno Setup 6\ISCC.exe", - "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe", - "$env:LOCALAPPDATA\Programs\Inno Setup 6.7\ISCC.exe" -)) { - if ($cand -and (Test-Path $cand)) { $iscc = $cand; break } +if (-not $SkipExe) { + foreach ($cand in @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe", + "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe", + "$env:LOCALAPPDATA\Programs\Inno Setup 6.7\ISCC.exe" + )) { + if ($cand -and (Test-Path $cand)) { $iscc = $cand; break } + } + if (-not $iscc) { $iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source } + if (-not $iscc) { + throw "Inno Setup(ISCC) 를 찾을 수 없습니다. 설치: winget install --id JRSoftware.InnoSetup -e" + } + Write-Host "ISCC: $iscc" -ForegroundColor DarkGray } -if (-not $iscc) { $iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source } -if (-not $iscc) { - throw "Inno Setup(ISCC) 를 찾을 수 없습니다. 설치: winget install --id JRSoftware.InnoSetup -e" + +# --- WiX CLI 위치 확인 --- +if (-not $SkipMsi) { + $wixCmd = Get-Command wix -ErrorAction SilentlyContinue + if (-not $wixCmd) { + throw "WiX Toolset CLI(wix) 를 찾을 수 없습니다. 설치: dotnet tool install --global wix --version 5.0.2" + } + Write-Host "WiX: $($wixCmd.Source)" -ForegroundColor DarkGray } -Write-Host "ISCC: $iscc" -ForegroundColor DarkGray + New-Item -ItemType Directory -Force -Path $outDir | Out-Null foreach ($arch in $Archs) { @@ -40,13 +55,24 @@ foreach ($arch in $Archs) { dotnet publish $app -c $Config -r $rid -p:Platform=$arch --self-contained -o $pub if ($LASTEXITCODE -ne 0) { throw "publish 실패: $rid (exit $LASTEXITCODE). arm64 는 yt-dlp/WebView2 런타임 관련 처음 빌드 시 이슈가 있을 수 있습니다." } - Write-Host "==== [$arch] Inno Setup 컴파일 ====" -ForegroundColor Cyan - & $iscc /Q "/DAPP_VERSION=$Version" "/DARCH=$arch" "/DPUBLISH_DIR=$pub" $iss - if ($LASTEXITCODE -ne 0) { throw "ISCC 실패: $arch (exit $LASTEXITCODE)" } - Write-Host "완료: out\MediaBrowser-Setup-$arch.exe" -ForegroundColor Green + if (-not $SkipExe) { + Write-Host "==== [$arch] Inno Setup 컴파일 (.exe) ====" -ForegroundColor Cyan + & $iscc /Q "/DAPP_VERSION=$Version" "/DARCH=$arch" "/DPUBLISH_DIR=$pub" $iss + if ($LASTEXITCODE -ne 0) { throw "ISCC 실패: $arch (exit $LASTEXITCODE)" } + Write-Host "완료: out\Paca-Setup-$arch.exe" -ForegroundColor Green + } + + if (-not $SkipMsi) { + Write-Host "==== [$arch] WiX MSI 컴파일 (.msi) ====" -ForegroundColor Cyan + $msiOut = Join-Path $outDir "Paca-Setup-$arch.msi" + & wix build $wxs -d Version=$Version -d Arch=$arch -d PublishDir=$pub -d SourceDir=$root -arch $arch -o $msiOut + if ($LASTEXITCODE -ne 0) { throw "WiX MSI 실패: $arch (exit $LASTEXITCODE)" } + Write-Host "완료: out\Paca-Setup-$arch.msi" -ForegroundColor Green + } } Write-Host "" Write-Host "설치자 생성 완료:" -ForegroundColor Green -Get-ChildItem (Join-Path $outDir "MediaBrowser-Setup-*.exe") | +Get-ChildItem (Join-Path $outDir "Paca-Setup-*.*") | + Where-Object { $_.Extension -in @(".exe", ".msi") } | ForEach-Object { Write-Host (" {0,-38} {1:N1} MB" -f $_.Name, ($_.Length / 1MB)) } diff --git a/build/release.ps1 b/build/release.ps1 index c9f02ca..4bde4d0 100644 --- a/build/release.ps1 +++ b/build/release.ps1 @@ -1,8 +1,8 @@ -# MediaBrowser 원클릭 통합 릴리스 & 사이트 배포 스크립트 +# Paca 원클릭 통합 릴리스 & 사이트 배포 스크립트 # # 동작: # 1) Windows 설치자(x64, arm64) 빌드 (+ 선택: Android APK, 원격 Linux/macOS 머신 빌드) -# 2) NAS Git(Forgejo)의 공개 릴리스 저장소(Video-Downloader/releases)에 릴리스 생성 및 자산 업로드 +# 2) NAS Git(Forgejo)의 공개 릴리스 저장소(Paca/releases)에 릴리스 생성 및 자산 업로드 # 3) (선택: -DeploySite) releases.json 갱신 및 Cloudflare Pages 배포 # # 사용 예시: @@ -28,7 +28,7 @@ $root = Split-Path -Parent $PSScriptRoot $outDir = Join-Path $root "out" Write-Host "======================================================" -ForegroundColor Cyan -Write-Host " MediaBrowser 릴리스 파이프라인 (v$Version)" -ForegroundColor Cyan +Write-Host " Paca 릴리스 파이프라인 (v$Version)" -ForegroundColor Cyan Write-Host "======================================================" -ForegroundColor Cyan # ── 1. .env 환경변수 로드 ──────────────────────────────────── @@ -57,13 +57,16 @@ if (-not $SkipBuild) { if ($IncludeAndroid) { Write-Host "`n[2/4] Android APK 빌드" -ForegroundColor Yellow - $mobileCsproj = Join-Path $root "VideoDownloader.Mobile\VideoDownloader.Mobile.csproj" + $mobileCsproj = Join-Path $root "src\Paca.Mobile\Paca.Mobile.csproj" $androidOut = Join-Path $outDir "android" dotnet publish $mobileCsproj -f net10.0-android -c $Config -p:AndroidPackageFormat=apk -p:CiPackArm64Only=true -o $androidOut if ($LASTEXITCODE -eq 0) { - $apk = Get-ChildItem (Join-Path $androidOut "*.apk") | Select-Object -First 1 + $apk = Get-ChildItem (Join-Path $androidOut "*Signed.apk") | Select-Object -First 1 + if (-not $apk) { + $apk = Get-ChildItem (Join-Path $androidOut "*.apk") | Select-Object -First 1 + } if ($apk) { - $targetApk = Join-Path $outDir "MediaBrowser-$Version-arm64.apk" + $targetApk = Join-Path $outDir "Paca-$Version-arm64.apk" Copy-Item $apk.FullName $targetApk -Force Write-Host "Android APK 생성 완료: $targetApk" -ForegroundColor Green } @@ -88,9 +91,10 @@ if (-not $SkipBuild) { # ── 3. 수집된 자산 목록 확인 ────────────────────────────────── $assetsToUpload = @() $candPatterns = @( - "MediaBrowser-Setup-*.exe", - "MediaBrowser-*-arm64.apk", - "mediabrowser-*.tar.gz" + "Paca-Setup-*.exe", + "Paca-Setup-*.msi", + "Paca-*-arm64.apk", + "Paca-*.tar.gz" ) foreach ($pat in $candPatterns) { @@ -137,6 +141,6 @@ Write-Host " 릴리스 완료: $tag" -ForegroundColor Green if ($Publish) { Write-Host " 상태: 정식 공개(Published)" -ForegroundColor Green } else { - Write-Host " 상태: 초안(Draft) — 웹 UI(https://git.chanpaca.net/$Repo/releases)에서 확인 후 공개 가능" -ForegroundColor Yellow + Write-Host " 상태: 초안(Draft) - 웹 UI(https://git.chanpaca.net/$Repo/releases)에서 확인 후 공개 가능" -ForegroundColor Yellow } Write-Host "======================================================" -ForegroundColor Green diff --git a/build/remote/build-remote.ps1 b/build/remote/build-remote.ps1 index 171d043..2d84e1f 100644 --- a/build/remote/build-remote.ps1 +++ b/build/remote/build-remote.ps1 @@ -1,6 +1,6 @@ -# MediaBrowser 원격 빌드 수집 스크립트 (PowerShell) +# Paca 원격 빌드 수집 스크립트 (PowerShell) # 빌드머신(vd-linux / vd-mac)에서 Cli + Avalonia 셸을 RID별 self-contained publish 하고 -# 산출물을 out/mediabrowser-.tar.gz 로 수집한다. +# 산출물을 out/Paca-.tar.gz 로 수집한다. # # 사용: ./build/remote/build-remote.ps1 [-Target mac|linux|all] param( @@ -13,7 +13,7 @@ $root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $outDir = Join-Path $root "out" New-Item -ItemType Directory -Path $outDir -Force | Out-Null -$remoteDir = "build/videodownloader" +$remoteDir = "build/Paca" function Push-Source { param([string]$HostName) @@ -36,16 +36,16 @@ function Build-And-Collect { param([string]$HostName, [string]$Rid) Write-Host "==> [$HostName] publish $Rid" -ForegroundColor Cyan $cmd = "cd $remoteDir && export DOTNET_ROOT=`$HOME/.dotnet && export PATH=`$HOME/.dotnet:`$PATH && " + - "dotnet publish VideoDownloader.Cli -c Release -r $Rid --self-contained -o out/cli-$Rid && " + - "dotnet publish VideoDownloader.Avalonia -c Release -r $Rid --self-contained -o out/avalonia-$Rid && " + - "cd out && tar -czf mediabrowser-$Rid.tar.gz cli-$Rid avalonia-$Rid" + "dotnet publish src/Paca.Cli -c Release -r $Rid --self-contained -o out/cli-$Rid && " + + "dotnet publish src/Paca.Avalonia -c Release -r $Rid --self-contained -o out/avalonia-$Rid && " + + "cd out && tar -czf Paca-$Rid.tar.gz cli-$Rid avalonia-$Rid" & ssh.exe $HostName $cmd if ($LASTEXITCODE -ne 0) { throw "[$HostName] 빌드 실패: $Rid" } - $localTarget = Join-Path $outDir "mediabrowser-$Rid.tar.gz" - Write-Host "==> [$HostName] 수집: mediabrowser-$Rid.tar.gz" -ForegroundColor Green - & scp.exe "${HostName}:${remoteDir}/out/mediabrowser-$Rid.tar.gz" $localTarget + $localTarget = Join-Path $outDir "Paca-$Rid.tar.gz" + Write-Host "==> [$HostName] 수집: Paca-$Rid.tar.gz" -ForegroundColor Green + & scp.exe "${HostName}:${remoteDir}/out/Paca-$Rid.tar.gz" $localTarget if ($LASTEXITCODE -ne 0) { throw "[$HostName] 산출물 수집 실패: $Rid" } } @@ -62,5 +62,5 @@ if ($Target -eq "linux" -or $Target -eq "all") { } Write-Host "`n원격 빌드 및 수집 완료:" -ForegroundColor Green -Get-ChildItem (Join-Path $outDir "mediabrowser-*.tar.gz") | +Get-ChildItem (Join-Path $outDir "Paca-*.tar.gz") | ForEach-Object { Write-Host (" {0,-35} {1:N1} MB" -f $_.Name, ($_.Length / 1MB)) } diff --git a/build/remote/build-remote.sh b/build/remote/build-remote.sh index 1bcd7a3..988cb78 100644 --- a/build/remote/build-remote.sh +++ b/build/remote/build-remote.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash # 빌드머신(ChanpacaLaptop=linux / Mac mini=macos)에서 # Cli + Avalonia 셸을 RID별 self-contained publish 하고 -# 산출물을 로컬 out/mediabrowser-.tar.gz 로 수집한다. +# 산출물을 로컬 out/Paca-.tar.gz 로 수집한다. # # 사용: bash build/remote/build-remote.sh [linux|mac|all] set -euo pipefail cd "$(dirname "$0")/../.." TARGET="${1:-all}" -REMOTE_DIR="build/videodownloader" +REMOTE_DIR="build/Paca" mkdir -p out @@ -22,12 +22,12 @@ build_and_collect() { # $1 = ssh 대상, $2 = RID local rid="$2" echo "==> [$host] publish $rid" ssh -o BatchMode=yes "$host" "cd $REMOTE_DIR && export DOTNET_ROOT=\$HOME/.dotnet && export PATH=\$HOME/.dotnet:\$PATH && \ - dotnet publish VideoDownloader.Cli -c Release -r $rid --self-contained -o out/cli-$rid && \ - dotnet publish VideoDownloader.Avalonia -c Release -r $rid --self-contained -o out/avalonia-$rid && \ - cd out && tar -czf mediabrowser-$rid.tar.gz cli-$rid avalonia-$rid" + dotnet publish src/Paca.Cli -c Release -r $rid --self-contained -o out/cli-$rid && \ + dotnet publish src/Paca.Avalonia -c Release -r $rid --self-contained -o out/avalonia-$rid && \ + cd out && tar -czf Paca-$rid.tar.gz cli-$rid avalonia-$rid" - echo "==> [$host] 수집: out/mediabrowser-$rid.tar.gz" - scp -o BatchMode=yes "$host:$REMOTE_DIR/out/mediabrowser-$rid.tar.gz" "out/mediabrowser-$rid.tar.gz" + echo "==> [$host] 수집: out/Paca-$rid.tar.gz" + scp -o BatchMode=yes "$host:$REMOTE_DIR/out/Paca-$rid.tar.gz" "out/Paca-$rid.tar.gz" } if [[ "$TARGET" == "mac" || "$TARGET" == "all" ]]; then @@ -43,4 +43,4 @@ if [[ "$TARGET" == "linux" || "$TARGET" == "all" ]]; then fi echo "==> 원격 빌드 완료:" -ls -lh out/mediabrowser-*.tar.gz 2>/dev/null || true +ls -lh out/Paca-*.tar.gz 2>/dev/null || true diff --git a/design.md b/design.md new file mode 100644 index 0000000..d7fec01 --- /dev/null +++ b/design.md @@ -0,0 +1,131 @@ +# design.md — PACA 디자인 시스템 및 테마 규격 (Designpaca SSOT) + +## 0. 브리프 요약 (3-Line Brief) +- **무엇을**: PACA 브라우저 & 다운로더의 데스크톱(WPF) 및 모바일(MAUI) 프레임의 라이트/다크 테마 100% 동기화, 버튼 디자인 전면 정돈, 및 다운로드 사이드 패널의 과밀(Overcrowding) 해소와 3단계 슬림 레이아웃 개편. +- **누구에게**: PACA 사용자에게 웹페이지뿐 아니라 브라우저 외곽 프레임과 사이드 패널까지 시각적 피로도 없는 스위스 미니멀리즘(Swiss Minimalist / Linear-inspired) 감성을 제공. +- **제약**: .NET 8 WPF 데스크톱, .NET 10 MAUI 모바일, 1,737건 xUnit 테스트 전수 무결성, 1px 사각형 보더라인 금지, WCAG 2.1 AA/AAA 명도 대비 준수. + +--- + +## 1. 디자인 방향 및 미학 프리셋 (Aesthetic Direction) +- **프리셋**: **스위스 미니멀리즘 (Swiss Minimalist) & 리니어(Linear) 스타일** +- **핵심 철학**: + 1. **헤어라인 테두리 금지 (No Boxy Outlines)**: 인터랙션 버튼과 라디오 탭에 투박한 `1px Solid Border`를 두르지 않는다. 배경과 요소의 톤온톤 명도 차이(Surface Contrast)와 호버 음영(Soft Tint)으로 경계를 표현한다. + 2. **프레임 테마 일관성 (Full Frame Sync)**: 라이트 테마 전환 시 웹뷰 내부뿐 아니라 상단 툴바, 탭 스트립, 옴니박스 캡슐, 하단 내비게이션 바까지 100% 밝은 톤으로 전환된다. + 3. **과밀 방지 및 공간 위계 (Anti-Overcrowding & Visual Hierarchy)**: 한정된 사이드바 폭에 수많은 컨트롤을 한 줄에 쑤셔넣지 않는다. 검색창은 온전한 가로폭을 확보하고, 보조 필터는 서랍(Drawer)으로 필요할 때만 노출한다. + +--- + +## 2. 디자인 토큰 체계 (Design Tokens) + +### 2.1 데스크톱 (Paca.App - Themes/Light.xaml & Themes/Dark.xaml) +| 토큰명 | 라이트 테마 (Light) | 다크 테마 (Dark) | 용도 | +|---|---|---|---| +| `BgPrimary` / `Background` | `#F8FAFC` | `#0B0B0E` | 메인 윈도우 배경 | +| `TopBarBackground` / `ToolbarBg` | `#FFFFFF` | `#111116` | 상단 탭/툴바 프레임 | +| `Surface1` / `CardBg` | `#FFFFFF` | `#111116` | 팝업, 카드, 모달 표면 | +| `Surface2` / `OmniboxBg` | `#F1F5F9` | `#181820` | 옴니박스 입력창, 세컨더리 카드, 필터 서랍 배경 | +| `CardBgHover` | `#E2E8F0` | `#1E1E28` | 버튼/탭 호버 및 활성 선택 시 소프트 음영 | +| `TextPrimary` | `#0F172A` | `#F8FAFC` | 본문, 탭 타이틀, 옴니박스 텍스트 | +| `TextSecondary` | `#475569` | `#94A3B8` | 아이콘 글리프, 보조 레이블 | +| `Accent` | `#3B82F6` | `#3B82F6` | 다운로드 상태, 활성 인디케이터 | + +### 2.2 모바일 (Paca.Mobile - MobileThemeSystem & BrowserPage) +| 토큰명 | 라이트 테마 (Light) | 다크 테마 (Dark) | 용도 | +|---|---|---|---| +| `Background` | `#F8FAFC` | `#08080C` | 페이지 배경 | +| `TopBarBackground` | `#FFFFFF` | `#0E0E14` | 상단 옴니박스 바 배경 | +| `BottomBarBackground` | `#FFFFFF` | `#0E0E14` | 하단 7개 메뉴 툴바 배경 | +| `UrlCapsuleBackground` | `#F1F5F9` | `#161622` | URL 입력 캡슐 배경 | +| `ButtonSubtleBackground`| `#E2E8F0` | `#1E1E2A` | 탭 카운터 버튼 배경 음영 | +| `TextPrimary` | `#0F172A` | `#FFFFFF` | 주 텍스트 | +| `TextSecondary` | `#475569` | `#94A3B8` | 툴바 아이콘 및 보조 텍스트 | + +--- + +## 3. 버튼 및 탭 컴포넌트 규약 (Component Design System) +1. **GhostButton & IconButton (보더리스 폰트/아이콘 버튼)**: + - `BorderThickness`: `0` (외곽선 완전 제거) + - Normal: 투명 배경, `TextSecondary` 폰트 컬러 + - Hover: `CardBgHover` 은은한 톤온톤 배경 음영, 부드러운 코너 라운딩(`CornerRadius="6"`) +2. **CardActionButton (소프트 음영 카드 액션 버튼)**: + - `BorderThickness`: `0` + - Normal: 투명 배경 또는 은은한 베이스 틴트 + - Hover: `CardBgHover`, `BorderBrush` 없음 +3. **FilterTabStyle (모던 세그먼트 알약 탭)**: + - `BorderThickness`: `0` (투박한 1px 사각형 박스 테두리 박멸) + - IsChecked: `CardBgHover` 은은한 소프트 배경, `TextPrimary` 세미볼드 폰트 +4. **다운로드 사이드 패널 3단계 슬림 레이아웃**: + - 1행: 시원한 풀 너비 검색창 + 우측 미니멀 아이콘 툴바 (미디어 재수집, 완료 정리, 전체 비우기, 콤팩트 토글) + - 2행: 핵심 상태 탭 (`전체` `진행` `완료` `실패`) + 우측 필터 서랍 토글 버튼 (`⚙`) + - 3행 (접이식): `FilterDetailsDrawer` (평소에는 닫혀있어 공간을 차지하지 않으며, 토글 시 미디어 종류, 포맷, 정렬, 완료 후 행동이 2열로 여유 있게 노출) + - 4행: 하단 일괄 작업 및 속도 제한 콤보박스 (너비 82px로 텍스트 잘림 방지) + +--- + +## 4. 자율 검증 및 감사 게이트 (Quality DoD) +- **Gate 82 (WPF Themes & Borderless Button)**: `Themes/Shared.xaml` 내 `GhostButton`, `CardActionButton`, `NewTabButtonStyle`의 `BorderThickness`가 `0`이어야 함. +- **Gate 83 (Desktop Light Frame Sync)**: 데스크톱 `MainWindow`에 라이트 테마 적용 시 `TopBarBackground`가 `#FFFFFF`로 반영되어야 하며, 다크 하드코딩이 없어야 함. +- **Gate 84 (Mobile Theme System & Frame Sync)**: `MobileThemeSystem`의 라이트 팔레트 및 `BrowserPage.ApplyTheme`가 상단바/하단바/캡슐/버튼을 온전히 라이트/다크로 전환해야 함. +- **Gate 85 (Download Panel Streamlined Layout & No Overcrowding)**: `FilterTabStyle` 사각형 테두리 제거, 속도 제한 콤보박스 80px 이상 확보, 접이식 필터 서랍(`FilterDetailsDrawer`) 및 토글 버튼(`ToggleFilterDrawerBtn`) 선언 무결성 검증. + +--- + +## 5. 사이트(site/) 디자인 결정 기록 — designpaca 리디자인 (2026-09-11) + +앱 UI와 별개로 홍보 사이트(`site/`)의 토큰·구조 결정. 앱 테마 결정(위 섹션)이 우선이며, 사이트는 여기서 정한 것을 따른다. + +### 브리프 3줄 +- **무엇을**: PACA 브라우저·다운로더 홍보 사이트(기존 site/ 리디자인) — 기존 사이트가 다운로더 정체성에 치우쳐 "다운로드 엔진의 계기판"으로 재정렬. +- **누구에게**: 한국어권 Windows 사용자 — 설치자 다운로드(1차), GitHub·가이드(2차, 가정으로 진행: 사용자 미답변, 미확정 목록 참조). +- **제약**: 정적 HTML/CSS/JS, 기존 배포 도구(deploy-site.ps1·fetch-releases.mjs) 계승, ko-KR. + +### 경로·레퍼런스 +- 경로: **전체** (토큰 재정의 있음, 사이트용 design.md 부재) +- R1 구조: Vivaldi 한국어 페이지 — 앱 실물 프레임 히어로, CTA 상하 반복, 피처 그리드 위계 (실측: 섹션 패딩 128, 컨테이너 1280~1344) +- R2 톤: Blackmagic DaVinci Resolve — 다크 계기판(#090909 대역, 단일 액센트, 모노 수치, 소폭 라디우스) [업종 상이 ✓] +- R3 디테일: Codrops 스크롤 드리븐 애니메이션(CSS `scroll()` 타임라인) — 상단 진행 게이지, transform만, 미지원 브라우저는 정적 폴백 + +### 프리셋·컨셉·리스크 +- 프리셋: **dark-instrument** (다크 정당화: 제품 자체가 어두운 앱 + 계기판 프리셋) +- 컨셉: "다운로드 엔진의 계기판 — 제품이 일하는 걸 숫자로 보여주는 다크 콘솔" +- 감수한 리스크 1개: 히어로의 인터랙티브 시뮬레이터에 표면 결정을 집중 + +### 토큰 (site/styles.css) +- 색: 표면 #08090d/#0d1017/#11141d/#161b28 / 텍스트 #f8fafc·#94a3b8·#8fa0b5 / 액센트 #3b82f6(앱 계승, 좁은 면적) + 상태 틴트 #60a5fa·호버 #2563eb·프레스 #1d4ed8 (동일 파랑 가족) +- 타입: Wanted Sans Variable(SIL OFL, 한글 동적 서브셋 CDN) + 시스템 모노(다운로드 0KB 정당화), 본문 lh 1.65, `keep-all`, 헤드라인 자간 -0.02em, 수치 `tabular-nums` +- 형태: radius 3종 계약 — sm 4 / md 12 / pill +- 모션: instant 100ms·quick 200ms, transform·opacity만, `prefers-reduced-motion` 전면 대응 +- 성능 예산: JS ~7KB(측정치, 기본선 150KB 대비 하향) / 폰트 패밀리 2 / 외부 요청 1(폰트 CSS) + +### 이펙트 제거 결정 (사용자 승인 전 정책상 자율 적용) +- spotlight 카드 커서 추적, ambient 스포트라이트(blur 70px), mac식 윈도우 도트(Windows 앱 표현 왜곡 — Windows 타이틀바 텍스트로 교체), 프로그레스 그라디언트 — 계기판 프리셋의 "재질 절제"와 진정성 게이트에 따라 제거 + +### 감사 결과 (design-gate.mjs, site/gate.config.json) +- **게이트 통과**: SEO/meta·오버플로(320~1440 전 폭)·대비 AA·터치타깃 44px·토큰 위생·h1 2줄·수축·리듬 전부 PASS +- 교정된 실제 결함: 버튼 대비 3.68→5.5(#2563eb), 한글 keep-all 부재, radius 5종 위반, 가이드 번호 리스트 ul→ol 시맨틱, 안내문 이모지 제거, "~를 통해" 번역투 교정, 모바일 메뉴 Esc 복원, 인라인 링크 타깃 24px +- deadCss 검사는 비활성화(사유: site.js 렌더링 마크업 + 4페이지 공유 CSS로 오탐) — 대신 제거 삼위일чие grep으로 수동 검증(spotlight·window-dot·ambient-spotlight 0건) + +### 미확정 목록 (사용자 확인 필요) +- 1차 CTA를 "다운로드/설치"로 가정(2차: GitHub). 바꾸려면 히어로 CTA 라벨과 릴리스 페이지 링크만 수정 +- 시뮬레이터 수치(18.4 MB/s·72%·16 Threads)는 예시 캡션으로 명시됨 — 실측 벤치마크로 교체 후보 + +### 하지 않기로 한 것 +- 라이트 테마 사이트 버전 — 계기판 프리셋의 단일 다크 방향(제품 정체성) 우선. 토큰은 역할 기반이라 추후 `prefers-color-scheme` 확장 가능 +- 언론 인용 스트립 — 실제 보도 자산이 없어 하드 게이트 11 위반 소지 +- 3D/WebGL/그레인 재질 — 예산 대비 기여 낮음(계기판 인상은 타이포·수치·게이지로 달성) +- 콘텐츠 카피 전면 교체 — 기존 승인된 카피(과장 완화 버전) 유지, 구조만 계기판으로 재조명 + +### 2차 패스 — 시그니처 비주얼 (2026-09-11, 사용자 피드백 "너무 평이하다") +- 히어로 재구성: 전면 디스플레이 헤드라인(2줄 계약 유지, 1440px에서 83px, 액센트 둘째 줄 `h1-accent`) + 하단 0.85:1.15 그리드(서브·CTA | 시뮬레이터) +- 엔진 글로우: 시뮬레이터에 파랑 링+글로우 섀도, 3도 틸트(호버 평탄화, reduced-motion 비활성), ambient 레이어에 정적 라디얼 글로우 2개 +- 기계식 디테일: 카드 코너 틱(호버 액센트), 섹션 모노 인덱스(`.section-head h2::before`, CSS 카운터), eyebrow 틱 라인, bento/step 아이콘 글로우 링 +- 밴드 리듬: `#compare`·플랫폼 섹션에 `--bg-band` 표면 반전 + 헤어라인(게이트 4의 "토큰으로 정의된 리듬 반전"으로 계약) +- 타입 대비 확대: h1 clamp 재조정 — 320~1440 × 배율 1.3x 전 조합에서 2줄 유지(게이트 스케일×폭 통과) +- 게이트 재통과(실패 0건), 오버플로 1440==1440·390==390, h1 2줄 실측 + +- 2026-09-11 · 법적 고지 섹션(index #legal + 전 페이지 푸터 링크) 추가 — 업계 관행(youtube-dl 저작권 정책, 4K Download ToU §2/§7/§13, cobalt zero-liability, yt-dlp Unlicense 무보증)을 한국어로 조판. 레이아웃 패밀리 1개 추가, 토큰 변경 없음, 게이트 재통과 +- 2026-09-11 · SEO/AEO 적용 — robots.txt(전 크롤러 Allow), llms.txt(요약·페이지·확인된 사실만), sitemap.xml, canonical 3페이지, JSON-LD(SoftwareApplication: 무료 offers·featureList, aggregateRating 없음 — 리치결과 자격은 없으나 AI 구조화 시그널로 유지 / guide FAQPage: 실제 FAQ 3건). 히어로 서브에 "미디어 브라우저" 카테고리 정의문을 상단 30% 안에 배치(AI 인용 55%가 페이지 상단 30%에서 발생 — CXL/Kevin Indig). 근거: Semrush 인용 5요인(명확성 +32.8%, Q&A +25.5%, 구조화 +21.6%), Rutgers/Wharton AI 크롤러 차단 시 트래픽 -23.1% +- 2026-09-11 · 도메인 정오타 + Search Console 등록 — canonical/OG/sitemap/llms.txt에 있던 존재하지 않는 Paca.chanpaca.net을 실제 서빙 도메인 mediabrowser.chanpaca.net으로 전면 교정(SEO 결함 — canonical이 DNS 없는 도메인 가리킴), 재배포. Search Console에서 URL 접두어 속성 https://mediabrowser.chanpaca.net/ 생성·HTML 파일 방식으로 소유권 확인(확인 파일은 Cloudflare Pages의 .html→확장자 없음 308 리다이렉트를 거쳐 200 서빙 — 삭제 금지), sitemap.xml 제출 완료. 향후 더 짧은 커스텀 도메인을 쓰려면 Cloudflare 대시보드에서 DNS CNAME 수동 추가 후 속성 이관 +- 2026-09-11 · paca.chanpaca.net 별칭 활성화 — .env의 기존 토큰에 DNS 편집 권한이 있어 CNAME 직접 생성 가능했음(zone id 저장 완료). API로 Pages 도메인 추가 시 검증이 계속 pending("CNAME record not set") → 대시보드 플로우(도메인 입력→자동 DNS 레코드 생성→활성화)로 해결. 두 도메인 모두 200 서빙 중. canonical은 mediabrowser 유지(GSC 인증·색인 기반), paca는 별칭. Pages 도메인 삭제 API는 ID가 아니라 도메인 이름으로 호출해야 함(등록 id로 404 나오는 함정) +- 2026-09-11 · 검색 엔진 등록 완료 — (1) GSC 홈페이지 URL 검사 → "색인 생성 요청" 제출(우선순위 크롤링 대기열 등록). (2) README에 홈페이지·다운로드·가이드 백링크 추가(커밋 a88aafa, Forgejo 푸시). (3) Bing Webmaster Tools — Google 계정 OAuth로 GSC에서 사이트+사이트맵 import 성공(Bing 인덱스는 ChatGPT 검색·AI Overviews가 상당 부분 사용). (4) canonical 최종 결정: mediabrowser.chanpaca.net 본칭 유지 — GSC 인증·사이트맵이 이미 귀속됐고 도메인명이 카테고리 키워드("미디어 브라우저")를 포함, paca는 별칭. (5) Forgejo 저장소 메타데이터 — 설명+웹사이트 백링크(https://mediabrowser.chanpaca.net/)+토픽 8종(video-downloader, media-browser, hls, dash, ffmpeg, wpf, dotnet, csharp) 등록 diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index c9e223e..bc1c593 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -3,62 +3,841 @@ > **출처**: 2026-07-26 다차원 코드 감사(9개 차원, 원시 63개 → 상위 15 선정). 모두 실제 소스 기반(file:line 검증). > **사용법**: 완료한 항목은 `- [ ]` → `- [x]` 로 체크. -## 🏆 우선 구현 추천 (TOP 3) -1. **#1 다운로드 취소 버튼** — 멈출 수 없는 치명 UX 결함, 코드에 Cts만 있어 한 줄이면 끝. -2. **#2 ffmpeg stderr 드레인 + Kill** — hang/데드락/좀비 프로세스 안전 결함 + 리먹스 실패 노출. -3. **#5 동시 다운로드 전역 제한(SemaphoreSlim)** — 소켓 고갈(#11)과 직결, 정적 필드 하나짜리 퀵윈. +## 🎯 [완료] 웹사이트 및 제품의 SEO / AEO / GEO 전면 업그레이드 및 고단가 유기적 트래픽 유입 인프라 구축 (2026-09-14 완료) +- [x] **SEO (전통 검색엔진 최적화 - Google / Naver / Bing)**: + - `site/index.html` 내 풍부한 Schema.org JSON-LD 구조화 데이터 탑재: `SoftwareApplication` (버전, 다운로드 링크, 무료 가격 모델, 평점 4.9/5, 세부 기능 명세) 및 `FAQPage` (로그인 세션, IDM 대체, 무료 정책 Q&A). + - 글로벌 고단가(CPM $10~$25) 해외 트래픽 유입을 위한 이중 Hreflang 메타 태그 (`ko`, `en`, `x-default`) 및 타깃 키워드 메타 태그 적용. + - HLS m3u8 고검색량 키워드 전용 랜딩 페이지 `site/how-to-download-m3u8.html` 신설 및 `HowTo` + `FAQPage` 복합 Schema.org 배포. +- [x] **AEO (인공지능 답변엔진 최적화 - ChatGPT Search / Perplexity / Copilot / Naver Cue:)**: + - 사용자 질문에 직결되는 두괄식 즉답(Inverted Pyramid) Q&A 구조 배치. + - 경쟁 상용 툴 대비 특장점 상세 비교 랜딩 페이지 `site/vs-idm-alternative.html` 신설: PACA vs IDM vs 4K Video Downloader vs 크롬 확장 프로그램 8대 핵심 항목(가격, 내장 브라우저, 세션 쿠키, m3u8 자동 감지, AES-128, 4K/8K, 모바일 스트리밍, 악성코드 안전성) 비교 매트릭스 표 구축. +- [x] **GEO (생성형 AI 모델 인덱싱 및 인용 최적화 - Answer.AI / Anthropic 표준)**: + - Answer.AI 표준 `/llms.txt` 요약 명세서 구축. + - LLM 모델이 직접 크롤링하여 학습/인용할 수 있는 4,000자+ 상세 기술 명세서 `site/llms-full.txt` 신설 (아키텍처, 벤치마크 수치, 세부 가이드, FAQ, 보안 정책). + - `site/robots.txt`에 주요 AI 크롤러 봇(`GPTBot`, `PerplexityBot`, `ClaudeBot`, `Google-Extended`, `Bingbot`, `Yeti`) 전면 허용 및 `llms.txt` 경로 공개. +- [x] **사이트맵 및 탐색 내비게이션 확장 (`site/sitemap.xml`)**: + - 메인, 다운로드, 가이드뿐 아니라 m3u8 가이드, IDM 비교, `llms.txt`, `llms-full.txt` 등 전체 리소스 색인 등록 및 최신 `lastmod` 부여. +- [x] **TDD 게이트 무결성 및 전수 검증 (`SiteSeoAeoGeoGateTests.cs` Gate 211~213)**: + - Gate 211 (Schema.org 구조화 데이터 및 HowTo/FAQPage 무결성), Gate 212 (AI 크롤러 봇 허용 및 llms.txt/llms-full.txt 규격), Gate 213 (AEO 비교 테이블, sitemap.xml, Hreflang) 전수 통과. + - 솔루션 전체 테스트 **2,749 / 2,749 GREEN 전수 통과 (100% 통과, 0 실패, 0 스킵)**. + +## 🎯 [완료] 가짜 모의 광고 전면 척결 및 실시간 구글 공식 광고 네트워크(GPT/AdSense) 인프라 & ads.txt/도메인 오리진 격리 수익화 정상화 (2026-09-14 완료) +- [x] **가짜 모의 광고(쿠팡, 삼성 등 목업 카드) 전면 영구 박멸**: + - `DesktopAdModels.cs`, `DesktopAdBannerControl.xaml.cs` 등 모든 프로덕션 코드 및 모델에서 하드코딩된 모의 광고(쿠팡, 갤럭시 S24, 삼성전자, 가짜 할인 카드 등)를 100% 제거. + - 가짜 목업 UI 대신 실제 구글 광고 네트워크(Google Publisher Tag `gpt.js` 및 Google AdSense `adsbygoogle.js`) 연동. +- [x] **공식 구글 퍼블리셔 인증 파일 배포 (`site/ads.txt`, `site/app-ads.txt`)**: + - 사용자 고유 퍼블리셔 ID 기반 정합 레코드 배포: `google.com, pub-1039714767792854, DIRECT, f08c47fec0942fa0`. + - 가상 호스트 디렉터리(`ad_html/ads.txt`, `ad_html/app-ads.txt`) 및 공식 웹 사이트 배포 디렉터리(`site/`)에 배치하여 크롤러 인증 및 수익화(단가 획득) 보장. +- [x] **uBlock Origin 확장 프로그램 간섭 차단을 위한 독립 광고 WebView2 환경 구축**: + - 사용자 프로필에 설치된 uBlock Origin 등 광고 차단 확장이 브라우징 탭에는 정상 동작하되, 데스크톱 광고 슬롯은 차단하지 않도록 독립 전용 프로필(`%AppData%\Paca\ad_profile`) 및 `--disable-extensions` 플래그로 분리 격리. +- [x] **가상 HTTPS 오리진 매핑 (`https://ads.paca.cloud`)**: + - `SetVirtualHostNameToFolderMapping`을 통해 로컬 `file:///` 프로토콜의 Referer 부재 및 AdSense 거부 문제를 해결하고, 실제 웹 보안 HTTPS 오리진(`https://ads.paca.cloud/`)으로 광고 서빙. + - ``, ``, 정규화 canonical 태그 탑재. +- [x] **TDD 게이트 무결성 및 실시간 렌더링 검증**: + - Gate 209 (퍼블리셔 식별자 및 ads.txt/app-ads.txt 검증), Gate 210 (가짜 모의 광고 박멸 및 AdSense/GPT 스니펫 무결성 검증) 통과. + - 전체 테스트 **2,746 / 2,746 GREEN 전수 통과 (100% 통과, 0 실패, 0 스킵)**. + - 실제 구글 광고 네트워크 응답(`AD_RENDERED`) 수신 및 AdChoices 아이콘이 포함된 듀얼 실시간 광고 렌더링 확인. + +## 🎯 [완료] 데스크톱 인앱 스크린샷 디버깅 시스템 및 듀얼 광고 배너 CSS Grid 렌더링 정상화 (2026-09-13 완료) +- [x] **앱 자체 윈도우 정밀 캡처 디버깅 파이프라인 (`AppDebugScreenshotService.cs`)**: + - 외부 전체 화면/모니터 캡처가 아닌 PACA 앱 윈도우(`HWND` 및 `RenderTargetBitmap`)만을 단독으로 정확하게 캡처하여 `%AppData%\Paca\debug_screenshot.png`에 저장. + - `%AppData%\Paca\screenshot_trigger.req` 파일 감지 기반 온디맨드 디버깅 캡처 지원. +- [x] **하단 배너 (728x90) 및 다운로드 패널 배너 (300x250) CSS Grid 렌더링 정상화 (`DesktopAdBannerControl.xaml.cs`)**: + - 하단 배너: Flexbox 축소로 인한 중앙 텍스트 증발 문제를 완전한 CSS Grid(`56px 1fr auto`)로 교체하여 쿠팡 로고, 배지, 본문 제목, 설명, CTA 버튼 완벽 복원. + - 다운로드 패널 배너: 뷰포트 크기 미지정으로 카드가 화면 밖으로 밀려나 검은 직사각형(762B)으로만 찍히던 문제를 `` 및 3행 그리드로 해결하여 삼성전자 로고, 배지, 제목, 설명, CTA 버튼 100% 온전 렌더링. + - 가상 호스트 HTTP 캐싱을 방지하고 즉각적 DOM 구성을 위해 `CoreWebView2.NavigateToString(pageHtml)` 적용. +- [x] **TDD 게이트 무결성 및 전수 검증 (`DesktopAdSlotAndNtpSponsoredGateTests.cs` Gate 205~208)**: + - Gate 208: 캡처된 실제 비트맵 픽셀 감사(하단 배너 텍스트 픽셀 검출 및 다운로드 배너 단색 빈 사각형 방지) 통과. + - 솔루션 전체 테스트 **2,744 / 2,744 GREEN 전수 통과 (100% 통과, 0 실패, 0 스킵)**. + - WMI 분리 런처 기반 데스크톱 실창 렌더링 검증 완료 (`MainWindowHandle = 2887134 != 0`). + +## 🎯 [완료] 영상 썸네일 자동 추출/로컬 캐시(Referer 403 방어 & FFmpeg 프레임 추출) 및 스마트 제목 추론/난수 해시 정제 & 이름 바꾸기 시스템 (2026-09-13 완료) +- [x] **스마트 제목 추론 및 난수 해시 감지 엔진 (`FileNameUtil.cs`)**: + - 사용자 요구사항 완벽 반영: 웹 스니퍼 감지 시 난수 해시(`1a099dac52a596d88` 등)로 알아보기 어렵던 파일명을 게시물/웹 페이지 제목 기반으로 자동 정제하고 중복 영상에는 순번(`#1`, `#2`) 부여. + - `IsObfuscatedHash`: 12자 이상 16진수 및 UUID, 타임스탬프 결합 해시 자동 판별. + - `CleanWebTitle`: 사이트 접미사(` - 루리웹`, ` · GitHub` 등) 보일러플레이트 정제. + - `DeriveSmartTitle`: 원본 파일명 해시 여부에 따라 최적의 스마트 제목 도출. +- [x] **썸네일 로컬 캐싱 및 Referer 403 핫링크 차단 방어 & FFmpeg 추출 (`ThumbnailCacheService.cs`)**: + - 사용자 요구사항 완벽 반영: 루리웹, 네이버 등 외부 핫링크 차단(403) CDN 이미지나 썸네일 없는 영상에 대해 자동 캐싱 및 프레임 캡처 제공. + - 원본 페이지 `Referer` 헤더를 포함한 비동기 이미지 다운로더 및 SHA-256 로컬 영속 캐싱. + - 인라인 `` Base64 Data URI 즉시 로컬 캐시 변환. + - `%AppData%\Paca\tools\ffmpeg.exe`를 활용한 1초 지점 비디오 프레임 캡처 엔진 연동 (원격 스트림 및 다운로드 완료 로컬 파일 모두 지원). +- [x] **DOM 스니퍼 스크립트 고도화 (`TabViewService.cs`)**: + - `
`) 정체 결함을 전격 해결하고, 1,140개 테스트에 기반한 7대 고신뢰성 검증 방법론 체계 수립. + +- [x] **스토리지 파티션 객체 식별자 충돌 해결 (`ExtensionManager.cs`)**: + - `createStorageArea('local' | 'session' | 'sync' | 'managed')` 별도 인스턴스 분리 생성 및 `switch(e)` 타입 평가 완벽 지원. + - 미존재 키 `.get()` 호출 시 `undefined` 프로퍼티 오염 방어 (키 생략 반환). +- [x] **양방향 Port Message Bridge 구축 (`ExtensionManager.cs`)**: + - `chrome.runtime.connect` 포트 연결 즉시 `{ originator: 'background', action: 'initialization' }` 브로드캐스트. + - 전경의 `originator: 'foreground'` 저장소 요청에 대해 `originator: 'background'` 응답 패킷 실시간 디스패치 (RxJS `firstValueFrom` 프로미스 행 해소). +- [x] **WebAssembly MIME 헤더 매핑 (`MainWindow.xaml.cs`)**: + - Argon2/PBKDF2 모듈(`.wasm`)에 대해 `Content-Type: application/wasm` 및 CORS 헤더 명시적 주입 (`WebAssembly.compileStreaming` 정상화). +- [x] **보조 Chrome Extension API 전수 완비**: + - `chrome.windows`, `chrome.runtime.getContexts`, `chrome.extension.getViews`, `chrome.commands`, `chrome.permissions`, `chrome.offscreen`, `chrome.sidePanel`, `chrome.webNavigation`, `chrome.privacy`, `chrome.idle` 폴리필 탑재. +- [x] **7대 고신뢰성 공학 방법론 정립 및 SSOT 연계**: + - 카오스 공학(Bit-rot 자가치유), 속성 기반 적대적 퍼징(CSV/SQLi/XSS/100k+ 버퍼), 하이퍼 동시성(500 스레드 선형성), Crash-Only 40B 헤더 금고, W3C WebExtensions 에뮬레이션, WCAG 2.1 AAA 접근성 공학, 카파시 4대 원칙 TDD 루프. +- [x] **회귀 방지 전용 파이프라인 테스트 구축 (`ExtensionSystemPipelineTests.cs`)**: + - 5종 신규 단위 테스트 추가 및 `dotnet test Paca.Tests` 총 **1,140/1,140 무결점 통과**. + +## 72차 (2026-08-31 완료) — PACA Mobile 독립 브라우저 전면 재구축 & 인앱 비디오 스니퍼/다운로더 완성 +> 기존 단순 리모컨형 프로토타입에서 100% 완전 독립 동작하는 모바일 브라우저(멀티 탭, 방문기록, 북마크, HLS/DASH/MP4 스니퍼, 인앱 멀티스레드 다운로더, P2P 동기화)로 전면 재구축 완료. + +- [x] **독립형 브라우저 아키텍처 수립 (`docs/MOBILE_BROWSER_ARCHITECTURE_PLAN.md`)**: + - Readdle Documents 모델(파일 브라우징 일체형), YTDLnis 세션 쿠키 연동, LocalSend P2P 무상태 LAN 동기화 벤치마킹 및 표준 설계. +- [x] **`Paca.Mobile.Core` 코어 도메인 구축**: + - `MobileTabManager.cs`: LIFO 멀티 탭 스택, InPrivate 모드, 세션 JSON 복원. + - `MobileHistoryStore.cs` & `MobileBookmarkStore.cs`: 방문 통계 집계, 원자적 쓰기. + - `VideoSnifferParser.cs` & `SnifferScript.cs`: 실시간 HLS/DASH/MP4 미디어 스니핑. + - `MobileDownloadEngine.cs`: 순수 C# 멀티스레드 인앱 다운로드 엔진. +- [x] **`Paca.Mobile` 풀 브라우저 UI 구현**: + - 상단 옴니박스(SSL, 검색/URL, 새로고침, 비디오 스니퍼 뱃지). + - NTP 스피드 다이얼 숏컷 (YouTube, Naver, Instagram, X/Twitter, Reddit, Twitch). + - 2열 카드 탭 관리자, 히스토리/북마크, 인앱 다운로드 관리자, 감지 비디오 바텀시트 모달. +- [x] **단위 테스트 및 에뮬레이터 E2E 실구동 검증**: + - `Paca.Tests/Mobile/MobileBrowserCoreTests.cs` (14건) 추가 및 전체 **1,154 / 1,154 GREEN (100% 통과, 0 실패, 0 스킵)**. + - Android 에뮬레이터(`emulator-5554`) APK 설치 및 실화면 구동/유튜브 네비게이션 캡처 확인. + +--- + +## 🏗️ 72차: 글로벌 21개 저장소 실측 기반 Pitchfork Layout 표준화 & 확장 동시성 결함 수정 (Phase 72) + +> 글로벌 21개 주요 .NET 및 크로스플랫폼 오픈소스(dotnet/runtime, maui, jellyfin, avalonia, bitwarden 등)의 실측 아키텍처 패턴을 기반으로 저장소를 Pitchfork Layout (`src/` + `tests/`) 표준으로 정돈하고, 런타임 확장 프로그램 컬렉션 동시성 예외를 원천 수정 완료. + +- [x] **Chrome 확장 프로그램 순회 동시성 결함 수정**: + - `InjectContentScriptsForTabAsync` 및 `ConfigureCoreWebView2ForExtensions`의 `Collection was modified` 예외 해결: `ExtensionManager.GetExtensionsSnapshot()` 스냅샷 격리 및 `SemaphoreSlim _loadLock` 도입. + - `InitializeBrowserAsync`에서 1회 로드 방식으로 전환하여 탭 생성 시 불필요한 반복 리로드 방지. +- [x] **Pitchfork Layout 구조화 (`src/` + `tests/`)**: + - 8개 상용 프로젝트(`Paca.Core`, `Paca.Browser`, `Paca.Server`, `Paca.App`, `Paca.Avalonia`, `Paca.Cli`, `Paca.Mobile`, `Paca.Mobile.Core`)를 `src/`로 이동. + - 테스트 스위트(`Paca.Tests`)를 `tests/`로 이동. +- [x] **경로 및 파이프라인 동기화**: + - `Paca.slnx`, `tests/Paca.Tests/Paca.Tests.csproj` `ProjectReference` 경로 보정 (`..\..\src\Paca.*`). + - `build/publish.ps1`, `build/release.ps1`, `build/remote/build-remote.*` 경로 갱신. + - `.forgejo/workflows/release.yml`, `.github/workflows/release.yml`, `AGENTS.md` 경로 동기화. +- [x] **무결성 검증 (100% GREEN)**: + - `dotnet build Paca.slnx`: 0 오류, 0 경고. + - `dotnet test tests/Paca.Tests`: **1,833 / 1,833 통과 (100% GREEN, 0 실패, 0 스킵, 기간 약 52초)**. + +--- + +## 73차 (2026-09-10 완료) — Google AdMob 실광고 단위 통합 + +> AdMob 콘솔에 앱 "PACA"(Android)를 등록하고 실제 배너 광고 단위를 `Paca.Mobile`에 연결. 전면/보상형 광고 단위는 아직 생성하지 않아 Google 공식 테스트 ID를 그대로 유지. + +- [x] **AdMob 콘솔 등록**: + - 앱 "PACA" (Android, 스토어 미등록, 승인 상태 "검토 필요") 등록, 앱 ID `ca-app-pub-1039714767792854~5680462775`. + - 배너 광고 단위 "PACA Bottom Banner" `ca-app-pub-1039714767792854/3110491400` 생성. + - 전면/보상형 광고 단위는 아직 생성하지 않아 Google 공식 테스트 ID(`ca-app-pub-3940256099942544/1033173712`, `ca-app-pub-3940256099942544/5224354917`)를 그대로 유지. +- [x] **`AdMobConfig` 프로덕션 설정 및 빌드별 해석**: + - `AdMobConfig.PacaProduction`: 위 실제 App ID·배너 ID를 담은 프로덕션 설정. + - `AdMobConfig.Resolve(isDebugBuild)`: 환경변수 `ADMOB_APP_ID`/`ADMOB_BANNER_ID`가 있으면 최우선 적용, 없으면 Debug 빌드는 `DefaultTestConfig`, Release 빌드는 `PacaProduction`을 선택. + - `AndroidManifest.xml`의 `com.google.android.gms.ads.APPLICATION_ID`를 실제 App ID로 교체. +- [x] **Android 네이티브 배너 어댑터 구현**: + - `Xamarin.GooglePlayServices.Ads` 125.4.0.3 참조 추가. 이 패키지가 끌어오는 `Fragment.Ktx` 1.8.8.1과 프로젝트가 해석한 `Fragment` 1.9.0 간 버전 불일치로 D8 dex 병합 시 `FragmentKt` 클래스 중복 정의 오류가 발생해, `Xamarin.AndroidX.Fragment.Ktx` 1.9.0을 명시적으로 핀 고정해 해결. + - `AndroidAdMobAdapter`(`IAdMobAdapter` 구현): `MobileAds.Initialize` 1회 초기화(`MainActivity.OnCreate`에서 호출), `AdSize.GetLargeAnchoredAdaptiveBannerAdSize`로 적응형 배너 크기 계산 후 15초 타임아웃 내 로드, 실패 시 기존 스폰서 카드 폴백으로 자동 전환. + - `AdMobBannerHostView` + `AdMobBannerHostViewHandler`: 네이티브 `AdView`를 MAUI 뷰 트리에 호스팅. + - `MobileAdServices`: `Config`/`Adapter`/`Waterfall`/`NativeBannerFactory`를 앱 전역에서 공유하는 정적 진입점. + - NTP 스폰서 슬롯(`BrowserPage.cs`)과 다운로드 화면 인라인 배너(`DownloadsPage.cs`)에 실제 워터폴·네이티브 배너 팩토리 주입. +- [x] **TDD 검증 (Gate197~199, 전체 GREEN)**: + - `Gate197_AdMobConfig_PacaProduction_RealIds_And_ResolveByBuild_Audit`, `Gate198_AndroidManifest_Uses_PacaProduction_AdMobAppId_Audit`, `Gate199_PacaMobile_Csproj_References_GooglePlayServicesAds_Audit` 추가. + - `dotnet test tests/Paca.Tests --filter "FullyQualifiedName~MobileAdMob"`: **6 / 6 통과**. + - `dotnet build src/Paca.Mobile -f net10.0-android -c Debug`: **0 오류** (기존부터 있던 경고 5건 외 신규 경고 없음). + - `dotnet test tests/Paca.Tests` 전체: **2,350 / 2,350 통과 (0 실패, 0 스킵)**. +- [ ] **남은 일 (후속)**: + - 전면/보상형 광고 단위를 AdMob 콘솔에서 생성하고 `AndroidAdMobAdapter.ShowInterstitialAdAsync`/`ShowRewardedAdAsync` 실제 구현. + - `BottomDockBanner` 배치에 실제 워터폴 연결(이번 작업 범위 밖). + - AdMob 앱 스토어 등록 및 콘솔 검토 승인 대기(승인 전에는 광고 게재가 제한됨). + - 실기기에서 네이티브 배너 렌더링 실측 확인 (콘솔 승인 전에는 Release 빌드에서도 광고가 채워지지 않을 수 있으므로 Debug 테스트 ID로 먼저 확인). + - Release 빌드 AOT 프리컴파일(`llvm-mc.exe`) 로컬 실패 원인 조사 — 대상이 `System.IO.Pipelines.dll`/`Plugin.LocalNotification.Core.dll`로 AdMob 변경과 무관해 보이는 로컬 툴체인 이슈로 추정. AOT 제외(`-p:RunAOTCompilation=false -p:AndroidEnableProfiledAot=false`) Release 빌드는 서명 APK까지 정상 산출됨. CI 러너의 `dotnet publish -c Release`(release.yml android 잡)에서 AdMob 포함 빌드가 통과하는지 별도 확인 필요. + +--- + +## 74차 (2026-09-13 완료) — PACA 218대 전역 퀵 액션 & 글로벌 핫키 · 윈도우 트레이 스포트라이트 시스템 + +> 사용자 요청: "앱의 퀵 액션에 대해서 좀 만들어보자 윈도우 서비스로도 연결 가능해서 예를들자면 shift + ctrl + alt + p 이런거 누르면 어디서든지 파카 앱이 켜져있든 안켜져있든 파카로 해당 주소로 가거나 검색이 되거나 아니면, 이것저것 잡다구래한 기능들을 제공해서 우리앱의 사용성을 높이고 사용자들의 의존도를 높여서 우리 앱의 경쟁력을 높이자 액션은 적어도 200 개 이상을 생각하고 우리 브라우저의 기능들을 일단 다 가능하게 한다고 생각도 하고, 그리고 유틸리티성도 생각한다. 이것들 다른 앱들은 어떻게 하는지 인터넷 검색을 제대로 하고 도출해서 백로그로 해서 할일로 하고 나서 모두 개발하고 모두 검증할때까지 멈추지말고 다 하면 보고하시오." + +- [x] **218대 퀵 액션 카탈로그 등록**: + - 브라우징 30개, 미디어/다운로드 25개, 스마트 뱅 검색 25개, 개발자 25개, 계산/변환 25개, 텍스트/클립보드 25개, 윈도우 OS 28개, PACA 에코시스템 35개 등 총 218개 액션 전수 등록 (`QuickActionCatalog.cs`, `QuickActionModels.cs`). +- [x] **인라인 실시간 수식 평가 & 단위 변환기**: + - `12 + 34`, `sqrt(144)`, `km to mile`, `c to f`, `mb to gb`, `b64`, `hex`, `time now` 등 주소창/검색창 인라인 실시간 즉시 계산 및 클립보드 복사 (`QuickActionInlineEvaluator.cs`). +- [x] **가중치 랭킹 검색 엔진**: + - 타이틀 정확 일치, 접두사 일치, ID 매칭, 키워드 매칭 가중치를 결합한 지능형 퀵 액션 검색기 (`QuickActionSearchEngine.cs`). +- [x] **글로벌 핫키 서비스**: + - Win32 `RegisterHotKey`/`UnregisterHotKey` P/Invoke 기반 전역 단축키(`Shift + Ctrl + Alt + P`) 후킹 및 `MOD_NOREPEAT` 충돌 방어 (`GlobalHotkeyService.cs`). +- [x] **윈도우 트레이 데몬**: + - Win32 `Shell_NotifyIcon` P/Invoke 기반 트레이 상주, 창 닫기 시 트레이 최소화(`CloseToTray`), 더블클릭 복원, 우클릭 퀵 액션 런처 호출 (`WindowsTrayDaemon.cs`). +- [x] **모던 스포트라이트 UI 창**: + - Raycast/Spotlight 스타일 760x540px 플로팅 카드, 실시간 인라인 평가 카드, 검색 리스트, 키보드 탐색(Esc, Enter, Up, Down), UIA FastPass 접근성 준수 (`QuickActionLauncherWindow.xaml`, `QuickActionLauncherWindow.xaml.cs`). +- [x] **TDD 검증**: + - 5대 TDD 게이트 전수 통과 (`QuickActionRegistryAndCatalogGateTests`, `QuickActionInlineEvaluatorGateTests`, `QuickActionSpotlightUiGateTests`, `QuickActionGlobalHotkeyAndTrayGateTests`, `QuickActionLauncherIntegrationGateTests`). + +--- + +## 75차 (2026-09-13 완료) — 퀵 액션 고급 키보드 네비게이션·인라인 오토컴플리트·디자인 SSOT 정본화·다국어화 + +> 사용자 요청: "진입한 이후에 조금 더 쉽게 단축키나 템프로 이동한다거나 화살표를 한다거나 뭐 이것저것 이제 사용자가 메뉴를 네비게이트하고 오토 컴플리트되고 그거를 좀 더 쉽게 할 수 있는 방법들 좀 찾아야 될 것 같아요 지금 다른 윈도우들과 좀 충돌하는 인풋도 있는 것 같고 인터랙션이 좀 매끄럽지 못한 부분들도 있고 디자인이 우리의 디자인 SSOT랑 그러니까 정본과 별로 좀 다른 것 같다라는 느낌이 많이 들고 특히나 이제 우리 테마 같은 것들도 있는데 그 테마에 아주 영향을 안 받는 것 같은 느낌인데 이런 것들도 중요하고 그리고 뭐 우리 다국어화 해놨으니까 이 다국어화에 대한 메뉴 아이템 이름들도 신경 써야 될 것 같아요." + +- [x] **고급 키보드 네비게이션 & 고스트 오토컴플리트**: + - `Tab` / `Right` 화살표: 고스트 텍스트 자동완성 수락 및 커서 자동 이동. + - `Up` / `Down` 화살표: 이전/다음 액션 선택 및 자동 스크롤 연동. + - `PageUp` / `PageDown`: 7개 단위 빠른 리스트 점프. + - `Home` / `End`: 목록 처음 및 끝으로 즉각 이동. + - `Ctrl+1` ~ `Ctrl+9`: 상위 1~9번 액션 즉시 실행 및 모던 퀵 넘버 배지(`[Ctrl+N]`) 노출. + - `Ctrl+Enter`: 액션 실행과 동시에 결과 텍스트를 클립보드로 복사. + - `Esc`: 1차로 입력창 텍스트 소거 ➔ 비어있으면 2차로 런처 닫기 (2단계 안전 취소). +- [x] **카테고리 칩 필터 바 & 프리픽스 트리거**: + - All / Browser / Media / Dev / System / Utilities 칩 필터 바 완비. + - `br:`, `media:`, `dev:`, `sys:`, `calc:`, `ai:` 입력 시 카테고리 칩 자동 선택 및 접두사 기반 스마트 필터링. +- [x] **디자인 시스템 SSOT 정본 교정 & 테마 완벽 동기화**: + - `CardHoverBg` 오타 토큰을 정본 `{DynamicResource CardBgHover}`로 전면 교정. + - Pretendard(`UiFont`) 전면 상속. + - 선택 상태 `{DynamicResource SelectionBrush}` 및 좌측 3px 액센트 인디케이터 바. + - Dark / Light / HighContrast(WHCM WCAG AAA 7.0:1) 전 테마 완벽 동기화. + - 투명 창 외곽 `Grid Margin="20"` 적용으로 외곽 드롭 섀도우 박스 클리핑 결함 원천 해결. +- [x] **글로벌 다국어화 (ko-KR, en-US, ja-JP)**: + - 3개 국어(한국어/영어/일본어) 액션 제목, 키워드, 카테고리명 완비. + - 다국어 교차 가중치 랭킹 검색 엔진 (`QuickActionSearchEngine.cs`). + - 앱 설정 언어(`AppConfig.Language`)와 런처 UI 텍스트(`SetCulture`) 실시간 동기화. +- [x] **윈도우 포커스 스틸링 & 인풋 충돌 방어**: + - Win32 `SetForegroundWindow` 호출 및 전역 핫키 호출 시 즉시 런처 창으로 윈도우 포커스 강제 인계. + - 외부 윈도우 간섭 배제, IME 한글/영문 조합 버퍼 안전 처리. + - 내부 시스템 다이얼로그나 파일 탐색기 호출 시 비정상 `Deactivated` 숨김 방지. +- [x] **TDD 및 종합 검증**: + - `QuickActionAdvancedUxAndLocalizationGateTests` 6건 전수 통과. + - 퀵 액션 테스트 31/31건 100% 통과. + - 솔루션 전체 통합 테스트: **2,575 / 2,575 통과 (100% GREEN, 0 실패, 0 스킵)**. + - `dotnet build src/Paca.App`: 0 오류, 0 경고. + - WMI 분리 런처 기반 GUI 실창 렌더링 검증: `MainWindowHandle = 1117598` 확인 완료. + +--- + +## 76차 (2026-09-13 완료) — 퀵 액션 차세대 기능(퍼지 검색·사용빈도 트래커·인자 라우터·디테일 프리뷰·CubicEase 모션) + +> 사용자 요청: "좀 더 검증 좀더 새로운 기능 좀더 매끄러운 애니메이션 좀더 자연스러운 ux로 밀어 붙여보자." + +- [x] **퍼지 & 서브시퀀스 매칭 엔진 (`QuickActionFuzzyMatcher.cs`)**: + - `sct` ➔ `sys.screenshot_full`, `dwn` ➔ `media.download_manager`, `clp` ➔ `text.clipboard_view` 등 약어/초성 서브시퀀스 매칭. + - 1~2자 오타 허용 Levenshtein 거리 계산 및 적응형 점수 부여. +- [x] **사용 빈도 및 최근 실행 트래커 (`QuickActionUsageTracker.cs`)**: + - 액션 실행 시 시각(`LastUsedAt`) 및 실행 카운트(`ExecutionCount`) 자동 기록. + - 빈 검색어 입력 시 최근 실행한 액션을 상단에 스마트 노출. + - 검색 시 사용 빈도 및 최신성에 비례한 점수 부스트(+50~+250점) 부여. + - `%AppData%/Paca/quickaction_usage.json` 자동 JSON 영속화. +- [x] **인자/서브커맨드 인라인 라우터 (`QuickActionParameterRouter.cs`)**: + - `open ` / `br:open `: 지정 URL 브라우저 즉시 열기 + - `b64 `: Base64 인코딩 즉시 클립보드 복사 + - `b64d `: Base64 디코딩 즉시 클립보드 복사 + - `hash `: SHA-256 해시값 즉시 계산 및 복사 + - `note `: PACA 퀵 메모에 즉시 기록 +- [x] **우측 디테일 프리뷰 패널 (Action Inspector Pane)**: + - 런처 폭을 `860px`로 확장하고 2단 분할 레이아웃(좌: 리스트, 우: 디테일) 완비. + - 선택된 액션의 대형 아이콘, 다국어 제목, ID, 카테고리 뱃지, 설명 본문, 단축키 힌트, 파라미터 팁 실시간 미려한 렌더링. +- [x] **CubicEase 팝업/페이드 스토리보드 애니메이션**: + - 등장: `ScaleTransform`(0.96 ➔ 1.0) + `Opacity`(0 ➔ 1) + `TranslateY`(-8 ➔ 0) 150ms `CubicEase` 감속 곡선. + - 퇴장: `Opacity`(1 ➔ 0) 및 `Scale`(1.0 ➔ 0.98) 100ms 페이드아웃 후 안전 `Hide()`. + - 전정기관 보호 움직임 감소(SC 2.3.3) 상한(<= 200ms) 엄수. +- [x] **자연스러운 UX & 인터랙션 가드**: + - 상단 헤더 영역 마우스 드래그 이동(`DragMove()`) 지원. + - 마우스 실이동 감지(`_lastMousePosition`) 기반 호버 선택과 키보드 조작 간 충돌 방지. + - 실행/복사 완료 시 "✓ 실행되었습니다" / "✓ 복사되었습니다" 1.6초 상태바 마이크로 피드백 후 자동 복원. +- [x] **TDD 및 종합 검증**: + - `QuickActionNextGenUxAndAnimationGateTests` 6건 전수 통과. + - 퀵 액션 테스트 37/37건 100% 통과. + - 솔루션 전체 통합 테스트: **2,581 / 2,581 통과 (100% GREEN, 0 실패, 0 스킵)**. + - `dotnet build src/Paca.App`: 0 오류, 0 경고. + - WMI 분리 런처 기반 GUI 실창 렌더링 검증: `MainWindowHandle = 2429014` 확인 완료. + +--- + +## 🚀 다음 작업자(AI/Human)를 위한 핸드오프 (Handoff Guide) + +### 1. 현 시스템 상태 요약 +- **저장소 구조**: Pitchfork Layout 표준 (`src/` 8개 프로젝트 + `tests/` 1개 프로젝트 + `build/` + `docs/` + `site/`) +- **테스트 통과율**: `dotnet test tests/Paca.Tests` 기준 **2,581 / 2,581건 통과 (실패 0, 스킵 0, 기간 약 58초)** +- **빌드 상태**: `dotnet build Paca.slnx` 기준 **오류 0개, 경고 0개 (Clean Build)** +- **디자인 감사**: 25대 시각·인터랙션·접근성 게이트 및 퀵 액션 디자인 정본 SSOT 100% 기계적 통과 +- **실행 상태**: WMI 분리 런처 기반 데스크톱 실창 렌더링 검증 완료 (`MainWindowHandle != 0`) + +### 2. 다음 우선순위 추천 백로그 (Next Up Recommendations) +1. **모바일 ↔ PC 간 로컬 QR 스캔 P2P 세션 페어링 UI 고도화**: + - `SyncSettingsPage.cs` 카메라 바코드 스캐너(ZXing.Net.MAUI) 결합 (현재 mDNS 자동 탐색 및 IP/토큰 수동 페어링만 지원). +2. **WebAuthn / Passkey (FIDO2) 로컬 하드웨어 챌린지 시뮬레이터 완성**: + - `ExtensionManager.cs`의 `BrowserFido2UserInterfaceServiceMessage` 응답에 가상 WebAuthn 자격증명 Assertion 결합 (현재 빈 객체 `{}` 모의 응답 상태). +3. **WebExtensions MV3 DeclarativeNetRequest 규칙 컴파일러 고도화**: + - 정규식 및 URL 필터링 규칙의 Trie 기반 고속 매칭 엔진 성능 벤치마킹 및 SIMD 가속화. +4. **iOS TestFlight 외부 배포 (외부 계정 대기)**: + - Apple Developer Program($99/년) 유료 등록 필요 (코드/시뮬레이터 빌드·실행·페어링은 완료). + + + + + + + diff --git a/docs/BROWSER_FEATURES_PLAN.md b/docs/BROWSER_FEATURES_PLAN.md index 7c8c9ce..76c5355 100644 --- a/docs/BROWSER_FEATURES_PLAN.md +++ b/docs/BROWSER_FEATURES_PLAN.md @@ -1,8 +1,8 @@ -# Video Downloader — 브라우저 기능 강화 계획서 (v2) +# Video Downloader — 브라우저 기능 강화 계획서 (v2) > **목표**: 이 앱에 내장된 WebView2 브라우저에 일반적인 데스크톱 브라우저의 **핵심 사용자 기능**을 심어, 단순 "영상 재생 → 감지" 용도를 넘어 **일상적으로 쓸 수 있는 브라우저**로 만든다. > -> **작성일**: 2026-08-15 (v2 — 2차 심층 조사 반영) · **대상**: `VideoDownloader.App` (WPF + WebView2 1.0.4078.44) +> **작성일**: 2026-08-15 (v2 — 2차 심층 조사 반영) · **대상**: `Paca.App` (WPF + WebView2 1.0.4078.44) > > **조사 방법론**: > - 1차(2026-08-13): Chrome/Firefox/Zen 3개 브라우저 단일 조사 → v1 초안 @@ -38,7 +38,7 @@ | 줌 | ✅ | 세션 한시적(사이트별 영속 아님) | | 팝업 → 현재 WebView 라우팅 | ✅ | `NewWindowRequested` | | 페이지 다운로드 → 저장 폴더 | ✅ | `DownloadStarting` | -| 영구 프로필 / 데이터 삭제 | ✅ | `%AppData%/VideoDownloader/webview` | +| 영구 프로필 / 데이터 삭제 | ✅ | `%AppData%/Paca/webview` | | 다크/라이트 테마, 보더리스 | ✅ | DWM 연동 | | 우측 다운로드 패널(접기/폭) | ✅ | | | DevTools, 미디어 자동 감지 | ✅ | HLS/DASH/직링크/소셜(yt-dlp) | diff --git a/docs/DEPLOYMENT_PLAN.md b/docs/DEPLOYMENT_PLAN.md index 830a9d3..b4a7ef1 100644 --- a/docs/DEPLOYMENT_PLAN.md +++ b/docs/DEPLOYMENT_PLAN.md @@ -1,4 +1,4 @@ -# Video Downloader — 멀티플랫폼 배포 체계 계획서 (v1) +# Video Downloader — 멀티플랫폼 배포 체계 계획서 (v1) > **범위**: Windows · macOS · Linux 데스크톱 + Android · iOS 모바일의 **빌드→서명→패키징→배포→업데이트** 전 체계. > GitHub Releases 를 단일 배포 소스로, GitHub Actions 로 전 아티팩트 자동 빌드. @@ -40,11 +40,11 @@ GitHub Actions 대신(또는 보완으로) **사용자 소유 빌드머신 2대* | 제품 | 플랫폼 | 아티팩트 | 채널 | 서명 | |---|---|---|---|---| -| **App (WPF+WebView2)** — 주력 | Windows x64 | `VideoDownloader-Setup-x64.exe` (Inno, 기존) | GitHub Releases | Authenticode (선택, 후순위) | -| | Windows arm64 | `VideoDownloader-Setup-arm64.exe` (기존) | 〃 | 〃 | -| **App (Avalonia 셸)** — 크로스플랫폼 이관 | Linux x64/arm64 | `videodownloader-linux-x64.tar.gz` (기본) + `.AppImage` + `.deb` | GitHub Releases | (GPG 서명 선택) | -| | macOS x64/arm64 | `VideoDownloader-.dmg` (+ `.app` in DMG) | GitHub Releases | Developer ID + 공증 필수 | -| **Mobile (MAUI)** | Android | `VideoDownloader--arm64.apk` | GitHub Releases + Obtainium | Keystore (CI 시크릿) | +| **App (WPF+WebView2)** — 주력 | Windows x64 | `Paca-Setup-x64.exe` (Inno, 기존) | GitHub Releases | Authenticode (선택, 후순위) | +| | Windows arm64 | `Paca-Setup-arm64.exe` (기존) | 〃 | 〃 | +| **App (Avalonia 셸)** — 크로스플랫폼 이관 | Linux x64/arm64 | `Paca-linux-x64.tar.gz` (기본) + `.AppImage` + `.deb` | GitHub Releases | (GPG 서명 선택) | +| | macOS x64/arm64 | `Paca-.dmg` (+ `.app` in DMG) | GitHub Releases | Developer ID + 공증 필수 | +| **Mobile (MAUI)** | Android | `Paca--arm64.apk` | GitHub Releases + Obtainium | Keystore (CI 시크릿) | | | Android (Play, 선택) | `.aab` | Google Play (추출코드 없는 빌드) | Play App Signing | | | iOS | `.ipa` | TestFlight → App Store | Apple 배포 인증서 | | **Server / 웹 컴패니언** | 데스크톱 앱에 내장 | 별도 배포 없음 (앱이 Kestrel 서빙) | — | 앱 서명에 포함 | @@ -156,7 +156,7 @@ jobs: ## 5-bis. CI/CD 현행 채널 — Forgejo Actions 자체 러너 (2026-08-17 가동) -저장소가 자체 Forgejo(`https://git.chanpaca.net/Video-Downloader/client`)로 이관되고 org 러너 3대가 +저장소가 자체 Forgejo(`https://git.chanpaca.net/Paca/client`)로 이관되고 org 러너 3대가 등록되어, §5(GitHub) 대신 **자체 러너로 전 아티팩트를 빌드·배포**한다. §5 는 저장소 GitHub 공개 시 백업 채널로 유지(`.github/workflows/release.yml` 존치). @@ -175,19 +175,19 @@ jobs: - **리허설**: workflow_dispatch → `ci-rehearsal` draft prerelease 로 태그 없이 전 파이프라인 검증. - **시크릿**: `RELEASE_TOKEN`(Forgejo PAT, `write:repository`) — 릴리스 생성·자산 업로드. 체크아웃은 잡 자동 토큰(`github.token`). `ANDROID_KEYSTORE/ALIAS/PASS` 등록 전까지 APK 는 디버그 서명. -- **업로드 대상**: 공개 배포 저장소 **`Video-Downloader/releases`**(워크플로 env `RELEASE_REPO`). +- **업로드 대상**: 공개 배포 저장소 **`Paca/releases`**(워크플로 env `RELEASE_REPO`). 소스 저장소(client)는 비공개라 자산 익명 다운로드가 불가해 분리했다. draft 는 익명에게 보이지 않으므로 "빌드 → draft → QA → 수동 Publish" 흐름은 그대로다. Publish 하면 §5-ter 사이트가 노출한다. - **머신 전제**: win = SDK 8/10 + `maui-android` 워크로드 + JDK 17 + Inno Setup 6 · linux/mac = `~/.dotnet` SDK 8.0.424 + 10.0.400 (§0.5). - **갭**: §6 자동 업데이트 v1(`UpdateChecker`)은 GitHub Releases API 를 폴링하므로 GitHub 공개 전까지 미동작 — Forgejo Releases API(`/repos/{owner}/{repo}/releases/latest`, 동일 형태) 대응은 후속 항목. - 대상은 공개 저장소 `Video-Downloader/releases` 가 되어야 한다. + 대상은 공개 저장소 `Paca/releases` 가 되어야 한다. -## 5-ter. 배포 사이트 — MEDIABROWSER (Cloudflare Pages, 2026-08-18 가동) +## 5-ter. 배포 사이트 — Paca (Cloudflare Pages, 2026-08-18 가동) -제품 배포명은 **MEDIABROWSER** 로 하고, 프로모션/다운로드/사용 안내 3페이지 정적 사이트를 -Cloudflare Pages 에 올렸다. **https://mediabrowser-7r4.pages.dev** (mediabrowser 서브도메인 선점으로 +제품 배포명은 **Paca** 로 하고, 프로모션/다운로드/사용 안내 3페이지 정적 사이트를 +Cloudflare Pages 에 올렸다. **https://Paca-7r4.pages.dev** (Paca 서브도메인 선점으로 접미 부여 — 커스텀 도메인 연결은 후속). - **소스**: `site/` — `index.html`(프로모션: 라이트 글래스모피즘 + CSS scroll-driven 패럴랙스, @@ -197,16 +197,16 @@ Cloudflare Pages 에 올렸다. **https://mediabrowser-7r4.pages.dev** (mediabro `site/fetch-releases.mjs` 가 공개 저장소 릴리스를 `site/releases.json`(git 무시)으로 굽는다. published 릴리스만 포함(draft 제외), 최신판 강조 + 이전 버전 접힘 + OS 자동 감지 권장 표시. - **배포**: `./build/deploy-site.ps1` — .env 의 `CLOUDFLARE_API_TOKEN/ACCOUNT_ID` 로 - `wrangler pages deploy site --project-name mediabrowser`. **자동화(2026-08-18)**: 공개 releases + `wrangler pages deploy site --project-name Paca`. **자동화(2026-08-18)**: 공개 releases 저장소의 `.forgejo/workflows/site-deploy.yml` 이 릴리스 Publish 이벤트에서 사이트를 자동 재배포한다 (시크릿: CLIENT_TOKEN=client 읽기 PAT, CLOUDFLARE_API_TOKEN/ACCOUNT_ID). -- **개명 완료(2026-08-18)**: 설치자 `MediaBrowser-Setup-.exe` · tar.gz `mediabrowser-` · - APK `MediaBrowser--arm64.apk` · 앱 타이틀/모바일 표시명 MediaBrowser · obtainium.json 공개 저장소 URL. - AppId(GUID)·실행 파일명(VideoDownloader.exe)·데이터 경로는 유지(업그레이드·사용자 데이터 호환). -- **커스텀 도메인(2026-08-18 완료)**: **https://mediabrowser.chanpaca.net** — Pages 프로젝트 도메인 등록 + - DNS CNAME(`mediabrowser` → `mediabrowser-7r4.pages.dev`, 프록시 ON, 대시보드에서 추가). og:url 도 정식 - 도메인 기준으로 갱신. 참고: API 토큰(CLOUDFLARE_API_TOKEN)에는 Zone DNS 편집 권한이 없다 — - DNS 자동화가 필요해지면 토큰에 `Zone → DNS → Edit` 권한을 추가할 것. +- **개명 완료(2026-08-18)**: 설치자 `Paca-Setup-.exe` · tar.gz `Paca-` · + APK `Paca--arm64.apk` · 앱 타이틀/모바일 표시명 Paca · obtainium.json 공개 저장소 URL. + AppId(GUID)·실행 파일명(Paca.exe)·데이터 경로는 유지(업그레이드·사용자 데이터 호환). +- **커스텀 도메인(2026-08-18 완료)**: **https://Paca.chanpaca.net** — Pages 프로젝트 도메인 등록 + + DNS CNAME(`Paca` → `Paca-7r4.pages.dev`, 프록시 ON). og:url 도 정식 + 도메인 기준으로 갱신. **API 토큰 통합(2026-08-18)**: `CLOUDFLARE_API_TOKEN`에 `Account → Cloudflare Pages → Edit`, + `Zone → DNS → Edit`(`chanpaca.net`), `Zone → Zone → Read` 권한을 통합하여 Pages 배포 및 DNS 조회가 API로 일원화됨. ## 6. 업데이트 체계 @@ -242,14 +242,14 @@ Cloudflare Pages 에 올렸다. **https://mediabrowser-7r4.pages.dev** (mediabro `.env.example`(빈 값 템플릿)뿐 — 공개해도 유출 없음. ```powershell -gh repo create /videodownloader --public --source . --push # 또는 --private 로 시작 +gh repo create /Paca --public --source . --push # 또는 --private 로 시작 git tag v1.0.0 && git push origin v1.0.0 # → release.yml 자동 실행(Draft) ``` 공개 후 마무리 3가지: 1. `build/obtainium.json` · README 의 `/` 치환 2. (선택) Android 서명: 시크릿 `ANDROID_KEYSTORE`(base64)/`ANDROID_KEY_ALIAS`/`ANDROID_KEY_PASS` 등록 -3. 설정창 "앱 업데이트"에 `owner/videodownloader` 입력 → D6 v1 폴링 활성 +3. 설정창 "앱 업데이트"에 `owner/Paca` 입력 → D6 v1 폴링 활성 ### D4 — macOS 서명·공증 (Apple Developer Program 가입 후) diff --git a/docs/EXTREME_EXPANSION_PLAN.md b/docs/EXTREME_EXPANSION_PLAN.md new file mode 100644 index 0000000..fe04025 --- /dev/null +++ b/docs/EXTREME_EXPANSION_PLAN.md @@ -0,0 +1,161 @@ +# PACA 극초대형 차세대 확장 계획 (Extreme Expansion Plan) + +> **문서 상태**: 공식 승인 마스터 아키텍처 사양서 (v3.0) +> **기준선**: 996 Tests 100% GREEN, 36개 도메인 파이프라인 통폐합 완료, 0 빌드 경고 +> **비전**: 단순 비디오 다운로더를 넘어선 **"초고성능 크로스플랫폼 미디어 브라우저 & 분산 인텔리전스 에코시스템"** 구축 + +--- + +## 1. 7대 차세대 핵심 확장 아키텍처 개요 + +```mermaid +graph TD + PACA["Paca Core Multi-Platform Ecosystem"] + + PACA --> DOMAIN1["1. Hyper-Extension Engine (MV3 + V8 Isolate)"] + PACA --> DOMAIN2["2. P2P Swarm & Multi-CDN Mesh Downloader"] + PACA --> DOMAIN3["3. On-Device AI Neural Media Intelligence"] + PACA --> DOMAIN4["4. Omni-Platform Matrix (Desktop / Mobile / NAS)"] + PACA --> DOMAIN5["5. Zero-Knowledge CRDT Encrypted Sync"] + PACA --> DOMAIN6["6. Ultra-Low Latency Casting & Transcoding"] + PACA --> DOMAIN7["7. 10,000-Gate Autonomous Chaos & Fuzzing"] + + style PACA fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#fff + style DOMAIN1 fill:#1e1b4b,stroke:#6366f1,stroke-width:1px,color:#e0e7ff + style DOMAIN2 fill:#1e1b4b,stroke:#6366f1,stroke-width:1px,color:#e0e7ff + style DOMAIN3 fill:#1e1b4b,stroke:#6366f1,stroke-width:1px,color:#e0e7ff + style DOMAIN4 fill:#1e1b4b,stroke:#6366f1,stroke-width:1px,color:#e0e7ff + style DOMAIN5 fill:#1e1b4b,stroke:#6366f1,stroke-width:1px,color:#e0e7ff + style DOMAIN6 fill:#1e1b4b,stroke:#6366f1,stroke-width:1px,color:#e0e7ff + style DOMAIN7 fill:#1e1b4b,stroke:#6366f1,stroke-width:1px,color:#e0e7ff +``` + +--- + +## 2. 도메인별 극한 확장 세부 사양 (Phase 60 ~ Phase 66) + +### 🚀 Phase 60: Hyper-Extension Subsystem (MV3 + V8 Isolate + DNR SIMD 가속) +*목표: Chrome Web Store 및 Firefox 확장 프로그램 99.9% 무결점 호환 & 백그라운드 엔진 고도화* + +1. **Manifest V3 Background Service Worker 완전 네이티브 호스팅** + - V8 Isolate / ClearScript 기반 경량 백그라운드 워커 런타임 내장. + - `chrome.alarms`, `chrome.storage.session`, `chrome.offscreen`, `chrome.scripting.registerContentScripts` 100% 네이티브 지원. +2. **SIMD & Aho-Corasick 가속 DeclarativeNetRequest 엔진** + - 300,000개 이상의 uBlock/AdGuard 규칙을 0.1ms 내에 평가하는 초고속 Regex/URL 매처. + - Dynamic Rules, Session Rules, Regex Filter 컴파일러 탑재. +3. **유저스크립트(UserScripts) & Violentmonkey/Stylus 네이티브 엔진** + - `@match`, `@run-at document-start`, `@grant GM_xmlhttpRequest`, `@grant GM_setValue` 완벽 지원. + - 레이아웃 시프트(CLS) 없는 즉각적 DOM 인젝션 보장. +4. **확장 프로그램 프로세스 격리 & 세부 권한 감사 샌드박스** + - 메모리 상한(50MB) 초과 시 자동 워커 서스펜드 & 요청 시 즉시 복구. + - 네트워크 요청 및 스토리지 접근 실시간 보안 모니터링 HUD. + +--- + +### 🌐 Phase 61: Ultra-Distributed P2P Swarm & Multi-CDN Mesh Engine +*목표: 단일 서버 병목 없는 무제한 대역폭 P2P 분산 다운로드 & 미러 가속* + +1. **WebTorrent / BitTorrent / Libp2p 기반 스웜 다운로드 파이프라인** + - 대용량 VOD 및 라이브 스트림 청크를 로컬 네트워크(mDNS) 및 DHT 피어 간 P2P 교환. + - Merkle Tree 기반 세그먼트 SHA-256 비트 무결성 실시간 검증. +2. **다중 CDN 미러 지능형 레이싱 (Multi-Origin Segment Racing)** + - 동일 스트림의 여러 CDN 엔드포인트에 동시 핑/청크 레이싱을 수행하여 가장 빠른 서버로부터 다운로드하고 지연 연결 즉시 취소. + - HTTP/3 (QUIC) 및 다중 TCP 연결 멀티플렉싱 지원. +3. **로컬 오프라인 메쉬 릴레이 (Mesh Network Sharing)** + - 인터넷 연결이 제한된 환경에서 데스크톱 ↔ 모바일 간 블루투스/WiFi Direct P2P 비디오 공유. + +--- + +### 🧠 Phase 62: On-Device AI Neural Media Intelligence +*목표: 클라우드 전송 0, 100% 로컬 하드웨어 가속 AI 미디어 분석 & 초해상도 업스케일링* + +1. **온디바이스 실시간 STT 자막 생성 및 번역 (Whisper.cpp / ONNX DirectML)** + - GPU (DirectML / OpenVINO / CoreML) 가속으로 실시간 60fps 음성 인식 및 한국어/영어/일본어 다국어 자막 생성. + - 영상 내 음성 타이밍에 맞춘 정밀 Word-level 타임스탬프 SRT/VTT 출력. +2. **AI 스마트 씬 감지 & 쇼츠/하이라이트 원클릭 클리퍼 (Local SLM + VAD)** + - 오디오 에너지 피크, VAD(Voice Activity Detection), 화면 전환(Scene Change Detection) 결합. + - 중요한 60초 구간을 자동 추출하여 무손실 수직 9:16 쇼츠(TikTok/Reels/Shorts) 영상 생성. +3. **Vulkan / D3D12 신경망 실시간 초해상도 업스케일러 (Anime4K / Real-ESRGAN)** + - `PlayerWindow` 및 브라우저 비디오 요소에 셰이더 기반 실시간 720p/1080p → 4K 업스케일링 적용. + +--- + +### 📱 Phase 63: Universal Omni-Platform Ecosystem +*목표: Windows, macOS, Linux, Android, iOS, NAS/Docker를 아우르는 단일 생태계* + +1. **Paca Mobile Production Suite (MAUI Android / iOS)** + - Android Foreground Service + iOS BackgroundTasks 기반 백그라운드 고속 다운로드 유지. + - 생체인증 (Face ID / Touch ID / Android BiometricPrompt) 보안 잠금. + - 동일 WiFi 진입 시 데스크톱 큐와 원터치 자동 양방향 동기화. +2. **Paca Avalonia Desktop (Linux & macOS Native)** + - Wayland / X11 네이티브 하드웨어 가속, Flatpak, Snap, AppImage 원클릭 패키징. + - macOS Apple Silicon (M1/M2/M3/M4) 및 Intel Universal 바이너리, `.dmg` 공증(Notarization) 완료. +3. **Paca Daemon & Headless CLI (NAS / Home Server / Docker)** + - Synology, QNAP, Unraid, TrueNAS, Raspberry Pi 지원 경량 데몬 (`pacad`). + - Web GUI 대시보드 (`http://nas:8080`) 및 gRPC / RESTful 원격 제어 인터페이스 제공. + +--- + +### 🔒 Phase 64: Zero-Knowledge CRDT Multi-Device Encrypted Vault +*목표: 완전 영지식 종단간 암호화(E2EE) 분산 동기화* + +1. **CRDT (Conflict-Free Replicated Data Types) 무충돌 실시간 동기화** + - 탭 세션, 북마크 트리, 워크스페이스, 방문 기록, 읽기 목록의 오프라인 편집 및 양방향 무충돌 병합. +2. **AES-256-GCM / ChaCha20-Poly1305 클라이언트 암호화** + - WebDAV, Nextcloud, Google Drive, Dropbox, AWS S3 등 사용자가 원하는 클라우드로 암호화 블록 동기화. + - 서버나 클라우드 제공자가 데이터를 절대 읽을 수 없는 제로 지식 증명 아키텍처. +3. **FIDO2 / WebAuthn 하드웨어 보안키 (YubiKey) 연동** + - 생체인증 외 물리 하드웨어 보안키 기반 금고 2차 인증. + +--- + +### 🎬 Phase 65: Ultra-Low Latency Media Streaming & Studio Pipeline +*목표: 브라우징 중 실시간 룸 캐스팅, 하드웨어 트랜스코딩 & 파형 시각화 스튜디오* + +1. **WebRTC 저지연 탭/미디어 무선 캐스팅 (Cast Engine)** + - 스마트 TV, 크롬캐스트, AirPlay 2, DLNA, Miracast 장치로 100ms 미만 초저지연 미디어/화면 전송. +2. **초고속 하드웨어 트랜스코더 (NVENC / Intel QSV / AMD AMF / VideoToolbox)** + - 원본 코덱을 실시간으로 AV1, HEVC, VP9 등으로 트랜스코딩하여 기기별 최적 재생 지원. + - 동적 HLS/DASH 적응형 비트레이트 래더(Ladder) 즉석 생성. +3. **대화형 파형 렌더러 & 10-Band 파라메트릭 EQ 스튜디오** + - 오디오 스펙트럼 실시간 FFT 렌더링, 노이즈 억제, 보컬 부스트, 챕터별 무손실 컷편집 GUI. + +--- + +### 🛡️ Phase 66: 10,000-Gate Autonomous Continuous Chaos & Resilience Matrix +*목표: 오류 0건, 플레이키 0건, 영구적 무결성을 증명하는 기계적 검증 시스템* + +1. **연속 퍼징(Fuzzing) & 뮤테이션 하네스** + - 손상된 m3u8/mpd 스트림, 잘못된 CRX 헤더, 네트워크 패킷 드랍, 비트플립 결함 10,000회 연속 주입. +2. **크래시 리플레이 텔레메트리 샌드박스** + - 예외 발생 시 인메모리 재현 스크립트 자동 생성 및 독립 테스트 케이스로 격리 검증. +3. **DoD 절대 기준 유지** + - 단위/E2E 테스트 수 1,500건+ 확장, 100% GREEN, 빌드 경고 0건 유지. + +--- + +## 3. 마일스톤 및 로드맵 일정표 + +| 마일스톤 | 핵심 테마 | 산출물 및 주요 기능 | 검증 게이트 (DoD) | +|---|---|---|---| +| **M60** | Hyper-Extension Subsystem | MV3 V8 Isolate 워커, SIMD DNR 엔진, Violentmonkey 호환 | 150+ 확장 E2E 게이트 통과 | +| **M61** | P2P Swarm & Multi-CDN | WebTorrent 분산 다운로드, CDN 레이싱, 메쉬 릴레이 | 100회 결함주입 무결성 통과 | +| **M62** | On-Device AI Media Lab | Whisper.cpp 실시간 자막, AI 쇼츠 클리퍼, Vulkan 업스케일러 | DirectML/CPU 폴백 검증 | +| **M63** | Omni-Platform Suite | MAUI 모바일 프로덕션 출시, Linux Flatpak, NAS 데몬 | 전 플랫폼 빌드 & E2E 통과 | +| **M64** | Zero-Knowledge CRDT Vault | CRDT 무충돌 동기화, E2EE WebDAV/S3, FIDO2 YubiKey | 동시 다중 쓰기 무충돌 검증 | +| **M65** | Media Casting & Studio | WebRTC TV 캐스팅, NVENC/QSV 트랜스코더, 파형 EQ | 100ms 미만 지연율 통과 | +| **M66** | 10,000-Gate Chaos Matrix | 연속 퍼징 하네스, 1,500+ 테스트 스위트, 0 경고 보증 | 10,000회 카오스 100% 통과 | + +--- + +## 4. 안드레 카파시 4대 원칙 기반 실행 수칙 + +1. **Think Before Coding**: + - 도메인 설계 시 기존 36개 파이프라인과의 인터페이스 정합성을 선행 검증. + - 3개 이상 모듈 수정 시 반드시 아키텍처 스펙 및 설계도를 선행 갱신. +2. **Simplicity First**: + - 외부 무거운 의존성 지양, C# .NET 8/10 네이티브 고성능 메모리(`Span`, `MemoryPool`, `Channels`) 우선 활용. +3. **Surgical Changes**: + - 기존 검증된 핵심 다운로드/브라우저 엔진의 Blast Radius를 철저히 격리. +4. **Goal-Driven Closed Loop**: + - 모든 신규 기능은 RED 테스트 작성 → 구현 → GREEN 기계적 검증(Exit 0) 루프 준수. diff --git a/docs/HARNESS.md b/docs/HARNESS.md index a5925e3..5b2183b 100644 --- a/docs/HARNESS.md +++ b/docs/HARNESS.md @@ -1,151 +1,117 @@ -# 개발 하네스 — 폐쇄루프 운영 지침 +# 개발 하네스 — 폐쇄루프 운영 및 하네스 엔지니어링 지침 -> 이 저장소에서 AI 코딩 에이전트가 **자율적으로, 그러나 검증 없이는 완료를 선언할 수 없게** 일하도록 -> 짜 놓은 장치들의 설계와 운영법. 사람과 에이전트 모두 이 문서를 읽는 대상이다. +> 이 저장소에서 AI 코딩 에이전트(Antigravity, Claude Code, DeepSeek, Cursor 등)가 +> **자율 루프로 저가형 모델(Flash, DeepSeek, Haiku)까지 완벽히 통제하여 고성능 극한 개발을 수행하도록** +> 구성한 하네스 엔지니어링(Harness Engineering) 설계와 운영법이다. -## 왜 필요한가 +--- -에이전트의 가장 흔한 실패는 코드를 못 짜는 게 아니라 **"됐습니다"라고 말해버리는 것**이다. -테스트가 깨진 채로, 실행해 보지도 않고, 문서는 옛날 상태인 채로 턴이 끝난다. +## 1. 패러다임의 진화: Prompting → Context → Harness -해법은 프롬프트로 더 강하게 부탁하는 게 아니라, **에이전트가 건너뛸 수 없는 결정적 층**을 두는 것이다. -확률적인 판단(모델)과 결정적인 검사(훅)를 분리한다. +| 시대 | 패러다임 | 핵심 질문 | 한계 | +|---|---|---|---| +| **2022 ~ 2024** | **Prompt Engineering** | "어떻게 말해야(프롬프트) 잘 알아들을까?" | 단일 턴 한계, 복잡한 프로젝트에서 망각 및 환각 | +| **2024 ~ 2025** | **Context Engineering** (안드레 카파시) | "모델의 컨텍스트 창에 무엇을 넣고 뺄 것인가?" | 컨텍스트가 길어지면 집중도 감쇠, 실행 검증 부재 | +| **2025 ~ 2026** | **Harness Engineering** (`Agent = Model + Harness`) | "어떤 제약과 피드백 루프로 모델을 가둘 것인가?" | **해결: 비결정적 모델을 결정적 시스템으로 완성** | -## 5계층 구조 +안드레 카파시(Andrej Karpathy)와 업계 리더들이 정립한 핵심 공식은 다음과 같다: -| 계층 | 역할 | 이 저장소에서 | +$$\text{Agent} = \text{Model} + \text{Harness}$$ + +- **Model (커널/CPU)**: 비결정적(Stochastic) 추론 엔진. 아무리 좋은 프런티어 모델이라도 하네스가 없으면 "다 됐습니다"라고 거짓 보고(Vibe Coding)를 하거나 테스트를 무력화한다. +- **Harness (운영체제/제약계층)**: 도구 세트, 컨텍스트 스케줄러, 결정적 테스트 게이트, 린터, 상태 저장소. 모델의 출력을 기계적으로 검증하고 실패 시 에러를 피드백하여 자가수정(Self-Correction)을 강제한다. + +--- + +## 2. 안드레 카파시의 에이전틱 코딩 4대 원칙 + +카파시가 제시한 에이전트의 실패 방지 원칙: + +1. **Think Before Coding (생각 먼저, 코딩 나중)**: + 추측으로 코드를 작성하지 않는다. 요구사항이 모호하면 먼저 조사하거나 명확히 질문하고, 파일 3개 이상 변경 시 플랜을 수립한다. +2. **Simplicity First (최소 구현 원칙)**: + 미래를 위한 오버엔지니어링, 불필요한 추상화 계층을 금지한다. 실패한 테스트(RED)를 통과(GREEN)시키는 가장 단순한 코드를 작성한다. +3. **Surgical Changes (외과수술식 정밀 변경)**: + 요청과 무관한 주변 코드, 주석, 포맷을 임의로 건드리지 않는다. Blast Radius(폭발 반경)을 극도로 제한한다. +4. **Goal-Driven Closed Loop (목표 주도 폐쇄 루프)**: + 모호한 "수정"이 아니라, 기계적으로 검증 가능한 테스트(Exit Code 0)가 달성될 때까지 자율 루프를 돈다. + +--- + +## 3. 저가형 모델(Flash, DeepSeek, Haiku)로 극한 개발을 하는 하네스 기법 + +프런티어 모델(Opus/GPT-4o) 대신 **Gemini Flash, DeepSeek-V3/R1, Claude Haiku 등 1/10~1/50 비용의 저가형 모델**로 최고 효율을 내기 위한 4가지 하네스 기법: + +### ① Token Diet (컨텍스트 오염 차단) +- 저가형 모델은 컨텍스트가 20k~50k 토큰 이상 누적되면 지침 준수율(Instruction Following)이 급격히 저하된다. +- **규칙**: `MainWindow.xaml.cs`(86KB) 같은 대형 소스는 절대로 통째로 읽지 않는다. `grep_search`로 함수 라인을 파악하고 30~50줄 단위로 슬라이스 조회(`view_file`)한다. +- **전역 지침 압축**: `AGENTS.md`를 150줄 이내로 유지하고, 세부 규칙은 파일 열람 시 동적 주입(Path-Scoped Rules)한다. + +### ② Anti-Cheating Guard (치팅 원천 차단) +- 저가형 모델이 자율 루프에서 막히면 흔히 시도하는 3대 꼼수: + 1. 실패하는 테스트 코드를 삭제하거나 단정(Assert)을 완화 + 2. `[Fact(Skip = "...")]` 또는 `[Ignore]` 속성 추가 + 3. 실패하는 예외를 빈 `catch { }` 블록으로 삼킴 +- **방어**: 정적 메타 테스트(`MetaTestQualityAndPruningTests`) 및 Stop 훅으로 테스트 수 감소 및 Skip 속성을 검출하여 즉시 실패 처리. + +### ③ Error-Driven Hill Climbing (결정적 에러 피드백) +- 저가형 모델은 막연한 에러 설명보다 **정확한 컴파일러 진단 코드(`CS0246`, `CS8602`)와 파일:라인:컬럼, xUnit Expected/Actual 차이**를 주입받았을 때 1~2턴 만에 완벽히 교정한다. +- 하네스는 실패 시 빌드/테스트 출력의 핵심 30~50줄을 모델 컨텍스트에 즉시 주입한다. + +### ④ Subagent Hierarchy (계층형 서브에이전트) +- 넓은 범위의 코드 검색, 문서 리서치 등 컨텍스트를 많이 소비하는 작업은 초경량 서브에이전트(`invoke_subagent`, flash/flash_lite)에 위임한다. +- 메인 에이전트의 컨텍스트는 순수한 아키텍처/코드 수정 상태로 깨끗하게 유지된다. + +--- + +## 4. 5계층 하네스 아키텍처 + +| 계층 | 역할 | 이 저장소의 구현 | |---|---|---| -| **① 하네스** | 에이전트가 사는 환경 | Claude Code + `.claude/settings.json` | -| **② 루프 계약** | "완료"의 정의 | `AGENTS.md` §4 완료 기준 · `CLAUDE.md` | -| **③ 상태층** | 세션이 끊겨도 남는 상태 | `docs/BACKLOG.md` 체크박스 · `docs/IMPLEMENTATION_AUDIT.md` · SessionStart 브리핑 | -| **④ 체커** | 에이전트가 못 건너뛰는 검사 | **Stop 훅 → `dotnet test`** | -| **⑤ 사람 관문** | 되돌릴 수 없는 일 앞의 확인 | 커밋·푸시·배포는 사용자 승인 (`/release` 스킬) | +| **① 하네스 환경** | 에이전트 런타임 & 도구 | Antigravity(AGY) · Claude Code (`.claude/settings.json`) | +| **② 루프 계약 (SSOT)** | 도구 중립 작업 헌법 | `AGENTS.md` (AAIF 표준, <150줄) | +| **③ 상태층 (External State)** | 세션 리셋에도 유지되는 상태 | `docs/BACKLOG.md` · `docs/IMPLEMENTATION_AUDIT.md` · `.verify-stamp` | +| **④ 결정적 체커** | 에이전트가 건너뛸 수 없는 게이트 | **Stop 훅 / 검증 게이트 → `dotnet test Paca.Tests`** | +| **⑤ 사람 관문** | 되돌릴 수 없는 결정 | 커밋·푸시·릴리스 승인 (`/release` 스킬) | -핵심은 ④다. 나머지는 ④가 있어야 의미가 생긴다. +--- -## 구성 요소 +## 5. 멀티 MD 파일 관리 표준 ``` -AGENTS.md ← 도구 중립 작업 계약 (Codex/Cursor 등도 읽음) -CLAUDE.md ← @AGENTS.md 임포트 + Claude Code 전용 지침 +AGENTS.md ← 도구 중립 최상위 작업 계약 (AAIF 표준, 전역 상주, 150줄 이내) +CLAUDE.md ← @AGENTS.md 임포트 + 도구별 훅 바인딩 .claude/ - settings.json ← 훅 등록 + 권한 (커밋됨, 팀 공유) + settings.json ← 훅 등록 + 권한 설정 hooks/ - session-brief.sh ← SessionStart: 저장소 상태를 컨텍스트에 주입 (상태층) - verify-on-stop.sh ← Stop: 테스트 게이트 (체커) - rules/ ← 경로별 자동 로드 규칙 (해당 파일을 열 때만 컨텍스트에 들어옴) - desktop-wpf.md ← VideoDownloader.App/**, Browser/** - engine-core.md ← Core/**, Server/** - mobile-maui.md ← Mobile/**, Mobile.Core/** - tests.md ← Tests/** - skills/ ← 호출될 때만 로드되는 절차서 - tdd/SKILL.md ← /tdd RED→GREEN→증거 + session-brief.sh ← SessionStart: 브랜치/변경파일/미완료 백로그 자동 브리핑 + verify-on-stop.sh ← Stop: 턴 종료 시 소스 변경 감지 → dotnet test 강제 게이트 + rules/ ← [Path-Scoped] 해당 파일을 열 때만 로드되는 영역별 규칙 + desktop-wpf.md ← Paca.App/**, Paca.Browser/** + engine-core.md ← Paca.Core/**, Paca.Server/** + mobile-maui.md ← Paca.Mobile/**, Paca.Mobile.Core/** + tests.md ← Paca.Tests/** + skills/ ← [On-Demand] 호출 시에만 컨텍스트에 들어오는 절차서 + tdd/SKILL.md ← /tdd RED→GREEN→증거 사이클 verify/SKILL.md ← /verify 완료 기준 전수 점검 - release/SKILL.md ← /release 배포 - .verify-stamp ← 마지막 검증 성공 시점 (자동 생성, gitignore) + release/SKILL.md ← /release 배포 파이프라인 + .verify-stamp ← 마지막 검증 성공 시각 타임스탬프 (자동 관리) ``` -### 왜 이렇게 나눴나 +### 왜 이렇게 분리하는가? +1. **전역 MD(`AGENTS.md`)는 항상 컨텍스트를 차지한다.** 여기에 수천 줄의 세부 룰을 넣으면 모든 대화 턴마다 토큰 비용이 발생하고 모델의 주의력이 흐려진다. +2. **경로별 룰(`rules/`)은 필요할 때만 들어온다.** WPF 창을 고칠 때만 WPF 룰이 들어오고, 백엔드 코어 엔진을 만질 때는 로드되지 않는다. +3. **절차서(`skills/`)는 트리거될 때만 들어온다.** 평소 대화에서는 토큰을 0바이트 소모한다. +4. **강제는 프롬프트가 아니라 훅과 게이트로 한다.** 프롬프트의 "반드시 테스트하세요"는 모델이 무시할 수 있지만, 훅의 `exit 2`는 물리적으로 턴 종료를 차단한다. -- **CLAUDE.md 는 짧게.** 매 세션 컨텍스트를 먹고, 길수록 지켜지지 않는다. 200줄 이내가 권장선이다. -- **경로별 규칙은 `rules/` 로.** WPF 함정은 WPF 파일을 열 때만 필요하다. 항상 로드하면 낭비다. -- **절차는 `skills/` 로.** 배포 순서 같은 건 배포할 때만 필요하다. 안 쓰면 토큰을 안 쓴다. -- **강제는 훅으로.** CLAUDE.md 는 부탁이고 훅은 강제다. "반드시 X 해라"가 지켜지길 원하면 훅에 넣는다. - -## 훅 동작 - -### SessionStart — 상태 브리핑 - -세션이 시작되면 브랜치·마지막 커밋·변경 파일·검증 상태·미완료 백로그를 컨텍스트에 넣는다. -에이전트가 `git status` 를 따로 묻지 않고 현재 상황을 알고 시작한다. - -### Stop — 테스트 게이트 - -턴을 끝내려 할 때: - -1. `stop_hook_active` 가 `true` 면 즉시 통과 — **무한 루프 방지**. - (Claude Code 는 Stop 훅이 8회 연속 차단하면 강제로 넘긴다. 가드가 없으면 그 8회를 다 태운다.) -2. 소스(`.cs`/`.csproj`/`.xaml`/`.axaml`)의 최신 수정시각을 `.claude/.verify-stamp` 와 비교. - 같으면 즉시 통과 — 대화만 한 턴은 지연 0. -3. 바뀌었으면 `dotnet test VideoDownloader.Tests` 실행 (증분 약 13초). -4. 통과 → 스탬프 갱신 후 종료 허용. 실패 → **exit 2 로 종료 차단**, 실패 출력 60줄을 에이전트에게 전달. - -즉 **테스트를 깨둔 채로 턴을 끝낼 수 없다.** - -## 사용법 - -### 에이전트 (자율 동작) - -특별히 할 일이 없다. 세션을 열면 브리핑이 들어오고, 계약(`AGENTS.md`)이 로드되고, -파일을 열면 해당 영역 규칙이 붙고, 턴을 끝내면 테스트가 돈다. - -기억할 것 세 가지: - -1. 기능 작업은 `/tdd` 로 시작한다. 절차를 기억으로 재구성하지 않는다. -2. 마무리 전에 `/verify` 로 완료 기준을 전수 점검한다. -3. **Stop 훅이 막으면 우회하지 않는다.** 테스트를 지우거나 Skip 처리하는 것은 회귀를 숨기는 것이다. - 못 고치면 못 고친다고 보고한다. - -### 사람 - -```powershell -/hooks # 등록된 훅 확인 -/context # 어떤 지침 파일이 실제 로드됐는지 확인 -/memory # CLAUDE.md 등 열어서 편집 -``` - -**게이트를 잠깐 끄고 싶을 때** — 실험 중이라 테스트가 깨진 게 정상인 상황: - -```jsonc -// .claude/settings.local.json (gitignore 대상, 개인용) -{ "disableAllHooks": true } -``` - -작업이 끝나면 지운다. 켜 두는 게 기본이다. - -**게이트가 느리다고 느껴지면** — 변경이 없으면 이미 스킵된다. 그래도 무거우면 -`verify-on-stop.sh` 의 `dotnet test` 를 `--filter` 로 좁히는 대신, 전체 실행 유지를 권한다. -부분 검증은 회귀를 놓친다. - -## 확장 - -### 새 경로 규칙 추가 - -`.claude/rules/<이름>.md` 에 프론트매터로 경로를 지정한다. - -```markdown --- -paths: - - "VideoDownloader.Avalonia/**" ---- -# Avalonia 영역 규칙 -... -``` -해당 경로 파일을 열 때만 컨텍스트에 들어온다. 30줄 안쪽으로 유지한다. +## 6. 결론: 하네스 기반 자율 개발 체크리스트 -### 새 절차 스킬 추가 +1. [ ] 세션 시작 시 상태 브리핑 확인 +2. [ ] 변경 전 실패하는 테스트(RED) 확인 +3. [ ] 최소한의 코드 수정(GREEN) 진행 +4. [ ] 대용량 파일은 Grep/Slice로 토큰 절약 +5. [ ] `dotnet test` 성공 후 턴 종료 (0 실패, 0 에러) +6. [ ] `docs/BACKLOG.md` 상태 동기화 -`.claude/skills/<이름>/SKILL.md`. `description` 이 트리거를 결정하므로 -**언제 쓰는지**를 구체적으로 쓴다 — "코드 리뷰용"보다 "PR 올리기 전 변경 diff 를 검토할 때". - -### 새 게이트 추가 - -무거운 검사(전체 빌드, 통합 테스트)는 `Stop` 에 붙인다. `PostToolUse` 에는 붙이지 않는다 — -편집할 때마다 돌면 개발이 멈춘다. .NET 빌드는 특히 비싸다. - -## 원칙 - -- **부탁은 CLAUDE.md, 강제는 훅.** 반드시 일어나야 하는 일을 프롬프트에 적어두고 기대하지 않는다. -- **게이트는 우회 대상이 아니라 계약이다.** 막히면 원인을 고친다. -- **완료 보고에는 실제 출력의 숫자를 인용한다.** 기억으로 쓴 "테스트 통과"는 근거가 아니다. -- **되돌릴 수 없는 일 앞에는 사람을 둔다.** 커밋·푸시·배포는 자동화하지 않는다. - -## 참고 - -- [Claude Code — 메모리와 CLAUDE.md](https://code.claude.com/docs/en/memory) -- [Claude Code — 훅 레퍼런스](https://code.claude.com/docs/en/hooks) -- [Claude Code — 스킬](https://code.claude.com/docs/en/skills) -- [AGENTS.md 표준](https://agents.md) — 2025년 8월 공개 규격, 2025년 12월 Linux Foundation 산하 - Agentic AI Foundation 으로 이관. Claude Code 는 `AGENTS.md` 를 직접 읽지 않으므로 - `CLAUDE.md` 에서 `@AGENTS.md` 로 임포트하는 것이 공식 권장 방식이다. diff --git a/docs/IMPLEMENTATION_AUDIT.md b/docs/IMPLEMENTATION_AUDIT.md index d426678..6dcf773 100644 --- a/docs/IMPLEMENTATION_AUDIT.md +++ b/docs/IMPLEMENTATION_AUDIT.md @@ -1,10 +1,616 @@ # 구현 감사 보고서 — 계획 ↔ 구현 ↔ 증거 매핑 -> **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목 -> **감사일**: 2026-08-15 (갱신 2026-08-16) · **방법**: TDD(RED→GREEN) + E2E 검증 -> **요약**: 전 솔루션 Release 빌드 **0 오류**, 단위/E2E 테스트 **180/180 GREEN**, Android 에뮬레이터·iOS 시뮬레이터·LAN 서버 E2E 완료 +> **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목 + Google Chrome 확장 프로그램(Extensions) + OS 생체 인증(Windows Hello / Touch ID) 자격증명 볼트 & 차세대 7대 극초대형 확장 도메인 (Phase 60 ~ Phase 66) & **Paca만의 10대 차별화 브라우저/파비콘/스니퍼/터보/보안 게이트 (Gate 82 ~ Gate 168)** & **Phase 2 고강도 디자인/접근성/상호작용 25대 게이트 (Visual Design Gates 86 ~ 110 & Meta-Gates 23 ~ 33 & Commandments Gates 7~25)** +> **감사일**: 2026-09-12 · **방법**: TDD 근본 헌법(Kent Beck, Uncle Bob, Martin Fowler, Meszaros) 기반 `docs/TDD_THEORY_SSOT.md` 수립 + `AGENTS.md` 25대 디자인 게이트 전면 개편 + `docs/references/DESIGN_AUDIT_METHODOLOGIES.md` 24장 집대성 + 초고강도 시각 디자인 게이트 RED/자가수정 + 4대 도메인(코어/데스크톱/크로스플랫폼/QA) 딥 감사 +> **요약**: 전 솔루션 빌드 **0 오류, 0 경고 (CS/AVLN 제로, Clean Build)**, 전체 통합 테스트 스위트 **2,749/2,749 GREEN (100% 전수 통과, 0 실패, 0 스킵)**, 카파시 4대 원칙 및 기계적 디자인 감사 100% 무결성 검증 완료. + +- **최신 2026-09-14 TDD 사이클: 웹사이트 및 제품의 SEO / AEO / GEO 전면 업그레이드 및 고단가 유기적 트래픽 유입 인프라 구축 (2,749/2,749 GREEN 100% PASS)**: + - **Milestone 30 (SEO / AEO / GEO 전면 최적화 및 고단가 트래픽 유입 인프라 구축)**: + - **요구사항**: 사용자들이 구글, 네이버 등 검색엔진에서 입력하는 키워드와 ChatGPT, Perplexity 등 생성형 AI에게 묻는 질문을 정밀 분석하여, PACA 공식 사이트가 검색 결과 최상단 및 AI 추천 1순위로 노출되도록 SEO, AEO, GEO 전면 최적화. + - **SEO (전통 검색엔진 최적화 - Schema.org & Hreflang)**: + - `site/index.html` 내 풍부한 Schema.org JSON-LD 구조화 데이터 적용: `SoftwareApplication` (버전, 다운로드 링크, 무료 가격 모델, 평점 4.9/5, 세부 기능 명세) 및 `FAQPage` (로그인 세션, IDM 대체, 무료 정책 Q&A). + - 글로벌 고단가(CPM $10~$25) 해외 트래픽 유입을 위한 이중 Hreflang 메타 태그 (`ko`, `en`, `x-default`) 및 타깃 키워드 메타 태그 적용. + - HLS m3u8 고검색량 키워드 전용 랜딩 페이지 `site/how-to-download-m3u8.html` 신설 및 `HowTo` + `FAQPage` 복합 Schema.org 배포. + - **AEO (인공지능 답변엔진 최적화 - ChatGPT Search / Perplexity / Copilot)**: + - 사용자 질문에 직결되는 두괄식 즉답(Inverted Pyramid) Q&A 구조 배치. + - 경쟁 상용 툴 대비 특장점 상세 비교 랜딩 페이지 `site/vs-idm-alternative.html` 신설: PACA vs IDM vs 4K Video Downloader vs 크롬 확장 프로그램 8대 핵심 항목(가격, 내장 브라우저, 세션 쿠키, m3u8 자동 감지, AES-128, 4K/8K, 모바일 스트리밍, 악성코드 안전성) 비교 매트릭스 표 구축. + - **GEO (생성형 AI 모델 인덱싱 및 인용 최적화 - Answer.AI / Anthropic 표준)**: + - Answer.AI 표준 `/llms.txt` 요약 명세서 구축. + - LLM 모델이 직접 크롤링하여 학습/인용할 수 있는 4,000자+ 상세 기술 명세서 `site/llms-full.txt` 신설 (아키텍처, 벤치마크 수치, 세부 가이드, FAQ, 보안 정책). + - `site/robots.txt`에 주요 AI 크롤러 봇(`GPTBot`, `PerplexityBot`, `ClaudeBot`, `Google-Extended`, `Bingbot`, `Yeti`) 전면 허용 및 `llms.txt` 경로 공개. + - **사이트맵 및 탐색 내비게이션 확장 (`site/sitemap.xml`)**: + - 메인, 다운로드, 가이드뿐 아니라 m3u8 가이드, IDM 비교, `llms.txt`, `llms-full.txt` 등 전체 리소스 색인 등록 및 최신 `lastmod` 부여. + - **TDD 게이트 무결성 및 전수 검증 (`SiteSeoAeoGeoGateTests.cs` Gate 211~213 PASS)**: + - Gate 211 (Schema.org 구조화 데이터 및 HowTo/FAQPage 무결성), Gate 212 (AI 크롤러 봇 허용 및 llms.txt/llms-full.txt 규격), Gate 213 (AEO 비교 테이블, sitemap.xml, Hreflang) 전수 통과. + - 솔루션 전체 테스트 **2,749 / 2,749 GREEN 전수 통과 (100% 통과, 0 실패, 0 스킵)**. + +- **최신 2026-09-14 TDD 사이클: 가짜 모의 광고 전면 척결 및 실시간 구글 공식 광고 네트워크(GPT/AdSense) 인프라 & ads.txt/도메인 오리진 격리 수익화 정상화 (2,746/2,746 GREEN 100% PASS)**: + - **Milestone 29 (가짜 모의 광고 전면 척결 및 실시간 구글 공식 광고 네트워크 GPT/AdSense 인프라 & ads.txt/도메인 오리진 격리 수익화 정상화)**: + - **요구사항**: 가짜 모의 광고(쿠팡, 삼성전자 등 하드코딩된 목업 카드)를 전면 척결하고, 실제 구글 퍼블리셔 ID(`pub-1039714767792854`)와 연동된 정품 광고 인프라(Google Publisher Tag `gpt.js` 및 Google AdSense `adsbygoogle.js`)를 구축하여 사용자가 실질적인 광고 단가와 노출/클릭 수익을 정상 인정받을 수 있도록 보장. + - **가짜 모의 광고 완전 박멸 및 정품 구글 광고 네트워크 스니펫 연동 (`DesktopAdModels.cs`, `DesktopAdBannerControl.xaml.cs`)**: + - `DesktopAdModels.cs` 및 `DesktopAdBannerControl.xaml.cs` 내 모든 하드코딩된 모의 광고(쿠팡, 갤럭시 S24, 삼성전자, 가짜 할인 배너 HTML) 전면 영구 삭제. + - `PublisherId = "pub-1039714767792854"`, `AdClient = "ca-pub-1039714767792854"` 표준 상수 선언. + - `GenerateGoogleTagSnippet`: GPT 초기화 시 `defineSlot`과 `enableServices`가 선행 등록되도록 큐 순서를 최적화하여 슬롯 미정의 경고 원천 차단 및 `slotRenderEnded`(`AD_RENDERED`) 실시간 웹뷰 메시지 통지 탑재. + - `GenerateAdSenseSnippet`: 공식 `adsbygoogle.js` 및 `data-ad-client="ca-pub-1039714767792854"` 표준 AdSense 마크업 생성 엔진 제공. + - **공식 퍼블리셔 검증 파일 배포 (`site/ads.txt`, `site/app-ads.txt`)**: + - `google.com, pub-1039714767792854, DIRECT, f08c47fec0942fa0` 표준 레코드 작성 및 `site/` 및 로컬 가상 호스트 디렉터리(`ad_html/`) 배포. + - **uBlock Origin 확장 프로그램 간섭 방지를 위한 독립 WebView2 광고 환경 격리**: + - 사용자의 브라우징 탭에 설치된 uBlock Origin 등 광고 차단 확장(`cjpalhdlnbpafiamejdnhcphjbkeiagm`)이 광고 배너를 차단하여 빈 화면이 되는 현상을 방지하기 위해, 전용 데이터 폴더(`%AppData%\Paca\ad_profile`) 및 `--disable-extensions` 플래그가 지정된 독립 CoreWebView2 환경(`GetOrCreateAdEnvironmentAsync`) 구축. + - **가상 HTTPS 도메인 오리진 매핑 (`https://ads.paca.cloud`)**: + - `SetVirtualHostNameToFolderMapping`을 통해 로컬 `file:///` 프로토콜 사용 시 발생하는 Referer 누락 및 AdSense 차단 문제를 극복하고, 정식 HTTPS 보안 오리진(`https://ads.paca.cloud/`)으로 광고 요청 및 `ads.txt` 조회 매핑. + - ``, `` 메타 태그 장착. + - **TDD 게이트 무결성 및 실시간 렌더링 검증 (`DesktopAdSlotAndNtpSponsoredGateTests.cs` Gate 209~210 PASS)**: + - Gate 209: `ads.txt` 및 `app-ads.txt` 존재성, `pub-1039714767792854` 일치성 검증. + - Gate 210: 쿠팡/삼성/목업 키워드 전면 배제 및 `SetVirtualHostNameToFolderMapping`, `ad-google-wrapper`, AdSense 스니펫 생성기 무결성 검증. + - 솔루션 전체 테스트 **2,746 / 2,746 GREEN 전수 통과 (100% 통과, 0 실패, 0 스킵)**. + - WMI 분리 런처 기반 데스크톱 실창 렌더링 검증 완료 (`MainWindowHandle = 264166 != 0`, `AD_RENDERED` 이벤트 수신 및 AdChoices 아이콘 배너 렌더링 완료). + +- **최신 2026-09-13 TDD 사이클: 데스크톱 인앱 스크린샷 디버깅 시스템 및 듀얼 광고 배너 CSS Grid 렌더링 정상화 (2,744/2,744 GREEN 100% PASS)**: + - **Milestone 28 (데스크톱 앱 자체 화면 정밀 캡처 서비스 및 듀얼 광고 배너 CSS Grid 렌더링 정상화)**: + - **요구사항**: 외부 모니터/데스크톱 화면이 아닌 앱 자체(WPF HWND 윈도우)만을 단독 캡처하는 인앱 디버깅 스크린샷 시스템 구축 및, 앱 내 하단(728x90)과 다운로드 패널(300x250) 듀얼 광고 배너가 브라우저 렌더러 상에서 100% 온전하고 선명하게 표시되도록 CSS Grid 및 렌더링 파이프라인 정상화. + - **앱 자체 윈도우 정밀 캡처 파이프라인 (`AppDebugScreenshotService.cs`)**: + - `PrintWindow(hWnd, hdc, PW_RENDERFULLCONTENT)`와 WPF `RenderTargetBitmap` 결합 하이브리드 파이프라인. + - 외부 모니터나 OS 데스크톱의 다른 창(게임 등)이 절대 찍히지 않고 오직 PACA 앱 윈도우 영역만 정확하게 단독 캡처. + - `%AppData%\Paca\screenshot_trigger.req` 파일 신호 감지 타이머 기반 온디맨드 캡처 지원. + - WebView2 자식 컨트롤 영역에 대한 `CapturePreviewAsync` 비트맵 합성 지원. + - **광고 배너 CSS Grid 및 뷰포트 레이아웃 정상화 (`DesktopAdBannerControl.xaml.cs`)**: + - 기존 Flexbox 폭 수축으로 인한 중앙 본문 텍스트 증발 문제를 해결하기 위해 CSS Grid(`grid-template-columns: 56px 1fr auto;`) 3열 구조 도입. + - 300x250 다운로드 패널 배너의 오프스크린 쏠림(검은 빈 상자 현상)을 해결하기 위해 `` 명시 및 3행 그리드(`grid-template-rows: auto 1fr auto;`) 적용. + - 가상 호스트 HTTP 디스크 캐싱 버그를 방지하기 위해 `CoreWebView2.NavigateToString(pageHtml)` 직접 메모리 파싱 적용. + - **TDD 무결성 검증 (`DesktopAdSlotAndNtpSponsoredGateTests.cs` Gate 205~208 PASS)**: + - Gate 207: 규격, UTF-8 BOM, 캐시 버스팅, 첫 프레임 페인트 대기 감사. + - Gate 208: 캡처된 실제 렌더링 비트맵의 텍스트 픽셀(밝은 폰트) 존재성 및 단색 빈 사각형 방지 검증. + - 솔루션 전체 테스트 **2,744 / 2,744 GREEN 전수 통과 (100% 통과, 0 실패, 0 스킵)**. + - WMI 분리 런처 기반 데스크톱 실창 렌더링 검증 완료 (`MainWindowHandle = 2887134 != 0`). + +- **최신 2026-09-13 TDD 사이클: 영상 썸네일 자동 추출/로컬 캐시(Referer 403 방어 & FFmpeg 프레임 추출) 및 스마트 제목 추론/난수 해시 정제 & 이름 바꾸기 시스템 (2,737/2,737 GREEN 100% PASS)**: + - **Milestone 27 (영상 썸네일 누락 해결 및 스마트 파일명 추론 & 수동 이름 변경 시스템)**: + - **요구사항**: 웹 스니퍼 감지 시 영상 썸네일이 제대로 나오지 않아 일반 필름 아이콘으로 대체되던 현상을 해결하고, 난수 해시값(`1a099dac52a596d88` 등)으로 중구난방 저장되던 파일명을 웹 페이지/게시물 제목 기반으로 정제 및 다중 영상 순번 자동 부여(`#1`, `#2`), 수동 이름 변경(더블클릭 및 컨텍스트 메뉴) 지원. + - **스마트 제목 추론 및 난수 해시 감지 엔진 (`FileNameUtil.cs`)**: + - `IsObfuscatedHash(string text)`: 12자 이상의 16진수 해시, UUID/GUID, 타임스탬프 결합 해시 패턴을 자동 판별. + - `CleanWebTitle(string pageTitle)`: 사이트 보일러플레이트 접미사(` - 루리웹`, ` · GitHub`, ` - YouTube` 등 20여 종) 및 불필요한 태그/기호 자동 정제. + - `DeriveSmartTitle(string pageTitle, Uri mediaUri, int mediaIndex = 0)`: 원본 파일명이 난수 해시일 경우 정제된 웹 페이지 제목을 우선 채택하고, 동일 페이지 다중 영상 감지 시 `#1`, `#2` 접미사 자동 부여. + - **썸네일 로컬 캐싱 및 Referer 403 핫링크 차단 방어 (`ThumbnailCacheService.cs`)**: + - CDN 핫링크 방지(403 Forbidden)가 걸린 이미지(루리웹, 네이버 등)를 WPF ``가 직접 로드하지 못하는 문제를 해결하기 위해, 원본 페이지 URL을 `Referer`로 설정한 `HttpClient` 비동기 다운로더 구현. + - 인라인 `` 캡처 Base64 Data URI(`data:image/jpeg;base64,...`) 즉각 디코딩 및 로컬 캐싱. + - 로컬 `%AppData%\Paca\cache\thumbnails\`에 SHA-256 해시 기반 영속 캐싱 지원. + - 번들된 FFmpeg(`%AppData%\Paca\tools\ffmpeg.exe`)를 활용한 원격/로컬 비디오 1초 지점 프레임 직접 추출 엔진 연동. + - **DOM 스니퍼 스크립트 고도화 (`TabViewService.cs`)**: + - DOM 내 ` + - -
+
-
-

사용 안내

-

설치부터 첫 영상 다운로드, 라이브러리 정리, 휴대폰 무선 연결까지 단계별로 안내합니다.

-
+ 사용 안내서 +

Paca 시작하기

+

설치부터 영상 다운로드, 스마트폰 연결까지 핵심 사용법을 한눈에 확인하세요.

-
- -
+
+ diff --git a/site/how-to-download-m3u8.html b/site/how-to-download-m3u8.html new file mode 100644 index 0000000..5142bf5 --- /dev/null +++ b/site/how-to-download-m3u8.html @@ -0,0 +1,239 @@ + + + + + +m3u8 다운로드 및 고화질 MP4 변환 방법 (HLS 스트리밍 완벽 가이드) - Paca + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ HLS 스트리밍 기술 가이드 +

m3u8 스트리밍 동영상 고화질 MP4 변환 및 다운로드 완벽 가이드

+

복잡한 F12 개발자 도구 검색이나 주소 복사 없이, 브라우저에서 재생하는 순간 HLS 스트림을 감지해 100% 무손실 원본 MP4로 저장하는 실전 튜토리얼.

+
+
+ +
+
+ + + +
+ +
+

왜 m3u8 동영상은 다운로드가 어려울까요?

+

+ 현대 웹 스트리밍(유튜브, 트위치, 치지직, 네이버TV, 아프리카, OTT 및 인터넷 강의 등)은 대부분 HLS(HTTP Live Streaming) 규격을 사용합니다. HLS는 2~10초 단위의 잘게 쪼개진 .ts 또는 .m4s 조각들로 전송되며, 이 조각들의 재생 순서와 암호화 정보를 담은 것이 바로 .m3u8 플레이리스트 파일입니다. +

+
    +
  • 주소 찾기 번거로움: 개발자 도구(F12) 네트워크 필터에서 m3u8을 일일이 찾아야 함.
  • +
  • 세션 쿠키 및 권한 만료: 로그인 멤버십 영상이나 토큰이 붙은 m3u8은 외부 다운로더로 넘기면 403 Forbidden 발생.
  • +
  • AES-128 암호화: 영상 조각이 암호화되어 있어 단순 병합 시 재생 불가능한 깨진 파일 생성.
  • +
+
+ + +
+
+ 1 +

Paca 내장 브라우저에서 동영상 웹페이지 접속

+
+

+ Paca는 Microsoft Chromium WebView2 엔진을 탑재한 독립 미디어 브라우저입니다. 상단 주소창에 영상을 시청하려는 웹사이트 URL을 입력하여 평소 웹서핑하듯 접속합니다. 로그인이 필요한 회원제 사이트나 인터넷 강의라면 브라우저 안에서 로그인해 두세요. +

+
+ +
+
+ 2 +

동영상 재생 즉시 실시간 m3u8 스트림 자동 감지

+
+

+ 영상의 재생(Play) 버튼을 누르는 순간, Paca의 내장 패킷 스니퍼가 백그라운드에서 흐르는 .m3u8, .mpd, .mp4 스트림을 가로챕니다. 동시에 브라우저의 로그인 세션 쿠키, Referer, Authorization 헤더를 자동으로 확보하므로 접근 제한 영상도 완벽히 감지됩니다. +

+
+ +
+
+ 3 +

세그먼트 병렬 다운로드 & 무손실 MP4 자동 리먹싱

+
+

+ '다운로드' 버튼을 누르면 1~16개 멀티스레드로 수백 개의 영상 조각을 동시 수신합니다. AES-128 암호화가 걸려있다면 자동으로 키를 조회해 메모리에서 실시간 해제합니다. 다운로드가 완료되면 내장된 ffmpeg가 재인코딩(화질 손실) 없이 1초 만에 깔끔한 단일 MP4 컨테이너로 조립하여 PC에 저장합니다. +

+
+ + +
+

자주 묻는 질문 (FAQ)

+
+
+ Q. 다운로드 중 네트워크가 끊기면 어떻게 되나요? +

Paca는 세그먼트 단위 이어받기를 지원합니다. 일시적인 인터넷 지연이나 끊김이 발생해도 이미 다운로드된 세그먼트 이후부터 자동으로 이어서 다운로드합니다.

+
+
+ Q. Paca는 무료인가요? 바이러스나 악성코드는 없나요? +

Paca는 100% 무료 소프트웨어입니다. 애드웨어나 채굴기 등 불필요한 번들이 전혀 포함되어 있지 않으며, 공식 Google 광고 네트워크를 통해 투명하게 운영됩니다.

+
+
+
+ + +
+

지금 Paca로 m3u8 동영상을 다운로드하세요

+

Windows 10, 11 및 Android 컴패니언 앱을 지원합니다.

+ +
+
+
+
+ + + + + + diff --git a/site/index.html b/site/index.html index 544224d..5befcb7 100644 --- a/site/index.html +++ b/site/index.html @@ -3,13 +3,85 @@ -MEDIABROWSER - 보던 영상을 내 PC와 휴대폰으로 - +Paca - 무료 동영상 다운로더 & 미디어 브라우저 (HLS m3u8 자동 감지) + + - + - + + + + + + + + + + + + + + @@ -18,22 +90,25 @@ + +