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

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

View file

@ -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

View file

@ -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

View file

@ -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` 가 실창에서 이를 강제한다.

View file

@ -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 이므로 인증·경로 검증을 우회하는 엔드포인트를 추가하지 않는다.

View file

@ -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 결선은 워크로드가 있는 환경에서 확인하도록 사용자에게 알린다.
- 화면 동작을 바꿨으면 에뮬레이터에 배포해 확인한 내용을 보고에 적는다.
단위 테스트만으로 "됐다"고 하지 않는다.

View file

@ -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~<이름>" # 단건
```

View file

@ -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)"
]
}
}

View file

@ -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/<arch>/`)
→ Inno Setup 으로 설치자 컴파일 → `out/MediaBrowser-Setup-<arch>.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/<rid>/artifacts-<rid>.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` 참고.

View file

@ -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 통과"). 기억으로 쓰지 않는다.
## 막혔을 때
- 원인을 모르겠으면 추측으로 코드를 바꾸지 말고, 먼저 재현 범위를 좁히는 테스트를 더 쓴다.
- 그래도 안 되면 무엇을 시도했고 무엇이 관측됐는지 정리해 사용자에게 보고한다.
통과하지 못한 것을 통과한 것처럼 쓰지 않는다.

View file

@ -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. 판정 보고
아래 형식으로 사용자에게 보고한다. **실제 출력에서 숫자를 인용한다.**
```
검증 결과
- 테스트: <통과>/<전체> 통과, 실패 <n>건
- 빌드: 오류 <n>, 신규 경고 <n>
- 실행 증거: <무엇을 어떻게 확인했는지 / 해당 없음>
- 저장소: 변경 <n>건, 산출물 오염 없음
- 문서: <갱신한 파일 / 갱신 불필요>
판정: 완료 | 미완료(사유: …)
```
미통과 항목을 숨기거나 완곡하게 쓰지 않는다.

3
.cursorrules Normal file
View file

@ -0,0 +1,3 @@
# Cursor AI Rules
# Single Source of Truth: AGENTS.md (Linux Foundation AAIF Standard)
@AGENTS.md

View file

@ -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 계정>
# FORGEJO_PASSWORD=<Forgejo 비밀번호>
# (선택) 개인 액세스 토큰 — 값이 있으면 비밀번호 대신 우선 사용
# 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

View file

@ -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

View file

@ -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

32
.gitignore vendored
View file

@ -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/

163
AGENTS.md
View file

@ -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자 서비스 장애에 깨지지 않도록 테스트 더블/오프라인 피스처 방어 구축.

View file

@ -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 을 만들지 않는다.** 그건 회귀를 숨기는 것이다.
- 작업 결과를 보고할 때 테스트 수를 실제 출력에서 인용한다. 기억으로 쓰지 않는다.

View file

@ -13,6 +13,14 @@
<!-- 한글 파일명·정렬을 다루므로 InvariantGlobalization 을 절대 켜지 않는다. -->
<InvariantGlobalization>false</InvariantGlobalization>
<!-- 모든 플랫폼에서 UTF-8 인코딩 소스코드 해석 강제 (문자열 깨짐 방지) -->
<CodePage>65001</CodePage>
<!-- 제품 브랜딩 메타데이터 -->
<Product>Paca</Product>
<Company>Paca</Company>
<Authors>Paca Team</Authors>
<Copyright>Copyright © 2026 Paca</Copyright>
<!-- 재현 가능한 빌드 + VS 증분 빌드 가속 -->
<Deterministic>true</Deterministic>

11
Paca.slnx Normal file
View file

@ -0,0 +1,11 @@
<Solution>
<Project Path="src/Paca.App/Paca.App.csproj" />
<Project Path="src/Paca.Avalonia/Paca.Avalonia.csproj" />
<Project Path="src/Paca.Browser/Paca.Browser.csproj" />
<Project Path="src/Paca.Cli/Paca.Cli.csproj" />
<Project Path="src/Paca.Core/Paca.Core.csproj" />
<Project Path="src/Paca.Mobile/Paca.Mobile.csproj" />
<Project Path="src/Paca.Mobile.Core/Paca.Mobile.Core.csproj" />
<Project Path="src/Paca.Server/Paca.Server.csproj" />
<Project Path="tests/Paca.Tests/Paca.Tests.csproj" />
</Solution>

View file

@ -1,47 +0,0 @@
<Window x:Class="VideoDownloader.App.BookmarkManagerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="즐겨찾기 관리" Height="520" Width="640"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- UC-M18 즐겨찾기 검색 -->
<DockPanel Grid.Row="0" Margin="0,0,0,10">
<TextBlock Text="이름 또는 URL 검색" Foreground="{DynamicResource TextMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="SearchBox" Style="{StaticResource DarkTextBox}" TextChanged="SearchBox_TextChanged"/>
</DockPanel>
<ListView x:Name="List" Grid.Row="1" Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource TextPrimary}"
SelectionMode="Extended" KeyDown="List_KeyDown">
<ListView.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,3">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource CardActionButton}" Content="편집" Click="Edit_Click" Margin="4,0,0,0"/>
<Button Style="{StaticResource CardActionButton}" Content="삭제" Click="Delete_Click" Margin="4,0,0,0"/>
</StackPanel>
<StackPanel>
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" FontWeight="Medium"/>
<TextBlock Text="{Binding Url}" Foreground="{DynamicResource TextMuted}" FontSize="10.5" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DockPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<!-- UC-M19 Netscape 북마크 HTML 내보내기/가져오기 -->
<Button Style="{StaticResource GhostButton}" Content="HTML 내보내기" Click="Export_Click" Margin="0,0,8,0"/>
<Button Style="{StaticResource GhostButton}" Content="HTML 가져오기" Click="Import_Click" Margin="0,0,8,0"/>
<Button Style="{StaticResource PrimaryButton}" Content="닫기" Width="90" Click="Close_Click"/>
</StackPanel>
</Grid>
</Window>

View file

@ -1,67 +0,0 @@
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media.Imaging;
using QRCoder;
using VideoDownloader.Server;
namespace VideoDownloader.App.Browser;
/// <summary>M0 원격 접근 페어링: QR(주소+토큰 동시 전달) + 상태 표시.</summary>
public sealed class PairingWindow : Window
{
public PairingWindow(ServerHost server, string deviceName)
{
Title = "기기 연결 - MediaBrowser";
Width = 420; Height = 560;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
SetResourceReference(FontFamilyProperty, "UiFont"); // 암시 스타일 폰트 강제 제거 후 창 루트 상속으로 통일
Background = System.Windows.Media.Brushes.White;
var lanIp = GetLanIp();
var url = $"http://{lanIp}:{server.Port}/#t={server.Token}";
var qrPayload = new QRCodeGenerator().CreateQrCode(url, QRCodeGenerator.ECCLevel.M);
var qr = new QRCode(qrPayload);
var bmp = qr.GetGraphic(12);
var stack = new StackPanel { Margin = new Thickness(24) };
var title = new TextBlock { Text = "📱 폰 카메라로 QR 스캔", FontSize = 16, FontWeight = FontWeights.Bold, Margin = new Thickness(0, 0, 0, 4) };
var sub = new TextBlock { Text = "브라우저에서 바로 열립니다(앱 설치 불필요). 같은 Wi-Fi 에 있어야 합니다.", FontSize = 12, Foreground = System.Windows.Media.Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16) };
var img = new Image { Width = 300, Height = 300 };
using var ms = new MemoryStream();
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
ms.Position = 0;
img.Source = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
var urlBox = new TextBox { Text = url, IsReadOnly = true, FontSize = 11, Margin = new Thickness(0, 12, 0, 4), TextWrapping = TextWrapping.Wrap };
var copyBtn = new Button { Content = "주소 복사", Padding = new Thickness(14, 6, 14, 6), HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 8, 0, 0) };
copyBtn.Click += (_, _) => { try { Clipboard.SetText(url); } catch { } };
var note = new TextBlock { Text = $"포트 {server.Port} · Windows 방화벽 확인이 뜨면 '허용'을 선택하세요\n토큰은 이 기기와 페어링된 폰에서만 유효합니다",
FontSize = 11, Foreground = System.Windows.Media.Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 12, 0, 0) };
stack.Children.Add(title);
stack.Children.Add(sub);
stack.Children.Add(img);
stack.Children.Add(urlBox);
stack.Children.Add(copyBtn);
stack.Children.Add(note);
Content = stack;
}
/// <summary>LAN IPv4(게이트웨이 있는 첫 후보) — 없으면 loopback.</summary>
private static string GetLanIp()
{
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
socket.Connect("8.8.8.8", 65530); // 외부 행선지 지정 → 라우팅되는 인터페이스 IP 획득(패킷 전송 없음)
return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString() ?? "127.0.0.1";
}
catch { return "127.0.0.1"; }
}
}

View file

@ -1,169 +0,0 @@
using System.IO;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.App.Browser.Features;
using VideoDownloader.Core.Config;
using Wv2 = Microsoft.Web.WebView2.Wpf;
namespace VideoDownloader.App.Browser;
/// <summary>
/// T2-10 미디어 재생목록 플레이어: Direct(MP4)/HLS(hls.js)/DASH(dash.js) 를
/// 생성된 HTML 플레이어로 재생. 곡 종료 시 다음 트랙 자동 재생(연속 재생).
/// </summary>
public sealed class PlayerWindow : Window
{
private readonly CoreWebView2Environment _env;
private readonly AppConfig _config;
private readonly Wv2.WebView2 _web = new() { DefaultBackgroundColor = System.Drawing.Color.Black };
private MediaPlaylist _playlist;
private readonly TextBlock _title;
public PlayerWindow(CoreWebView2Environment env, AppConfig config, MediaPlaylist playlist)
{
_env = env;
_config = config;
_playlist = playlist;
Title = "재생목록 - MediaBrowser";
Width = 900; Height = 620;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
SetResourceReference(FontFamilyProperty, "UiFont"); // 암시 스타일 폰트 강제 제거 후 창 루트 상속으로 통일
var bar = new Border { Background = System.Windows.Media.Brushes.Black, Padding = new Thickness(8, 6, 8, 6) };
var dock = new DockPanel();
var prev = MkBtn("⏮ 이전", () => { _playlist.Previous(); PlayCurrent(); });
var next = MkBtn("⏭ 다음", () => { _playlist.Next(); PlayCurrent(); });
var close = MkBtn("✕ 닫기", () => Close());
_title = new TextBlock { Foreground = System.Windows.Media.Brushes.LightGray, FontSize = 12, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Margin = new Thickness(8, 0, 8, 0) };
DockPanel.SetDock(close, Dock.Right);
DockPanel.SetDock(next, Dock.Right);
DockPanel.SetDock(prev, Dock.Left);
dock.Children.Add(close);
dock.Children.Add(next);
dock.Children.Add(prev);
dock.Children.Add(_title);
bar.Child = dock;
var grid = new Grid { RowDefinitions = { new RowDefinition { Height = GridLength.Auto }, new RowDefinition { Height = new GridLength(1, GridUnitType.Star) } } };
Grid.SetRow(bar, 0); Grid.SetRow(_web, 1);
grid.Children.Add(bar);
grid.Children.Add(_web);
Content = grid;
Loaded += async (_, _) =>
{
try
{
var options = _env.CreateCoreWebView2ControllerOptions();
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true;
await _web.EnsureCoreWebView2Async(_env, options);
var cv = _web.CoreWebView2!;
cv.WebMessageReceived += (_, we) =>
{
try
{
var msg = we.WebMessageAsJson;
if (string.IsNullOrEmpty(msg)) return;
using var doc = System.Text.Json.JsonDocument.Parse(msg);
if (doc.RootElement.TryGetProperty("vdEnded", out var e) && e.GetBoolean())
{
Dispatcher.BeginInvoke(new Action(() =>
{
var cur = _playlist.Current;
if (cur != null) _playlist.MarkPlayed(cur);
// UC-M12 반복 모드: one=현 트랙 재생, all=끝에서 처음으로 순환, none=끝에서 정지
var mode = _config.PlaylistLoopMode;
if (mode == "one") { PlayCurrent(); return; }
var atEnd = _playlist.Tracks.Count > 0 && _playlist.CurrentIndex >= _playlist.Tracks.Count - 1;
if (atEnd && mode != "all") return;
_playlist.Next();
PlayCurrent();
}));
}
}
catch { }
};
PlayCurrent();
}
catch { }
};
}
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
try
{
var hwnd = new WindowInteropHelper(this).Handle;
int dark = 1;
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
}
catch { }
}
[System.Runtime.InteropServices.DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
private Button MkBtn(string text, Action onClick)
{
var b = new Button
{
Content = text,
Padding = new Thickness(10, 3, 10, 3),
Margin = new Thickness(4, 0, 4, 0),
Background = System.Windows.Media.Brushes.Transparent,
Foreground = System.Windows.Media.Brushes.White,
BorderBrush = System.Windows.Media.Brushes.Gray
};
b.Click += (_, _) => onClick();
return b;
}
public void PlayCurrent()
{
var track = _playlist.Current;
if (track == null) { _title.Text = "재생목록이 비었습니다"; return; }
_title.Text = $"{_playlist.CurrentIndex + 1}/{_playlist.Tracks.Count} · {track.Title} [{track.Kind}]";
try { _web.CoreWebView2?.NavigateToString(BuildPlayerHtml(track)); }
catch { }
}
/// <summary>트랙 재생 HTML — HLS 는 hls.js, DASH 는 dash.js 번들 인라인.</summary>
public static string BuildPlayerHtml(MediaTrack track)
{
var url = track.Url.Replace("&", "&amp;").Replace("\"", "&quot;").Replace("<", "&lt;");
var isHls = track.Kind == "Hls" || track.Url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase);
var isDash = track.Kind == "Dash" || track.Url.Contains(".mpd", StringComparison.OrdinalIgnoreCase);
var hls = isHls ? ReadResource("VideoDownloader.App.Assets.hls.min.js") : "";
var dash = isDash ? ReadResource("VideoDownloader.App.Assets.dash.all.min.js") : "";
var loader = "";
if (isHls) loader = "if (Hls.isSupported()) { var h = new Hls(); h.loadSource(SRC); h.attachMedia(v); } else v.src = SRC;";
else if (isDash) loader = "var p = dashjs.MediaPlayer().create(); p.initialize(v, SRC, true);";
else loader = "v.src = SRC;";
var html = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>" +
"body{background:#000;margin:0;overflow:hidden}video{width:100vw;height:100vh}" +
"</style></head><body><video id='v' controls autoplay playsinline></video>" +
"<script>var SRC=" + System.Text.Json.JsonSerializer.Serialize(track.Url) + ";" +
(hls.Length > 0 ? "<script>" + hls + "</script>" : "") +
(dash.Length > 0 ? "<script>" + dash + "</script>" : "") +
"<script>(function(){var v=document.getElementById('v');" + loader +
"v.addEventListener('ended',function(){try{window.chrome.webview.postMessage({vdEnded:true});}catch(e){}});" +
"v.addEventListener('error',function(){try{window.chrome.webview.postMessage({vdEnded:true});}catch(e){}});})();" +
"</script></body></html>";
return html;
}
private static string ReadResource(string name)
{
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(name);
return stream == null ? "" : new StreamReader(stream).ReadToEnd();
}
}

View file

@ -1,43 +0,0 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace VideoDownloader.App;
/// <summary>컬렉션 카운트 → 빈 상태 표시용 (0일 때 Visible)</summary>
public class InverseCountToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> (value is int i && i > 0) ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
/// <summary>다운로드 상태 문자열 → 표시 여부. parameter="done" 이면 완료일 때만 Visible(UC-A11 완료 아이콘).</summary>
public class StatusToVisibilityConverter : IValueConverter
{
public static readonly StatusToVisibilityConverter Done = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is string s && s == "완료" ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
/// <summary>UC-D10 세그먼트 진행도(0~1) → 블록 배경색(진행 중 강조/대기 트랙).</summary>
public class SegmentFillConverter : IValueConverter
{
public static readonly SegmentFillConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is double d && d >= 1.0
? System.Windows.Media.Brushes.DodgerBlue
: value is double p && p > 0
? (System.Windows.Media.Brush)System.Windows.Application.Current.FindResource("AccentBrush")
: (System.Windows.Media.Brush)System.Windows.Application.Current.FindResource("TrackBrush");
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}

View file

@ -1,34 +0,0 @@
using System.Text.Json;
using VideoDownloader.Core.Config;
namespace VideoDownloader.App.Core;
/// <summary>%AppData%/VideoDownloader/config.json 으로 설정 영속화</summary>
public static class ConfigStore
{
private static string Dir => AppPaths.DataDir;
private static string FilePath => Path.Combine(Dir, "config.json");
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
public static AppConfig Load()
{
try
{
if (File.Exists(FilePath))
return JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(FilePath)) ?? new AppConfig();
}
catch { /* 손상 시 기본값 */ }
return new AppConfig();
}
public static void Save(AppConfig config)
{
try
{
Directory.CreateDirectory(Dir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(config, JsonOpts));
}
catch { /* 저장 실패는 무시 */ }
}
}

View file

@ -1,34 +0,0 @@
<Window x:Class="VideoDownloader.App.CrashReportDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="예기치 않은 오류" Height="380" Width="560"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterScreen" ShowInTaskbar="False" Topmost="True">
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,12">
<TextBlock Text="&#xE7BA;" FontFamily="{DynamicResource IconFont}" FontSize="22"
Foreground="{DynamicResource ErrorBrush}" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Text="문제가 발생해 작업을 계속하지 못했습니다" Style="{StaticResource Type.Body}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- UC-N05 오류 상세 펼침 -->
<Expander Grid.Row="1" Header="상세 정보" Foreground="{DynamicResource TextSecondary}">
<TextBox x:Name="DetailBox" Style="{StaticResource DarkTextBox}" IsReadOnly="True"
TextWrapping="NoWrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" FontFamily="{DynamicResource MonoFont}" FontSize="11"/>
</Expander>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Style="{StaticResource GhostButton}" Content="오류 복사" Click="Copy_Click" Margin="0,0,8,0"/>
<Button Style="{StaticResource PrimaryButton}" Content="닫기" Width="90" IsCancel="True" Click="Close_Click"/>
</StackPanel>
</Grid>
</Window>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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;
/// <summary>
/// 탭별 WebView2 생명주기·이벤트 배선 전담(재구성 ④ — MainWindow 갓-클래스 해체).
/// 일반 브라우저 동작(제목/URL/로딩/오디오/검색세션/줌 적용/초기 탐색/뉴윈도우→탭)은
/// 이 서비스가, 앱 고유 기능(미디어 감지/다운로드 라우팅/소셜/기록/주소창 반영)은
/// 이벤트 훅으로 호스트(MainWindow)가 주입한다.
/// </summary>
public sealed class TabViewService
{
private readonly CoreWebView2Environment _env;
private readonly AppConfig _config;
private readonly Panel _host;
private readonly SiteZoomStore _zoomStore;
private readonly Dispatcher _dispatcher;
private readonly Dictionary<TabItem, Task> _init = new();
/// <summary>뉴윈도우(팝업) 요청 시 새 탭을 만들어 반환하는 공급자(호스트가 TabManager 에 연결).</summary>
public Func<Task<TabItem>>? NewTabProvider { get; set; }
/// <summary>탭 CoreWebView2 초기화 완료(호스트가 감지 등 탭별 구독 부착).</summary>
public event Action<TabItem, CoreWebView2>? TabCoreReady;
/// <summary>페이지 내 a[download] 등 다운로드 시작(호스트가 저장 폴더로 라우팅).</summary>
public event Action<CoreWebView2, CoreWebView2DownloadStartingEventArgs>? DownloadStarting;
/// <summary>URL 확정(SourceChanged). 인자: (탭, url).</summary>
public event Action<TabItem, string>? UrlChanged;
/// <summary>문서 타이틀 확정(DocumentTitleChanged). 인자: (탭, title).</summary>
public event Action<TabItem, string>? TitleChanged;
/// <summary>탭 뒤로/앞으로 가능 상태 변화(네비 버튼 갱신용).</summary>
public event Action? HistoryChanged;
/// <summary>우클릭 메뉴 요청 — 호스트가 WPF ContextMenu 로 재구성해 표시(Handled=true).</summary>
public event Action<CoreWebView2, CoreWebView2ContextMenuRequestedEventArgs>? ContextMenuBuild;
/// <summary>T2-7 부스트: URL → 주입할 JS(강제 다크/커스텀 CSS). 빈 문자열이면 미적용.</summary>
public Func<string?, string>? BoostScriptProvider { get; set; }
/// <summary>T2-6 Glance: Alt+클릭한 링크 URL 요청.</summary>
public event Action<CoreWebView2, string>? GlanceRequested;
/// <summary>T2-4 리더 내 낭독(TTS) 요청 — 리더 본문 텍스트.</summary>
public event Action<CoreWebView2, string>? SpeakRequested;
/// <summary>T3-15 주소창 진행 바: 네비게이션 시작(전 탭 집계).</summary>
public event Action? NavigationStarted;
/// <summary>T3-15 주소창 진행 바: 네비게이션 완료(전 탭 집계).</summary>
public event Action? NavigationCompleted;
/// <summary>T3-11 사이트별 권한 정책(출처×종류 기억).</summary>
public PermissionGate Permissions { get; set; } = new();
/// <summary>T3-11 Ask 판정 시 호스트 다이얼로그 — State/Handled/SavesInProfile 설정은 호스트 책임.</summary>
public event Action<CoreWebView2, CoreWebView2PermissionRequestedEventArgs>? PermissionPrompt;
/// <summary>T3-14 추적 방지 블랙리스트(교체 주입 가능).</summary>
public TrackerBlocklist TrackerBlocklist { get; set; } = new();
/// <summary>T3-14 차단 카운터(실드 배지·팝업 데이터).</summary>
public TrackerCounter TrackerCounter { get; } = new();
/// <summary>T3-14 차단 발생 알림(배지 갱신).</summary>
public event Action? TrackerBlocked;
/// <summary>T3-3 InPrivate 탭: 다음에 만들어지는 탭에 적용(1회성).</summary>
public bool NextTabIsPrivate { get; set; }
public TabViewService(CoreWebView2Environment env, AppConfig config, Panel host,
SiteZoomStore zoomStore, Dispatcher dispatcher)
{
_env = env;
_config = config;
_host = host;
_zoomStore = zoomStore;
_dispatcher = dispatcher;
}
public TabItem CreateTabItem() => new();
/// <summary>탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드)</summary>
public void AttachView(TabItem tab)
{
var view = new Wv2.WebView2 { DefaultBackgroundColor = System.Drawing.Color.Black };
tab.View = view;
_host.Children.Add(view);
view.Visibility = tab.IsActive ? Visibility.Visible : Visibility.Collapsed;
_init[tab] = InitCoreAsync(tab);
}
/// <summary>부스트 즉시 재적용(토글 직후). 비동기 fire-and-forget.</summary>
public void ApplyBoostNow(TabItem tab)
{
var cv = tab.Core;
if (cv == null) return;
var js = BoostScriptProvider?.Invoke(cv.Source) ?? "";
if (js.Length > 0 && !tab.IsReader) _ = cv.ExecuteScriptAsync(js);
else if (js.Length == 0) _ = cv.ExecuteScriptAsync("(function(){try{document.getElementById('vd-boost')?.remove();}catch(e){}})();");
}
/// <summary>탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용).</summary>
public async Task WaitTabReady(TabItem tab)
{
if (tab.Core != null) return;
if (_init.TryGetValue(tab, out var t)) { try { await t; } catch { } return; }
var sw = System.Diagnostics.Stopwatch.StartNew();
while (tab.Core == null && sw.ElapsedMilliseconds < 15000)
await Task.Delay(50);
}
/// <summary>탭 제거 시 뷰를 호스트에서 떼고 재오픈 가능 상태로(null) 만든다.</summary>
public void RemoveView(TabItem tab)
{
if (tab.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);
}
/// <summary>활성 탭 검색 상태 표시 요청(FindStatus 텍스트 갱신 — 호스트가 구독).</summary>
public event Action<TabItem>? FindStateChanged;
/// <summary>권한 기준 출처(scheme://host:port) 추출.</summary>
public static string OriginOf(string? uri)
{
if (string.IsNullOrEmpty(uri)) return "";
try { var u = new Uri(uri); return u.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped); }
catch { return uri; }
}
}

View file

@ -1,487 +0,0 @@
<Window x:Class="VideoDownloader.App.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="설정" Height="680" Width="860"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ResizeMode="CanResize" ShowInTaskbar="False"
UseLayoutRounding="True"
KeyboardNavigation.TabNavigation="Cycle"
WindowStyle="None"
AutomationProperties.Name="설정 창">
<WindowChrome.WindowChrome>
<WindowChrome CaptionHeight="0"
ResizeBorderThickness="6"
GlassFrameThickness="1"
CornerRadius="0"
UseAeroCaptionButtons="False"
NonClientFrameEdges="None"/>
</WindowChrome.WindowChrome>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 캡션 (드래그 핸들 + 검색 + 닫기) -->
<Border Grid.Row="0" Background="{DynamicResource PanelBg}"
BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="0,0,0,1"
Padding="22,11,0,11" MouseLeftButtonDown="TitleBar_MouseDown">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource CloseButton}"
Content="&#xE8BB;" ToolTip="닫기" Click="Close_Click"
AutomationProperties.Name="설정 창 닫기"/>
<!-- UC-S01 설정 검색 -->
<TextBox x:Name="SettingsSearchBox" DockPanel.Dock="Right" Style="{StaticResource DarkTextBox}"
Width="220" FontSize="12" Padding="10,5" Margin="12,0,8,0"
TextChanged="SettingsSearchBox_TextChanged" ToolTip="설정 항목 검색"
AutomationProperties.Name="설정 검색"/>
<TextBlock Text="설정" Foreground="{DynamicResource TextPrimary}"
FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
</DockPanel>
</Border>
<Grid Grid.Row="1" Margin="18,14,18,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="170"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- UC-S02 좌측 카테고리 내비게이션 -->
<ListBox x:Name="SettingsNavList" Grid.Column="0" Background="Transparent" BorderThickness="0"
SelectionChanged="SettingsNav_SelectionChanged"
AutomationProperties.Name="설정 카테고리">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="12.5" Padding="10,7" Foreground="{DynamicResource TextSecondary}"/>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" Background="Transparent" CornerRadius="7" Padding="10,7" Margin="0,1">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto" Padding="14,0,4,0">
<StackPanel>
<!-- ==================== 외관 ==================== -->
<StackPanel x:Name="SectionAppearance" Margin="0,0,0,20" Tag="외관">
<TextBlock Text="외관" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<!-- UC-T01/T02/T06 테마: 다크/라이트/시스템/고대비 콤보박스 — 즉시 적용 -->
<TextBlock Text="테마" Style="{StaticResource Type.Caption}"/>
<ComboBox x:Name="ThemeModeBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
Margin="0,4,0,8" SelectionChanged="ThemeModeBox_SelectionChanged"
AutomationProperties.Name="테마 선택" AutomationProperties.HelpText="선택 즉시 적용됩니다">
<ComboBoxItem Content="다크" Tag="dark"/>
<ComboBoxItem Content="라이트" Tag="light"/>
<ComboBoxItem Content="시스템 설정 따르기" Tag="system"/>
<ComboBoxItem Content="고대비" Tag="highcontrast"/>
</ComboBox>
<!-- UC-T07 강조색 프리셋 스와치 + 사용자 지정 -->
<TextBlock Text="강조색" Style="{StaticResource Type.Caption}"/>
<StackPanel Orientation="Horizontal" Margin="0,4,0,4">
<RadioButton x:Name="AccentDefault" Style="{StaticResource DarkRadioButton}" Content="기본(보라)" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
<RadioButton x:Name="AccentBlue" Style="{StaticResource DarkRadioButton}" Content="파랑" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
<RadioButton x:Name="AccentGreen" Style="{StaticResource DarkRadioButton}" Content="초록" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
<RadioButton x:Name="AccentPink" Style="{StaticResource DarkRadioButton}" Content="핑크" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
</StackPanel>
<DockPanel Margin="0,2,0,8">
<TextBlock DockPanel.Dock="Right" Text="#RRGGBB" Style="{StaticResource Type.Caption}" VerticalAlignment="Center" Margin="8,0,0,0"/>
<TextBox x:Name="AccentColorBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,5"
ToolTip="사용자 지정 강조색(#6366F1 등)" AutomationProperties.Name="사용자 지정 강조색"/>
</DockPanel>
<!-- UC-T12 사용자 테마 파일 -->
<TextBlock Text="사용자 테마 파일(.xaml)" Style="{StaticResource Type.Caption}"/>
<DockPanel Margin="0,4,0,0">
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,0,4" Click="BrowseThemeFile"
AutomationProperties.Name="테마 파일 찾아보기"/>
<TextBox x:Name="CustomThemeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
<!-- UC-L02 밀도 -->
<TextBlock Text="밀도" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
<ComboBox x:Name="UiDensityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
AutomationProperties.Name="정보 밀도 선택">
<ComboBoxItem Content="컴팩트" Tag="compact"/>
<ComboBoxItem Content="보통" Tag="normal"/>
<ComboBoxItem Content="여유" Tag="comfortable"/>
</ComboBox>
<!-- UC-L25/A13 배율 -->
<TextBlock Text="UI 크기 · 글자 확대 (100~200%)" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
<StackPanel Orientation="Horizontal">
<Slider x:Name="UiScaleSlider" Minimum="1.0" Maximum="2.0" Width="180" ValueChanged="UiScaleSlider_ValueChanged"
IsSnapToTickEnabled="True" TickFrequency="0.05"
AutomationProperties.Name="UI 크기 배율"/>
<TextBlock x:Name="UiScaleLabel" Text="100%" Style="{StaticResource Type.Mono}" VerticalAlignment="Center" Margin="10,0,0,0"/>
</StackPanel>
</StackPanel>
<!-- ==================== 글꼴 (UC-F13) ==================== -->
<StackPanel x:Name="SectionFont" Margin="0,0,0,20" Tag="글꼴">
<TextBlock Text="글꼴" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<TextBlock Text="기본 폰트" Style="{StaticResource Type.Caption}"/>
<TextBox x:Name="UiFontFamilyBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,8"
ToolTip="예: Pretendard, 나눔고딕" AutomationProperties.Name="기본 폰트"/>
<TextBlock Text="한국어 폴백 폰트" Style="{StaticResource Type.Caption}"/>
<TextBox x:Name="UiFontKoreanFallbackBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,8"
ToolTip="예: Malgun Gothic" AutomationProperties.Name="한국어 폴백 폰트"/>
<!-- UC-F15 폰트 크기 프리셋 -->
<TextBlock Text="폰트 크기" Style="{StaticResource Type.Caption}"/>
<ComboBox x:Name="FontSizeBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
AutomationProperties.Name="폰트 크기 프리셋">
<ComboBoxItem Content="작게 (12)" Tag="12"/>
<ComboBoxItem Content="보통 (13)" Tag="13"/>
<ComboBoxItem Content="크게 (14)" Tag="14"/>
<ComboBoxItem Content="더 크게 (15)" Tag="15"/>
</ComboBox>
<!-- UC-F14 미리보기 -->
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1" CornerRadius="8" Padding="12" Margin="0,10,0,0">
<StackPanel>
<TextBlock Text="미리보기" Style="{StaticResource Type.Caption}"/>
<TextBlock x:Name="FontPreviewText" Text="다운로드 완료 — 영상 123개 · 속도 4.5 MB/s"
FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,4,0,0"/>
</StackPanel>
</Border>
</StackPanel>
<!-- ==================== 일반 ==================== -->
<StackPanel x:Name="SectionGeneral" Margin="0,0,0,20" Tag="일반">
<TextBlock Text="일반" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<TextBlock Text="저장 폴더" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,10">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,4,4" Click="BrowseFolder"/>
<TextBox Name="FolderBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="홈 페이지 (홈 버튼이 이동할 주소)" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,10">
<TextBox Name="HomeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
<!-- UC-S14 UI 언어 -->
<TextBlock Text="UI 언어" Style="{StaticResource Type.Caption}"/>
<ComboBox x:Name="LanguageBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,4,0,10"
AutomationProperties.Name="UI 언어 선택">
<ComboBoxItem Content="한국어" Tag="ko"/>
<ComboBoxItem Content="English" Tag="en"/>
</ComboBox>
<StackPanel Margin="0,0,0,10">
<TextBlock Text="기본 검색 엔진 (주소창에 검색어 입력 시)" Style="{StaticResource Type.Caption}"/>
<ComboBox Name="SearchEngineBox" Style="{StaticResource DarkComboBox}" Margin="0,4,0,4"
SelectionChanged="SearchEngineBox_SelectionChanged"/>
<TextBlock x:Name="SearchEngineHint" Style="{StaticResource Type.Caption}"
Text="주소창에 `g 검색어`, `yt 검색어` 등 키워드를 입력하면 검색 엔진을 즉시 전환할 수 있어요"/>
</StackPanel>
<!-- UC-S15 Windows 시작 시 자동 실행 / UC-S16 트레이 최소화 -->
<WrapPanel Orientation="Horizontal" Margin="0,2,0,0">
<CheckBox Name="AutoStartBox" Style="{StaticResource DarkCheckBox}" Content="Windows 시작 시 자동 실행" Margin="4,4"/>
<CheckBox Name="MinimizeToTrayBox" Style="{StaticResource DarkCheckBox}" Content="닫기 버튼으로 트레이로 최소화" Margin="4,4"/>
</WrapPanel>
</StackPanel>
<!-- ==================== 다운로드 ==================== -->
<StackPanel x:Name="SectionDownload" Margin="0,0,0,20" Tag="다운로드">
<TextBlock Text="다운로드" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="세그먼트 동시 수" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="ConcurrencyBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="최대 동시 항목" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="MaxConcurrentBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
<!-- UC-S11 재시도 횟수 -->
<StackPanel Grid.Column="4">
<TextBlock Text="재시도 횟수" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="RetriesBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
</Grid>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="최소 크기 (MB)" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="MinSizeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
<!-- UC-D22 속도 상한 -->
<StackPanel Grid.Column="2">
<TextBlock Text="속도 상한 (KB/s, 0=무제한)" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="MaxSpeedBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
</Grid>
<WrapPanel Orientation="Horizontal" Margin="0,2,0,10">
<CheckBox Name="AutoClearCompletedBox" Style="{StaticResource DarkCheckBox}" Content="완료 항목 자동 치우기" Margin="4,4"/>
<CheckBox Name="OpenFolderOnCompleteBox" Style="{StaticResource DarkCheckBox}" Content="완료 시 폴더 자동 열기" Margin="4,4"/>
</WrapPanel>
<!-- UC-D21 다운로드 예약 창 -->
<TextBlock Text="다운로드 예약 시간대 (HH:mm-HH:mm, 비우면 항상)" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
<TextBox x:Name="ScheduleBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,5" Width="200" HorizontalAlignment="Left"
ToolTip="예: 02:00-08:00 — 이 시간대에만 다운로드가 실행됩니다" AutomationProperties.Name="다운로드 예약 시간대"/>
<!-- UC-D20 자막 언어 -->
<TextBlock Text="자막 언어 (ko/en/…, 비우면 자막 안 받음)" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
<ComboBox x:Name="SubtitleLangBox" Style="{StaticResource DarkComboBox}" IsEditable="True" Margin="0,4,0,0"
AutomationProperties.Name="자막 언어 선택">
<ComboBoxItem Content="(자막 없음)" Tag=""/>
<ComboBoxItem Content="한국어 (ko)" Tag="ko"/>
<ComboBoxItem Content="English (en)" Tag="en"/>
<ComboBoxItem Content="日本語 (ja)" Tag="ja"/>
</ComboBox>
</StackPanel>
<!-- ==================== 알림 (UC-S17) ==================== -->
<StackPanel x:Name="SectionNotify" Margin="0,0,0,20" Tag="알림">
<TextBlock Text="알림" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Name="NotifySoundBox" Style="{StaticResource DarkCheckBox}" Content="완료 알림 소리" Margin="4,4"/>
<CheckBox Name="DoNotDisturbBox" Style="{StaticResource DarkCheckBox}" Content="방해 금지 (토스트 숨김)" Margin="4,4"/>
<CheckBox Name="PauseOnPowerSaverBox" Style="{StaticResource DarkCheckBox}" Content="절전 모드 진입 시 일시정지" Margin="4,4"/>
</WrapPanel>
<TextBlock Text="토스트 표시 시간 (초)" Style="{StaticResource Type.Caption}"/>
<TextBox x:Name="ToastDurationBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,5" Width="120" HorizontalAlignment="Left"
AutomationProperties.Name="토스트 표시 시간"/>
</StackPanel>
<!-- ==================== 접근성 (UC-A12) ==================== -->
<StackPanel x:Name="SectionAccessibility" Margin="0,0,0,20" Tag="접근성">
<TextBlock Text="접근성" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Name="ReduceMotionBox" Style="{StaticResource DarkCheckBox}" Content="애니메이션 줄이기" Margin="4,4"/>
<CheckBox Name="ShowCaptionsBox" Style="{StaticResource DarkCheckBox}" Content="영상 자막 표시" Margin="4,4"/>
<CheckBox Name="FocusSelectsAddressBox" Style="{StaticResource DarkCheckBox}" Content="주소창 포커스 시 전체 선택" Margin="4,4"/>
<CheckBox Name="PasteAndGoBox" Style="{StaticResource DarkCheckBox}" Content="붙여넣으면 즉시 이동" Margin="4,4"/>
</WrapPanel>
<TextBlock Text="글자 확대 배율 (저시력 지원, 외관 섹션 슬라이더와 연동)" Style="{StaticResource Type.Caption}"/>
<Slider x:Name="UiFontScaleSlider" Minimum="1.0" Maximum="2.0" Width="220" HorizontalAlignment="Left"
IsSnapToTickEnabled="True" TickFrequency="0.1" ValueChanged="UiFontScaleSlider_ValueChanged"
AutomationProperties.Name="글자 확대 배율"/>
</StackPanel>
<!-- ==================== 브라우저 ==================== -->
<StackPanel x:Name="SectionBrowser" Margin="0,0,0,20" Tag="브라우저">
<TextBlock Text="브라우저" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Name="TabWheelSwitchBox" Style="{StaticResource DarkCheckBox}" Content="휠로 탭 전환" Margin="4,4"/>
<CheckBox Name="ConfirmCloseMultipleTabsBox" Style="{StaticResource DarkCheckBox}" Content="여러 탭 닫기 전 확인" Margin="4,4"/>
<CheckBox Name="BookmarkBarVisibleBox" Style="{StaticResource DarkCheckBox}" Content="북마크바 표시" Margin="4,4"/>
</WrapPanel>
<!-- UC-L18 탭 스트립 위치 -->
<TextBlock Text="탭 스트립 위치" Style="{StaticResource Type.Caption}"/>
<ComboBox x:Name="TabStripPositionBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,4,0,8"
AutomationProperties.Name="탭 스트립 위치 선택">
<ComboBoxItem Content="위 (기본)" Tag="top"/>
<ComboBoxItem Content="아래" Tag="bottom"/>
<ComboBoxItem Content="왼쪽 (세로 탭)" Tag="left"/>
</ComboBox>
<TextBlock Text="반응할 형식" Style="{StaticResource Type.Caption}" Margin="0,4,0,6"/>
<WrapPanel Orientation="Horizontal">
<CheckBox Name="FmtHls" Style="{StaticResource DarkCheckBox}" Content="HLS (m3u8)" Margin="4,5"/>
<CheckBox Name="FmtDash" Style="{StaticResource DarkCheckBox}" Content="DASH (.mpd)" Margin="4,5"/>
<CheckBox Name="FmtMp4" Style="{StaticResource DarkCheckBox}" Content="MP4" Margin="4,5"/>
<CheckBox Name="FmtWebM" Style="{StaticResource DarkCheckBox}" Content="WebM" Margin="4,5"/>
<CheckBox Name="FmtMkv" Style="{StaticResource DarkCheckBox}" Content="MKV" Margin="4,5"/>
<CheckBox Name="FmtM4v" Style="{StaticResource DarkCheckBox}" Content="M4V" Margin="4,5"/>
<CheckBox Name="FmtMov" Style="{StaticResource DarkCheckBox}" Content="MOV" Margin="4,5"/>
<CheckBox Name="FmtAvi" Style="{StaticResource DarkCheckBox}" Content="AVI" Margin="4,5"/>
<CheckBox Name="FmtAudio" Style="{StaticResource DarkCheckBox}" Content="오디오 (m4a/mp3/ogg...)" Margin="4,5"/>
</WrapPanel>
<TextBlock Text="브라우저 데이터" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
<TextBlock x:Name="ProfileModeText" Text="" Style="{StaticResource Type.Caption}" Margin="0,0,0,6"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,0">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="삭제" Padding="12,4" Margin="8,4,4,4"
ToolTip="쿠키·localStorage·캐시 삭제 (현재 프로필)" Click="ClearData_Click"/>
<TextBox x:Name="ProfilePathBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent" IsReadOnly="True"/>
</DockPanel>
</Border>
</StackPanel>
<!-- ==================== 고급 (UC-S07 접힘) ==================== -->
<Expander x:Name="AdvancedSection" Header="고급" FontSize="14" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,20"
ExpandDirection="Down" IsExpanded="False" Tag="고급">
<StackPanel Margin="0,10,0,0">
<TextBlock Text="ffmpeg 경로" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,4">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,4,4" Click="BrowseFfmpeg"/>
<TextBox Name="FfmpegBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="비워두면 시스템 PATH 의 ffmpeg 사용 (HLS→MP4 리먹스)" Style="{StaticResource Type.Caption}" Margin="0,0,0,10"/>
<!-- UC-S12 User-Agent / UC-S13 프록시 -->
<TextBlock Text="User-Agent" Style="{StaticResource Type.Caption}"/>
<TextBox x:Name="UserAgentBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,10"
AutomationProperties.Name="User-Agent"/>
<TextBlock Text="프록시 서버 (http://host:port)" Style="{StaticResource Type.Caption}"/>
<TextBox x:Name="ProxyBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,4"
AutomationProperties.Name="프록시 서버"/>
<TextBlock Text="프록시·개인정보 보호 모드 변경은 앱 다시 시작 후 적용됩니다" Style="{StaticResource Type.Caption}"/>
<TextBlock Text="소셜 영상 (YouTube·Instagram·X)" Style="{StaticResource Type.Caption}" Margin="0,10,0,6"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,4">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="재설치" Padding="12,4" Margin="4,4,8,4" ToolTip="yt-dlp 최신 버전으로 다시 다운로드" Click="ReinstallYtDlp_Click"/>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="4,4,4,4" Click="BrowseYtDlp"/>
<TextBox Name="YtDlpBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="비워두면 자동/PATH 의 yt-dlp 사용" Style="{StaticResource Type.Caption}" Margin="0,0,0,8"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,10">
<CheckBox Name="UseCookiesBox" Style="{StaticResource DarkCheckBox}" Content="로그인 필요 영상을 위해 브라우저 쿠키 사용" Margin="4,4"/>
<CheckBox Name="AutoInstallBox" Style="{StaticResource DarkCheckBox}" Content="yt-dlp 가 없으면 자동 다운로드" Margin="4,4"/>
<CheckBox Name="AutoUpdateBox" Style="{StaticResource DarkCheckBox}" Content="YouTube 차단 시 yt-dlp 자동 갱신 후 재시도" Margin="4,4"/>
<CheckBox Name="AutoInstallDenoBox" Style="{StaticResource DarkCheckBox}" Content="Deno(JS 런타임) 자동 설치 — YouTube 차단 해제용" Margin="4,4"/>
</WrapPanel>
<TextBlock Text="기본 화질" Style="{StaticResource Type.Caption}"/>
<ComboBox Name="DefaultQualityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,4,0,10">
<ComboBoxItem Content="최고 화질 (MP4)" Tag="best"/>
<ComboBoxItem Content="1080p" Tag="1080"/>
<ComboBoxItem Content="720p" Tag="720"/>
<ComboBoxItem Content="480p" Tag="480"/>
<ComboBoxItem Content="360p" Tag="360"/>
<ComboBoxItem Content="오디오만 (MP3)" Tag="audio"/>
</ComboBox>
<TextBlock Text="YouTube 클라이언트 (고급)" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="YoutubeClientsBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
<TextBlock Text="비워두면 yt-dlp 기본값 사용(권장). 봇 차단/PO 토큰 오류 시 예: tv,android_vr,web" Style="{StaticResource Type.Caption}" Margin="0,4,0,0"/>
</StackPanel>
</Expander>
<!-- ==================== 단축키 (UC-K01) ==================== -->
<StackPanel x:Name="SectionShortcuts" Margin="0,0,0,20" Tag="단축키">
<TextBlock Text="단축키" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,6"/>
<TextBlock Text="주요 단축키는 앱 안 Ctrl+K 명령 팔레트와 상태바 ? 버튼(단축키 목록)에서 확인하고, 충돌 없이 직접 바꾸려면 JSON 파일로 관리합니다." Style="{StaticResource Type.Caption}"/>
</StackPanel>
<!-- ==================== 실험적 (UC-S18) ==================== -->
<StackPanel x:Name="SectionExperimental" Margin="0,0,0,20" Tag="실험적">
<TextBlock Text="실험적" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,6"/>
<WrapPanel Orientation="Horizontal">
<CheckBox Name="RestoreTabsSuspendedBox" Style="{StaticResource DarkCheckBox}" Content="세션 복원을 잠자기 상태로(빠른 시작)" Margin="4,4"/>
<CheckBox Name="TabTitleMultilineBox" Style="{StaticResource DarkCheckBox}" Content="탭 제목 두 줄 표시" Margin="4,4"/>
</WrapPanel>
</StackPanel>
<!-- ==================== 업데이트·정보 ==================== -->
<StackPanel x:Name="SectionAbout" Margin="0,0,0,10" Tag="정보">
<TextBlock Text="앱 업데이트" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,6"/>
<TextBlock Text="GitHub Releases 최신 태그와 비교합니다 (owner/repo 형식)" Style="{StaticResource Type.Caption}" Margin="0,0,0,6"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,4">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="지금 확인" Padding="12,4" Margin="8,4,4,4"
ToolTip="GitHub Releases 에서 새 버전 확인" Click="CheckUpdate_Click"/>
<TextBox Name="UpdateRepoBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock x:Name="UpdateResultText" Text="" Style="{StaticResource Type.Caption}" Margin="0,0,0,14"/>
<!-- UC-S20 버전 정보 -->
<TextBlock Text="버전" Style="{StaticResource Type.Caption}"/>
<TextBlock x:Name="VersionText" Text="-" FontSize="12" Foreground="{DynamicResource TextSecondary}" Margin="0,2,0,0"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- UC-S03 다시 시작 필요 배너 -->
<Border x:Name="RestartBanner" Grid.Row="1" VerticalAlignment="Bottom" Margin="18,0,18,8"
Background="{DynamicResource AccentFadeBrush}" BorderBrush="{DynamicResource AccentBrush}" BorderThickness="1"
CornerRadius="8" Padding="12,7" Visibility="Collapsed">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}" Content="지금 다시 시작" Click="RestartNow_Click"/>
<TextBlock Text="일부 변경(프록시·개인정보 보호 모드)은 앱을 다시 시작하면 적용됩니다."
FontSize="11.5" Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center"/>
</DockPanel>
</Border>
<!-- 하단 액션 바 -->
<Border Grid.Row="2" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="22,12">
<DockPanel>
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal">
<!-- UC-S04 기본값 복원 · UC-S05/S06 설정 내보내기/가져오기 -->
<Button x:Name="ResetDefaultsButton" Style="{StaticResource GhostButton}" Content="기본값으로 되돌리기" Click="ResetDefaults_Click" Margin="0,0,8,0"
AutomationProperties.Name="기본값 복원"/>
<Button x:Name="ExportSettingsButton" Style="{StaticResource GhostButton}" Content="내보내기" Click="ExportSettings_Click" Margin="0,0,8,0"
AutomationProperties.Name="설정 내보내기"/>
<Button x:Name="ImportSettingsButton" Style="{StaticResource GhostButton}" Content="가져오기" Click="ImportSettings_Click" Margin="0,0,8,0"
AutomationProperties.Name="설정 가져오기"/>
</StackPanel>
<!-- UC-S19 검증 힌트 -->
<TextBlock x:Name="ValidationHint" Text="" Foreground="{DynamicResource ErrorBrush}" FontSize="11.5"
VerticalAlignment="Center" Margin="12,0,12,0" TextTrimming="CharacterEllipsis"
AutomationProperties.LiveSetting="Assertive"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource GhostButton}" Content="취소" Width="90" Margin="0,0,8,0" IsCancel="True" Click="Cancel_Click"/>
<Button Style="{StaticResource PrimaryButton}" Content="확인" Width="90" Click="Ok_Click" IsDefault="True"
AutomationProperties.Name="설정 저장"/>
</StackPanel>
</DockPanel>
</Border>
</Grid>
</Window>

View file

@ -1,580 +0,0 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<!-- 폰트 (테마 무관) — 한국어 가독성 프리셋 Pretendard(UC-F12) 우선 + 폴백 체인.
브러시/폰트/토큰 참조는 DynamicResource 로 연결해 테마·폰트·설정 변경이 즉시 반영된다(UC-T04). -->
<FontFamily x:Key="UiFont">Pretendard, Segoe UI Variable Text, Segoe UI, Malgun Gothic</FontFamily>
<FontFamily x:Key="IconFont">Segoe Fluent Icons</FontFamily>
<FontFamily x:Key="MonoFont">Cascadia Mono, Consolas, Courier New</FontFamily>
<!-- ===== 디자인 토큰 (§B 타이포그래피 · §C 레이아웃 · UC-L22 지속시간) ===== -->
<sys:Double x:Key="FontSize.Base">13</sys:Double>
<sys:Double x:Key="FontSize.Small">11.5</sys:Double>
<sys:Double x:Key="FontSize.Large">16</sys:Double>
<sys:Double x:Key="FontSize.XLarge">22</sys:Double>
<sys:Double x:Key="IconSize.Small">14</sys:Double>
<sys:Double x:Key="Type.LineHeight">1.5</sys:Double>
<Thickness x:Key="Spacing.S">4,4,4,4</Thickness>
<Thickness x:Key="Spacing.M">8,8,8,8</Thickness>
<Thickness x:Key="Spacing.L">16,16,16,16</Thickness>
<CornerRadius x:Key="CornerRadius.Card">12</CornerRadius>
<Duration x:Key="Duration.Fast">0:0:0.15</Duration>
<Duration x:Key="Duration.Normal">0:0:0.3</Duration>
<!-- UC-A16/L21: ReduceMotion 켜면 App 이 Duration 토큰들을 0으로 교체해 애니메이션을 즉시 완결시킨다. -->
<sys:Boolean x:Key="ReduceMotion">False</sys:Boolean>
<!-- ===== 그림자 토큰 (UC-T23/T24) ===== -->
<DropShadowEffect x:Key="TooltipShadow" Color="#000000" Opacity="0.45" BlurRadius="14" ShadowDepth="3" Direction="270"/>
<DropShadowEffect x:Key="PopupShadow" Color="#000000" Opacity="0.4" BlurRadius="20" ShadowDepth="5" Direction="270"/>
<!-- ===== 스크롤바 썸 전용 색 (UC-T22) ===== -->
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#555C70"/>
<!-- ===== 탭 그룹 색 팔레트 (UC-B17) — MainWindow 탭 컨텍스트 메뉴가 사용 ===== -->
<x:Array x:Key="GroupPalette" Type="SolidColorBrush">
<SolidColorBrush Color="#E74C3C"/>
<SolidColorBrush Color="#3498DB"/>
<SolidColorBrush Color="#2ECC71"/>
<SolidColorBrush Color="#F39C12"/>
<SolidColorBrush Color="#9B59B6"/>
<SolidColorBrush Color="#E91E8C"/>
</x:Array>
<!-- 암시 스타일: FontFamily 를 여기서 강제하면 버튼 ContentPresenter 의 자동 TextBlock 이
IconFont 상속을 잃어 모든 아이콘 글리프가 tofu(□)가 된다(2026-08-18 실결함).
UI 폰트 통일은 각 창 루트의 FontFamily="{DynamicResource UiFont}" 상속으로 해결한다. -->
<Style TargetType="TextBlock">
<Setter Property="SnapsToDevicePixels" Value="True"/>
</Style>
<Style TargetType="ToolTip">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Background" Value="{DynamicResource CardBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Padding" Value="10,6"/>
<!-- UC-T23 툴팁 그림자 -->
<Setter Property="Effect" Value="{StaticResource TooltipShadow}"/>
</Style>
<!-- UC-A05 키보드 포커스 링 — 모든 인터랙티브 스타일이 참조 -->
<Style x:Key="FocusVisual">
<Setter Property="Control.Margin" Value="2"/>
<Setter Property="Control.Template">
<Setter.Value>
<ControlTemplate>
<Rectangle Margin="2" StrokeThickness="1.5" Stroke="{DynamicResource FocusAdornerBrush}"
StrokeDashArray="4 2" SnapsToDevicePixels="True"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 공용 텍스트 스타일 (§B) ===== -->
<Style x:Key="Type.H1" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="{DynamicResource FontSize.XLarge}"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="LineHeight" Value="{DynamicResource Type.LineHeight}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
</Style>
<Style x:Key="Type.Body" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="{DynamicResource FontSize.Base}"/>
<Setter Property="LineHeight" Value="{DynamicResource Type.LineHeight}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
<Style x:Key="Type.Caption" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="{DynamicResource FontSize.Small}"/>
<Setter Property="Foreground" Value="{DynamicResource TextMuted}"/>
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
<!-- UC-F11 등폭 숫자 — 진행률·속도 표기에 사용 -->
<Style x:Key="Type.Mono" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
<Setter Property="FontSize" Value="{DynamicResource FontSize.Small}"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
</Style>
<!-- UC-F19 탭 제목 -->
<Style x:Key="TabTitleStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
</Style>
<!-- UC-F20 상태바 텍스트 -->
<Style x:Key="StatusTextStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="11.5"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
</Style>
<!-- UC-N17 빈 상태 안내 -->
<Style x:Key="EmptyStateStyle" TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Padding" Value="16"/>
<Setter Property="Margin" Value="8"/>
</Style>
<!-- ===== Primary 버튼 ===== -->
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="RenderTransformOrigin" Value="0.5,0.5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{DynamicResource AccentGradient}" CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Opacity" Value="0.9"/></Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bd" Property="RenderTransform"><Setter.Value><ScaleTransform ScaleX="0.97" ScaleY="0.97"/></Setter.Value></Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="bd" Property="Opacity" Value="0.35"/>
<Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== Ghost 버튼 ===== -->
<Style x:Key="GhostButton" TargetType="Button">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="12,7"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="RenderTransformOrigin" Value="0.5,0.5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bd" Property="RenderTransform"><Setter.Value><ScaleTransform ScaleX="0.97" ScaleY="0.97"/></Setter.Value></Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="bd" Property="Opacity" Value="0.4"/>
<Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 아이콘 전용 버튼 (Fluent) ===== -->
<Style x:Key="IconButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Padding" Value="10,0"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
</Style>
<!-- ===== Fluent 아이콘 TextBlock ===== -->
<Style x:Key="FluentIcon" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- ===== 윈도우 캡션 버튼 (최소화/최대화/복원) — 고정폭, 서로 밀착(네이티브 Fluent) ===== -->
<Style x:Key="CaptionButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
<Setter Property="Cursor" Value="Arrow"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="Transparent">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource BorderBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 닫기 버튼 — 빨간 호버 (Fluent 표준) ===== -->
<Style x:Key="CloseButton" TargetType="Button" BasedOn="{StaticResource CaptionButton}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="Transparent">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="#E81123"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 작은 카드 액션 버튼 ===== -->
<Style x:Key="CardActionButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
<Setter Property="Padding" Value="10,5"/>
<Setter Property="FontSize" Value="11"/>
</Style>
<!-- ===== 투명 행 버튼 ===== -->
<Style x:Key="FlatButton" TargetType="Button">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="Transparent" CornerRadius="7" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 주소창 자동완성 제안 목록 ===== -->
<Style x:Key="SuggestList" TargetType="ListBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="MaxHeight" Value="360"/>
</Style>
<Style x:Key="SuggestItem" TargetType="ListBoxItem">
<Setter Property="Padding" Value="12,7"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" Background="Transparent" CornerRadius="7" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/></Trigger>
<Trigger Property="IsSelected" Value="True"><Setter TargetName="bd" Property="Background" Value="{DynamicResource GlassBg}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== TextBox ===== -->
<Style x:Key="DarkTextBox" TargetType="TextBox">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Background" Value="{DynamicResource GlassBg}"/>
<Setter Property="CaretBrush" Value="{DynamicResource TextPrimary}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource SelectionBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1" CornerRadius="8">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
Focusable="False" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/></Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== ProgressBar (UC-T10: 하드코딩 색 제거 — 테마 강조 리소스만 참조) ===== -->
<Style x:Key="AccentProgressBar" TargetType="ProgressBar">
<Setter Property="Height" Value="8"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Grid>
<!-- PART_Track 이 있어야 ProgressBar 가 PART_Indicator 폭을(Value/Maximum 비율) 계산한다.
없으면 바가 절대 채워지지 않는 치명 결함이었음. -->
<Border x:Name="PART_Track" CornerRadius="4" Background="{DynamicResource TrackBrush}"/>
<Border x:Name="PART_Indicator" CornerRadius="4" HorizontalAlignment="Left"
Background="{DynamicResource AccentGradient}"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 부정확(길이 미상) 진행률 바 ===== -->
<Style x:Key="IndeterminateBar" TargetType="ProgressBar">
<Setter Property="Height" Value="8"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Grid ClipToBounds="True">
<Border CornerRadius="4" Background="{DynamicResource TrackBrush}"/>
<Border x:Name="shimmer" Width="48" CornerRadius="4" HorizontalAlignment="Left" RenderTransformOrigin="0,0"
Background="{DynamicResource AccentBrush}" Opacity="0.85">
<Border.RenderTransform><TranslateTransform X="-50"/></Border.RenderTransform>
</Border>
</Grid>
<ControlTemplate.Triggers>
<EventTrigger RoutedEvent="FrameworkElement.Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="shimmer"
Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.X)"
From="-50" To="470" Duration="{DynamicResource Duration.Normal}" RepeatBehavior="Forever"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== CheckBox ===== -->
<Style x:Key="DarkCheckBox" TargetType="CheckBox">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" Background="Transparent">
<Border x:Name="box" Width="18" Height="18" CornerRadius="5" Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1.5">
<Path x:Name="check" Width="10" Height="10" Stretch="Uniform" Data="M 0 5 L 4 9 L 10 0" Stroke="White" StrokeThickness="2" HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed"/>
</Border>
<ContentPresenter VerticalAlignment="Center" Margin="8,0,0,0"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="box" Property="Background" Value="{DynamicResource AccentGradient}"/>
<Setter TargetName="box" Property="BorderBrush" Value="Transparent"/>
<Setter TargetName="check" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/></Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== RadioButton(DarkCheckBox 와 같은 모양, 라디오 그룹용) ===== -->
<Style x:Key="DarkRadioButton" TargetType="RadioButton">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<StackPanel Orientation="Horizontal" Background="Transparent">
<Border x:Name="box" Width="18" Height="18" CornerRadius="9" Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1.5">
<Ellipse x:Name="dot" Width="8" Height="8" Fill="White" HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed"/>
</Border>
<ContentPresenter VerticalAlignment="Center" Margin="8,0,0,0"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="box" Property="Background" Value="{DynamicResource AccentGradient}"/>
<Setter TargetName="box" Property="BorderBrush" Value="Transparent"/>
<Setter TargetName="dot" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/></Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== ComboBox ===== -->
<Style x:Key="DarkComboBox" TargetType="ComboBox">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Background" Value="{DynamicResource GlassBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Padding" Value="10,5"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="ibd" Background="Transparent" CornerRadius="5" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ibd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ibd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="MainBorder" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8"/>
<Grid Margin="{TemplateBinding Padding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ToggleButton x:Name="ToggleButton" Grid.ColumnSpan="2" Focusable="False" ClickMode="Press"
Background="Transparent"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="{TemplateBinding Background}"/>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter x:Name="ContentSite" Grid.Column="0" IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
VerticalAlignment="Center" HorizontalAlignment="Stretch"
TextBlock.Foreground="{TemplateBinding Foreground}"
TextBlock.FontSize="{TemplateBinding FontSize}"/>
<TextBlock x:Name="arrow" Grid.Column="1" Text="&#xE70D;" Style="{StaticResource FluentIcon}"
FontSize="9" Foreground="{DynamicResource TextMuted}"
VerticalAlignment="Center" Margin="10,0,0,0" IsHitTestVisible="False"/>
</Grid>
<!-- UC-A14 팝업 내 방향키/Tab 탐색 -->
<Popup x:Name="Popup" Placement="Bottom" IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True" Focusable="False" PopupAnimation="Slide">
<Border x:Name="DropDown" Background="{DynamicResource CardBg}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="4"
MinWidth="{TemplateBinding ActualWidth}" MaxHeight="340"
Effect="{StaticResource PopupShadow}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsDropDownOpen" Value="True">
<Setter TargetName="MainBorder" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 얇은 스크롤바 (UC-T22 썸 색 토큰) ===== -->
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="8"/>
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="Orientation" Value="Vertical">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid>
<Track Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border CornerRadius="4" Background="{DynamicResource ScrollBarThumbBrush}"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>

View file

@ -1,63 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:CompileBindings="False"
mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="520"
x:Class="VideoDownloader.Avalonia.MainWindow"
Title="VideoDownloader (Avalonia · 크로스플랫폼 셸)"
Width="640" Height="520"
WindowStartupLocation="CenterScreen">
<!-- DataContext 는 MainWindow 생성자에서 코드비하인드로 설정(MainViewModel). -->
<Border Background="#1B1C24" Padding="24">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*">
<!-- 헤더 -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="VideoDownloader" FontSize="22" FontWeight="SemiBold" Foreground="#E6E6EC"/>
<TextBlock Text="Avalonia 크로스플랫폼 셸 — Core 엔진 재사용 (HLS · DASH · yt-dlp 소셜 · 직링크)"
FontSize="12" Foreground="#8A8B95" Margin="0,4,0,0"/>
</StackPanel>
<!-- URL 입력 -->
<TextBox Grid.Row="1" x:Name="UrlBox"
Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="영상 URL 붙여넣기 (YouTube/Instagram/X · m3u8 · mpd · mp4)"
Background="#26272F" Foreground="#E6E6EC"
BorderBrush="#3A3B45" BorderThickness="1" Padding="10,8" CornerRadius="8"/>
<!-- 버튼 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,12,0,12" Spacing="8">
<Button Content="받기" Command="{Binding DownloadCommand}"
Background="#5B8DEF" Foreground="White" Padding="24,8" CornerRadius="8" FontWeight="SemiBold"/>
<Button Content="취소" Command="{Binding CancelCommand}"
Background="#3A3B45" Foreground="#E6E6EC" Padding="18,8" CornerRadius="8"/>
</StackPanel>
<!-- 진행률 -->
<ProgressBar Grid.Row="3" Value="{Binding Progress}" Maximum="100"
MinHeight="6" CornerRadius="3" Foreground="#5B8DEF" Background="#26272F" Margin="0,0,0,8"/>
<!-- 상태/결과 -->
<StackPanel Grid.Row="4" Margin="0,0,0,12">
<TextBlock Text="{Binding Status}" Foreground="#B9BAC4" FontSize="13"/>
<TextBlock Text="{Binding Result}" Foreground="#7FD1A0" FontSize="13" TextWrapping="Wrap" Margin="0,6,0,0"/>
</StackPanel>
<!-- 로그 -->
<Border Grid.Row="5" Background="#202127" CornerRadius="8" Padding="12">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" Foreground="#8A8B95" FontSize="11" Margin="0,2"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Grid>
</Border>
</Window>

View file

@ -1,78 +0,0 @@
namespace VideoDownloader.Browser;
public enum PermissionVerdict { Ask, Allow, Deny }
/// <summary>
/// T3-11 사이트별 권한 정책 — 출처×종류 단위 기억. WebView2 PermissionRequested 에서
/// Ask 만 프롬프트로 올리고 Allow/Deny 는 즉시 응답. "항상" 선택은 네이티브
/// Profile.SetPermissionStateAsync 로도 영속(세션 밖 저장).
/// </summary>
public sealed class PermissionGate
{
/// <summary>프롬프트 없이 거절: 센서류(피싱 표면 축소).</summary>
private static readonly HashSet<string> AutoDeny = new(StringComparer.OrdinalIgnoreCase) { "OtherSensors" };
/// <summary>프롬프트 없이 허용: 연속 다운로드(다운로더 앱 본업).</summary>
private static readonly HashSet<string> AutoAllow = new(StringComparer.OrdinalIgnoreCase) { "MultipleAutomaticDownloads" };
private readonly Dictionary<(string origin, string kind), PermissionVerdict> _saved = new();
public PermissionVerdict Decide(string kind, string origin)
{
if (AutoDeny.Contains(kind)) return PermissionVerdict.Deny;
if (AutoAllow.Contains(kind)) return PermissionVerdict.Allow;
return _saved.GetValueOrDefault((origin, kind), PermissionVerdict.Ask);
}
public void Remember(string kind, string origin, PermissionVerdict verdict)
=> _saved[(origin, kind)] = verdict;
}
/// <summary>T3-14: 추적 스크립트 블랙리스트 — 호스트 또는 서브도메인 매칭.</summary>
public sealed class TrackerBlocklist
{
/// <summary>주요 광고/분석 도메인(EasyList 공통 항목 발췌). 생성자 주입으로 교체 가능(테스트).</summary>
public static readonly string[] DefaultDomains =
{
"google-analytics.com", "googletagmanager.com", "doubleclick.net", "googlesyndication.com",
"googleadservices.com", "scorecardresearch.com", "hotjar.com", "mixpanel.com", "segment.io",
"facebook.net", "connect.facebook.net", "amplitude.com", "branch.io", "appsflyer.com",
"criteo.com", "taboola.com", "outbrain.com", "adnxs.com", "pubmatic.com", "rubiconproject.com",
};
private readonly HashSet<string> _domains;
public TrackerBlocklist() : this(DefaultDomains) { }
public TrackerBlocklist(IEnumerable<string> domains) =>
_domains = new HashSet<string>(domains, StringComparer.OrdinalIgnoreCase);
public bool Matches(Uri url)
{
var host = url.Host;
foreach (var d in _domains)
if (host.Equals(d, StringComparison.OrdinalIgnoreCase) ||
host.EndsWith("." + d, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
/// <summary>T3-14: 차단 카운터 — 총계/도메인별 집계, 변경 통지(실드 배지 갱신용).</summary>
public sealed class TrackerCounter
{
private int _total;
public int Total => Volatile.Read(ref _total);
public Dictionary<string, int> ByDomain { get; } = new(StringComparer.OrdinalIgnoreCase);
public event Action? Changed;
public void Record(string url)
{
var host = new Uri(url).Host;
var baseDomain = host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host;
Interlocked.Increment(ref _total);
lock (ByDomain)
{
ByDomain[baseDomain] = ByDomain.GetValueOrDefault(baseDomain) + 1;
}
Changed?.Invoke();
}
}

View file

@ -1,242 +0,0 @@
using System.Collections.ObjectModel;
namespace VideoDownloader.Browser.Tabs;
/// <summary>
/// 탭 관리 순수 로직(Phase A). WebView2 생성은 주입된 팩토리/어태처가 담당해
/// 단위 테스트에서 UI 없이 검증 가능. 닫힌 탭 스택(25) · 핀 좌측 정렬 · 세션 캡처/복원 포함.
/// </summary>
public sealed class TabManager
{
private const int MaxClosed = 25;
private readonly Func<TabItem> _createTab;
private readonly Action<TabItem>? _attachView;
private readonly List<(TabItem Tab, int Index)> _closed = new();
public ObservableCollection<TabItem> Tabs { get; } = new();
public TabItem? ActiveTab { get; private set; }
/// <summary>UC-B11 탭 최대 개수(0=무제한). 도달 시 새 탭 대신 마지막 탭을 활성화해 반환한다.</summary>
public int MaxTabs { get; set; }
public event Action<TabItem>? ActiveChanged;
public event Action<TabItem>? TabAdded;
public event Action<TabItem>? TabRemoved;
/// <summary>닫힌 탭(LIFO — 최신 우선). Ctrl+Shift+T 복원 대상.</summary>
public IReadOnlyList<TabItem> ClosedStack => _closed.Select(e => e.Tab).ToList();
public TabManager(Func<TabItem> createTab, Action<TabItem>? attachView = null)
{
_createTab = createTab;
_attachView = attachView;
}
public TabItem NewTab(string? url = null, bool activate = true)
{
// UC-B11 최대 탭 수 제한 — 꽉 차면 마지막 탭 활성화로 대체(새 탭 무한 증식 방지)
if (MaxTabs > 0 && Tabs.Count >= MaxTabs)
{
var last = Tabs[^1];
Activate(last);
return last;
}
var tab = _createTab();
if (!string.IsNullOrEmpty(url)) tab.Url = url;
_attachView?.Invoke(tab);
Tabs.Add(tab); // 신규 탭은 핀 여부와 무관하게 끝에(핀 상태로 만들면 SetPinned 이동)
TabAdded?.Invoke(tab);
if (activate) Activate(tab);
else tab.IsActive = false;
return tab;
}
public void Activate(TabItem tab)
{
if (!Tabs.Contains(tab)) return;
foreach (var t in Tabs) t.IsActive = ReferenceEquals(t, tab);
ActiveTab = tab;
tab.LastActivated = DateTime.UtcNow;
ActiveChanged?.Invoke(tab);
}
/// <summary>탭 닫기. 활성 탭이면 오른쪽 이웃(없으면 왼쪽) 활성화. 마지막 탭이면 새 빈 탭으로 대체.
/// UC-B01: 잠긴 탭(IsLocked)은 닫기 요청을 거부한다.</summary>
public bool CloseTab(TabItem tab)
{
if (tab.IsLocked) return false;
var index = Tabs.IndexOf(tab);
if (index < 0) return false;
_closed.Insert(0, (tab, index));
while (_closed.Count > MaxClosed) _closed.RemoveAt(_closed.Count - 1);
tab.ClosedIndex = index;
var wasActive = ReferenceEquals(ActiveTab, tab);
Tabs.RemoveAt(index);
TabRemoved?.Invoke(tab);
if (Tabs.Count == 0)
{
NewTab(); // 창 유지 — 마지막 탭은 새 빈 탭으로 대체
}
else if (wasActive)
{
var next = Tabs[Math.Min(index, Tabs.Count - 1)]; // 오른쪽 우선, 끝이면 왼쪽
Activate(next);
}
return true;
}
/// <summary>Ctrl+Shift+T: 닫힌 탭을 닫힌 위치에 복원.</summary>
public TabItem? ReopenClosedTab()
{
if (_closed.Count == 0) return null;
var (tab, index) = _closed[0];
_closed.RemoveAt(0);
if (tab.View == null) _attachView?.Invoke(tab); // 뷰가 정리된 경우 재생성
Tabs.Insert(Math.Min(index, Tabs.Count), tab);
TabAdded?.Invoke(tab);
Activate(tab);
return tab;
}
public void ActivateNext()
{
if (ActiveTab == null || Tabs.Count < 2) return;
var i = Tabs.IndexOf(ActiveTab);
Activate(Tabs[(i + 1) % Tabs.Count]);
}
public void ActivatePrevious()
{
if (ActiveTab == null || Tabs.Count < 2) return;
var i = Tabs.IndexOf(ActiveTab);
Activate(Tabs[(i - 1 + Tabs.Count) % Tabs.Count]);
}
public void ActivateAt(int index)
{
if (index >= 0 && index < Tabs.Count) Activate(Tabs[index]);
}
public void ActivateLast() => ActivateAt(Tabs.Count - 1);
public void Move(TabItem tab, int newIndex)
{
var old = Tabs.IndexOf(tab);
if (old < 0) return;
newIndex = Math.Clamp(newIndex, 0, Tabs.Count - 1);
if (newIndex == old) return;
Tabs.Move(old, newIndex);
}
/// <summary>핀 설정. 핀 탭은 좌측 끝에 정렬(Chrome 동작).</summary>
public void SetPinned(TabItem tab, bool pinned)
{
if (!Tabs.Contains(tab)) return;
tab.IsPinned = pinned;
if (pinned)
{
var target = Tabs.Count(t => t.IsPinned) - 1; // 자신 포함 핀 탭 수 - 1
Move(tab, Math.Max(0, target));
}
else
{
var target = Tabs.Count(t => t.IsPinned); // 핀 탭 블록 바로 뒤
Move(tab, target);
}
}
public SessionSnapshot CaptureSession()
{
var active = ActiveTab == null ? 0 : Math.Max(0, Tabs.IndexOf(ActiveTab));
return new SessionSnapshot(
Tabs.Select(t => t.Url).ToList(),
active,
Tabs.Select(t => t.IsPinned).ToList());
}
public void RestoreSession(SessionSnapshot snap)
{
foreach (var t in Tabs.ToList())
{
Tabs.Remove(t);
TabRemoved?.Invoke(t);
}
for (var i = 0; i < snap.Urls.Count; i++)
{
var tab = NewTab(snap.Urls[i], activate: false);
if (i < snap.Pinned.Count && snap.Pinned[i]) tab.IsPinned = true; // 순서 유지(핀 우선 저장됨)
}
ActivateAt(Math.Clamp(snap.ActiveIndex, 0, Math.Max(0, Tabs.Count - 1)));
}
}
// ── T2-11 탭 그룹 확장 ──
public static class TabManagerGroupExtensions
{
/// <summary>그룹 지정(null 이면 해제). 같은 그룹 탭은 인접하도록 이동.</summary>
public static void SetGroup(this TabManager m, TabItem tab, string? name, string? color)
{
if (!m.Tabs.Contains(tab)) return;
tab.GroupName = name;
tab.GroupColor = color;
if (name == null) return;
// 같은 그룹 마지막 탭 뒤로 이동
var lastIdx = -1;
for (var i = 0; i < m.Tabs.Count; i++)
if (string.Equals(m.Tabs[i].GroupName, name, StringComparison.Ordinal) && !ReferenceEquals(m.Tabs[i], tab))
lastIdx = i;
if (lastIdx >= 0) m.Move(tab, lastIdx + 1);
}
/// <summary>그룹 전체 닫기. 반환: 닫은 탭 수(잠긴 탭은 건너뛴다 — UC-B01).</summary>
public static int CloseGroup(this TabManager m, string groupName)
{
var group = m.Tabs.Where(t => string.Equals(t.GroupName, groupName, StringComparison.Ordinal) && !t.IsLocked).ToList();
foreach (var t in group) m.CloseTab(t);
return group.Count;
}
/// <summary>UC-B04 그룹 이름 변경 — 같은 GroupName 을 가진 모든 탭의 라벨을 바꾼다.</summary>
public static bool RenameGroup(this TabManager m, string oldName, string newName)
{
if (string.IsNullOrWhiteSpace(newName) || oldName == newName) return false;
var changed = 0;
foreach (var t in m.Tabs.Where(t => string.Equals(t.GroupName, oldName, StringComparison.Ordinal)).ToList())
{
t.GroupName = newName;
changed++;
}
return changed > 0;
}
/// <summary>UC-B03 그룹 접기/펼치기 — 그룹 소속 탭 전체에 접음 상태 반영.</summary>
public static int ToggleGroupCollapsed(this TabManager m, string groupName)
{
var changed = 0;
foreach (var t in m.Tabs.Where(t => string.Equals(t.GroupName, groupName, StringComparison.Ordinal)).ToList())
{
t.IsGroupCollapsed = !t.IsGroupCollapsed;
changed++;
}
return changed;
}
}
/// <summary>워크스페이스 스냅샷(T3-1, Zen Workspaces 패턴).</summary>
public sealed record WorkspaceSnapshot(string Name, List<string> Urls);
public static class TabManagerWorkspaceExtensions
{
/// <summary>현재 탭 세트를 워크스페이스로 캡처(현재 상태는 유지).</summary>
public static WorkspaceSnapshot CaptureWorkspace(this TabManager m, string name)
=> new(name, m.Tabs.Select(t => t.Url).ToList());
/// <summary>워크스페이스로 전환: 기존 탭 제거 후 스냅샷 복원.</summary>
public static void SwitchWorkspace(this TabManager m, WorkspaceSnapshot ws)
=> m.RestoreSession(new SessionSnapshot(ws.Urls, 0, Enumerable.Repeat(false, ws.Urls.Count).ToList()));
}

View file

@ -1,25 +0,0 @@
namespace VideoDownloader.Browser.Tabs;
/// <summary>UC-B05 탭 호버 미리보기 모델 — 제목·URL·상태를 한 눈에 보여주는 툴팁 페이로드.</summary>
public sealed record TabPreview(string Title, string Url, bool IsSuspended, bool IsAudioPlaying, bool IsLocked, bool IsReader)
{
/// <summary>탭 상태에서 미리보기 생성.</summary>
public static TabPreview From(TabItem tab) => new(
tab.Title,
tab.Url,
tab.IsSuspended,
tab.IsAudioPlaying,
tab.IsLocked,
tab.IsReader);
/// <summary>낭독기·툴팁용 한 줄 요약(상태 배지). 예: "제목 — zzz 잠자기 · 오디오 재생 중".</summary>
public string BuildTooltipText()
{
var badges = new List<string>();
if (IsSuspended) badges.Add("잠자기");
if (IsAudioPlaying) badges.Add("오디오 재생 중");
if (IsLocked) badges.Add("잠금");
if (IsReader) badges.Add("리더 모드");
return badges.Count == 0 ? $"{Title}\n{Url}" : $"{Title} ({string.Join(" · ", badges)})\n{Url}";
}
}

View file

@ -1,38 +0,0 @@
using System.IO;
using System.Text.Json;
namespace VideoDownloader.Browser.Tabs;
/// <summary>세션 JSON 저장소(T1-10). 종료 시 탭 URL 저장, 시작 시 복원.</summary>
public static class TabSessionStore
{
public static void Save(string path, SessionSnapshot snap)
{
try
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var tmp = path + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(snap));
File.Move(tmp, path, overwrite: true);
}
catch { }
}
public static SessionSnapshot? Load(string path)
{
try
{
if (!File.Exists(path)) return null;
var snap = JsonSerializer.Deserialize<SessionSnapshot>(File.ReadAllText(path));
if (snap == null || snap.Urls == null || snap.Urls.Count == 0) return null;
var pinned = snap.Pinned ?? new List<bool>();
while (pinned.Count < snap.Urls.Count) pinned.Add(false);
return snap with { Pinned = pinned };
}
catch
{
return null;
}
}
}

View file

@ -1,43 +0,0 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace VideoDownloader.Browser;
/// <summary>T3-15: "파라미터 제거 복사" — 추적용 쿼리·프래그먼트를 뺀 URL 반환.</summary>
public static class UrlCleaner
{
public static string? StripQuery(string? url)
{
if (url == null) return null;
var q = url.IndexOf('?');
if (q >= 0) url = url[..q];
var f = url.IndexOf('#');
if (f >= 0) url = url[..f];
return url;
}
}
/// <summary>T3-15: 주소창 진행 바 상태 — 진행 중 네비게이션 개수 집계(탭·리다이렉트 중복 안전).</summary>
public sealed class NavProgressTracker : INotifyPropertyChanged
{
private int _count;
public bool IsNavigating => Volatile.Read(ref _count) > 0;
public event PropertyChangedEventHandler? PropertyChanged;
public void Started() => Update(1);
public void Completed() => Update(-1);
private void Update(int delta)
{
var before = IsNavigating;
var c = _count + delta;
Volatile.Write(ref _count, Math.Max(0, c)); // 과잉 완료 방지
if (before != IsNavigating)
OnPropertyChanged(nameof(IsNavigating));
}
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

View file

@ -1,6 +0,0 @@
namespace VideoDownloader.Core.Detection;
public enum MediaKind { Hls, Dash, Direct, Social }
/// <summary>감지된 미디어. HLS(m3u8) / DASH(.mpd) / 직링크(Direct mp4/webm/...) / Social(yt-dlp).</summary>
public record DetectionContext(string Url, string PageUrl, MediaKind Kind, string? ContentType = null, string? Title = null, string? Tag = null);

View file

@ -1,81 +0,0 @@
using VideoDownloader.Core.Config;
namespace VideoDownloader.Core.Detection;
/// <summary>요청(URL + content-type + 길이)과 설정으로 미디어 종류를 판정.</summary>
public static class MediaDetector
{
/// <returns>HLS 또는 Direct, 감지 대상이 아니면 null</returns>
public static MediaKind? Classify(string? uri, string? contentType, long len, AppConfig config)
{
var l = (uri ?? "").ToLowerInvariant();
var c = (contentType ?? "").ToLowerInvariant();
bool isHls = c.Contains("mpegurl") || l.Contains(".m3u8") || l.Contains(".m3u");
if (isHls)
return Enabled(config, "hls") ? MediaKind.Hls : null;
bool isDash = c.Contains("dash+xml") || l.Contains(".mpd");
if (isDash)
return Enabled(config, "dash") ? MediaKind.Dash : null;
long min = config.MinSizeMB * 1024L * 1024L;
if (len >= 0 && len <= min) return null; // 너무 작음
var fmt = MatchFormat(l, c);
if (fmt == null) return null;
return Enabled(config, fmt) ? MediaKind.Direct : null;
}
private static bool Enabled(AppConfig config, string key) => config.EnabledFormats.Contains(key);
/// <summary>형식 키 반환: mp4/webm/mkv/m4v/mov/avi/audio (미매칭 null)</summary>
public static string? MatchFormat(string l, string c)
{
if (l.EndsWith(".mp4") || c.Contains("mp4")) return "mp4";
if (l.EndsWith(".webm") || c.Contains("webm")) return "webm";
if (l.EndsWith(".mkv") || c.Contains("matroska")) return "mkv";
if (l.EndsWith(".m4v")) return "m4v";
if (l.EndsWith(".mov") || c.Contains("quicktime")) return "mov";
if (l.EndsWith(".avi") || c.Contains("x-msvideo")) return "avi";
bool audio = c.StartsWith("audio/") || l.EndsWith(".m4a") || l.EndsWith(".mp3")
|| l.EndsWith(".ogg") || l.EndsWith(".opus") || l.EndsWith(".flac")
|| l.EndsWith(".wav") || l.EndsWith(".aac");
if (audio) return "audio";
if (c.StartsWith("video/")) return "mp4"; // 기타 video 는 mp4 취급
return null;
}
/// <summary>감지 후보(사람에게 보여줄 만한) 판정 - DASH/MSE/세그먼트 등</summary>
public static bool LooksLikeCandidate(string l, string c) =>
l.Contains(".m4s") || l.Contains("segment") || l.Contains("/init") || l.Contains(".ts?")
|| l.Contains(".mpd") || c.Contains("dash+xml") || c.Contains("mp2t") || c.Contains("octet-stream");
/// <summary>#15 본문 선두 magic byte 로 실제 포맷 판별 (위장 image/* 등 검증용).
/// 이미지 포맷(GIF/PNG/JPEG/WebP)은 null 반환 — 위장 트래킹 픽셀이 미디어로 오탐되는 것 방지.
/// (GIF 'G'=0x47 가 MPEG-TS sync byte 와 겹쳐 ts 로 오탐되던 문제 수정)</summary>
public static string? ProbeFormat(ReadOnlySpan<byte> h)
{
if (h.Length < 4) return null;
// 이미지 포맷 → 미디어 아님
if (h.Length >= 3 && h[0] == 'G' && h[1] == 'I' && h[2] == 'F') return null; // GIF
if (h.Length >= 8 && h[0] == 0x89 && h[1] == 0x50 && h[2] == 0x4E && h[3] == 0x47) return null; // PNG
if (h.Length >= 3 && h[0] == 0xFF && h[1] == 0xD8 && h[2] == 0xFF) return null; // JPEG
if (h.Length >= 12 && h[0] == 0x52 && h[1] == 0x49 && h[2] == 0x46 && h[3] == 0x46) // RIFF
{
if (h[8] == 'W' && h[9] == 'E' && h[10] == 'B' && h[11] == 'P') return null; // WebP(이미지)
if (h[8] == 'W' && h[9] == 'A' && h[10] == 'V' && h[11] == 'E') return "wav"; // WAV(오디오)
return null;
}
if (h.Length >= 12 && h[4] == 'f' && h[5] == 't' && h[6] == 'y' && h[7] == 'p') return "mp4"; // ftyp box
if (h[0] == 0x47) return "ts"; // MPEG-TS sync (GIF 는 위에서 걸러짐)
if (h[0] == 0x1A && h[1] == 0x45 && h[2] == 0xDF && h[3] == 0xA3) return "webm"; // EBML
if (h[0] == 'I' && h[1] == 'D' && h[2] == '3') return "mp3"; // ID3
if (h[0] == 0xFF && (h[1] & 0xE0) == 0xE0) return "mp3"; // MP3 frame
if (h[0] == 0x66 && h[1] == 0x4C && h[2] == 0x61 && h[3] == 0x43) return "flac"; // fLaC
if (h[0] == 0x4F && h[1] == 0x67 && h[2] == 0x67 && h[3] == 0x53) return "ogg"; // OggS
return null;
}
}

View file

@ -1,157 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Threading;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Hls;
/// <summary>
/// 직링크 미디어(mp4/webm/mkv/m4v/mov/m4a/mp3/ogg...) 다운로드.
/// - .part 임시파일에 쓰고 완료 시 이동
/// - Range 헤더로 이어받기 (서버 지원 시)
/// - 일시 오류 재시도 + 백오프 (영구 4xx 제외)
/// </summary>
public class DirectDownloader
{
private readonly HttpClient _http;
private readonly AppConfig _config;
public DirectDownloader(HttpClient http, AppConfig config)
{
_http = http;
_config = config;
}
public async Task<string> DownloadAsync(
Uri url, string outputBase,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
string? dir = Path.GetDirectoryName(outputBase);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var part = outputBase + ".part";
Exception? last = null;
string ext = GuessExt("", url);
long total = -1;
for (int attempt = 1; attempt <= _config.Retries; attempt++)
{
ct.ThrowIfCancellationRequested();
long written = File.Exists(part) ? new FileInfo(part).Length : 0;
TimeSpan? retryAfter = null; // 서버가 Retry-After 를 주면 그만큼 대기(#12)
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
if (written > 0) req.Headers.Range = new RangeHeaderValue(written, null);
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
// 이미 완전히 받은 경우
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
return Finalize(part, outputBase, ext);
// 영구 4xx (408/429 제외) → 재시도 의미 없음
var code = (int)resp.StatusCode;
bool permanent = code >= 400 && code < 500
&& resp.StatusCode != HttpStatusCode.RequestTimeout
&& resp.StatusCode != HttpStatusCode.TooManyRequests;
// #12 429/503 은 서버가 정한 쿨다운(Retry-After) 존중 — resp 가 dispose 되기 전에 캡처
if ((code == 429 || code == 503) && resp.Headers.RetryAfter is { } ra)
retryAfter = ra.Delta ?? (ra.Date is { } d ? d - DateTimeOffset.Now : null);
resp.EnsureSuccessStatusCode();
bool partial = resp.StatusCode == HttpStatusCode.PartialContent;
if (!partial) written = 0; // 서버가 range 무시 → 처음부터
var mediaType = resp.Content.Headers.ContentType?.MediaType ?? "";
ext = GuessExt(mediaType, url);
if (resp.Content.Headers.ContentLength is long cl)
total = partial ? written + cl : cl;
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var fs = new FileStream(part, partial ? FileMode.Append : FileMode.Create,
FileAccess.Write, FileShare.None, bufferSize: 65536, useAsync: true);
var buf = new byte[65536];
int n;
while ((n = await stream.ReadAsync(buf, ct)) > 0)
{
fs.Write(buf, 0, n);
written += n;
double pct = total > 0 ? written * 100.0 / total : -1;
string detail = total > 0
? $"{written / 1048576.0:F1}/{total / 1048576.0:F1} MB"
: $"{written / 1048576.0:F1} MB";
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, detail,
ReceivedBytes: written, TotalBytes: total));
}
await fs.FlushAsync(ct);
return Finalize(part, outputBase, ext);
}
catch (OperationCanceledException) { throw; }
catch (HttpRequestException hre)
{
last = hre;
var c = hre.StatusCode is { } s ? (int)s : 0;
bool perm = c >= 400 && c < 500 && c != 408 && c != 429;
if (perm || attempt == _config.Retries) break;
await DelayAsync(retryAfter, attempt, ct);
}
catch (Exception e)
{
last = e;
if (attempt == _config.Retries) break;
await DelayAsync(retryAfter, attempt, ct);
}
}
throw last!;
}
private static string Finalize(string part, string outputBase, string ext)
{
var final = outputBase + ext;
// 대상 파일이 탐색기(썸네일)/백신에 의해 잠깐 잠겨있으면 File.Move 가
// "being used by another process" 로 실패하므로 잠시 대기하며 재시도.
for (int i = 0; ; i++)
{
try { File.Move(part, final, overwrite: true); return final; }
catch (IOException) when (i < 10) { Thread.Sleep(250); }
}
}
/// <summary>#12 재시도 대기: 서버 Retry-After(있으면, 최대 60s 캡) 를 우선, 없으면 지수 백오프.</summary>
private static async Task DelayAsync(TimeSpan? retryAfter, int attempt, CancellationToken ct)
{
var delay = retryAfter ?? TimeSpan.FromMilliseconds(Math.Min(1500 * attempt, 10000));
if (delay > TimeSpan.FromSeconds(60)) delay = TimeSpan.FromSeconds(60);
if (delay < TimeSpan.Zero) delay = TimeSpan.Zero;
try { await Task.Delay(delay, ct); } catch { }
}
private static readonly string[] KnownExts =
{ ".mp4", ".webm", ".mkv", ".m4v", ".mov", ".avi", ".m4a", ".mp3", ".ogg", ".opus", ".flac", ".wav", ".ts" };
private static string GuessExt(string mediaType, Uri url)
{
var path = url.AbsolutePath.ToLowerInvariant();
foreach (var k in KnownExts)
if (path.EndsWith(k)) return k;
return mediaType.ToLowerInvariant() switch
{
"video/mp4" or "video/mp4v-es" => ".mp4",
"video/webm" => ".webm",
"video/x-matroska" => ".mkv",
"video/quicktime" => ".mov",
"video/x-msvideo" => ".avi",
"audio/mp4" or "audio/x-m4a" => ".m4a",
"audio/mpeg" => ".mp3",
"audio/ogg" or "application/ogg" => ".ogg",
"audio/webm" => ".webm",
"audio/wav" or "audio/x-wav" => ".wav",
"audio/flac" => ".flac",
_ => ".mp4",
};
}
}

View file

@ -1,13 +0,0 @@
namespace VideoDownloader.Core.Platform;
/// <summary>
/// 앱 데이터 디렉터리 결정(Core 레벨). 기본 %AppData%/VideoDownloader.
/// VD_DATA_DIR 환경변수로 오버라이드 — E2E 테스트 격리 공식 후크.
/// </summary>
public static class CorePaths
{
public static string DataDir =>
Environment.GetEnvironmentVariable("VD_DATA_DIR") is { Length: > 0 } env
? env
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader");
}

View file

@ -1,50 +0,0 @@
using System.Text;
namespace VideoDownloader.Core.Util;
public static class FileNameUtil
{
private static readonly char[] Invalid = Path.GetInvalidFileNameChars();
// 무의미한 제네릭 이름 (이름 추출 시 건너뜀)
private static readonly HashSet<string> GenericNames = new(StringComparer.OrdinalIgnoreCase)
{
"index", "playlist", "playlists", "master", "chunklist", "main", "play",
"media", "video", "stream", "streaming", "vod", "output", "file", "hls",
"dash", "manifest", "seg", "segment", "test", "default"
};
/// <summary>파일명으로 사용 불가능한 문자를 '_'로 치환하고 길이를 제한한다.</summary>
public static string Sanitize(string? name, int maxLen = 120)
{
if (string.IsNullOrWhiteSpace(name)) return "video";
var extra = new[] { '"', '<', '>', '|', '*', '?' };
var sb = new StringBuilder();
foreach (var c in name.Trim())
{
if (Array.IndexOf(Invalid, c) >= 0 || Array.IndexOf(extra, c) >= 0) sb.Append('_');
else sb.Append(c);
}
var result = sb.ToString().Trim().Trim('.');
if (string.IsNullOrWhiteSpace(result)) result = "video";
return result.Length > maxLen ? result[..maxLen].Trim() : result;
}
/// <summary>미디어 URL 에서 '진짜 파일명'을 추출 (페이지 타이틀 사용 X).</summary>
public static string DeriveName(Uri url)
{
var segs = url.Segments
.Select(s => Uri.UnescapeDataString(s).Trim('/'))
.Where(s => s.Length > 0)
.ToArray();
for (int i = segs.Length - 1; i >= 0; i--)
{
var withoutExt = Path.GetFileNameWithoutExtension(segs[i]);
if (string.IsNullOrWhiteSpace(withoutExt)) continue;
if (GenericNames.Contains(withoutExt)) continue; // index.m3u8 등은 건너뜀
return Sanitize(withoutExt);
}
return Sanitize(url.Host); // fallback: 호스트명
}
}

View file

@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -1,21 +0,0 @@
namespace VideoDownloader.Mobile;
/// <summary>
/// 외부망/네트워크 불통 안내(MOBILE_PLAN §7 성공 기준) —
/// "같은 네트워크 필요 / 외부망에서는 Tailscale 등 VPN 안내".
/// </summary>
public static class ConnectionTroubleshoot
{
public const string NotConfigured = "먼저 연결 탭에서 PC 와 페어링하세요";
public const string Unreachable =
"PC 에 연결할 수 없습니다. 폰과 PC 가 같은 네트워크(Wi-Fi)에 있는지 확인하세요. " +
"외부망에서 접속하려면 Tailscale 등 VPN 으로 PC 의 사설망에 접근해야 합니다.";
public static string Message(bool isConfigured, string? lastError)
{
if (!isConfigured) return NotConfigured;
if (!string.IsNullOrEmpty(lastError)) return Unreachable;
return "";
}
}

View file

@ -1,72 +0,0 @@
using System.Text.Json;
namespace VideoDownloader.Mobile;
public sealed record OfflineItem(string Id, string FileName, long SizeBytes);
/// <summary>
/// 폰 오프라인 저장(U4): 라이브러리 항목을 앱 전용 폴더에 저장·목록·삭제.
/// manifest.json(id→파일명)으로 서버 항목과 매핑한다.
/// </summary>
public sealed class OfflineStore
{
private readonly string _dir;
private readonly string _manifestPath;
private readonly object _gate = new();
private Dictionary<string, string> _manifest;
/// <summary>baseDir 아래 offline/ 폴더 사용(호스트가 앱 데이터 디렉터리 주입).</summary>
public OfflineStore(string baseDir)
{
_dir = Path.Combine(baseDir, "offline");
_manifestPath = Path.Combine(_dir, "manifest.json");
try
{
if (File.Exists(_manifestPath))
_manifest = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(_manifestPath)) ?? new();
}
catch { }
_manifest ??= new Dictionary<string, string>();
}
public string Directory => _dir;
public string PathFor(string id) => Path.Combine(_dir, id + Path.GetExtension(_manifest.GetValueOrDefault(id, ".mp4")));
public bool Exists(string id) { lock (_gate) { var p = PathFor(id); return File.Exists(p) && _manifest.ContainsKey(id); } }
/// <summary>다운로드 완료 후 등록.</summary>
public void Record(string id, string fileName)
{
lock (_gate)
{
System.IO.Directory.CreateDirectory(_dir);
_manifest[id] = fileName;
Save();
}
}
public void Delete(string id)
{
lock (_gate)
{
try { if (File.Exists(PathFor(id))) File.Delete(PathFor(id)); } catch { }
_manifest.Remove(id);
Save();
}
}
public List<OfflineItem> List()
{
lock (_gate)
return _manifest.Select(kv =>
{
var p = PathFor(kv.Key);
return new OfflineItem(kv.Key, kv.Value, File.Exists(p) ? new FileInfo(p).Length : 0);
}).Where(i => i.SizeBytes > 0).ToList();
}
private void Save()
{
try { System.IO.Directory.CreateDirectory(_dir); File.WriteAllText(_manifestPath, JsonSerializer.Serialize(_manifest)); } catch { }
}}

View file

@ -1,18 +0,0 @@
namespace VideoDownloader.Mobile;
/// <summary>U5 완료 알림: 이전/다음 큐 스냅샷에서 새로 완료("완료")된 항목 ID 감지.
/// 이전 스냅샷이 비면(앱 시작 직후 첫 폴링) 무시 — 과거 완료 알림 스팸 방지.</summary>
public sealed class QueueCompletionDetector
{
public IReadOnlyList<string> Detect(
IReadOnlyList<(string Id, string Status)> previous,
IReadOnlyList<(string Id, string Status)> next)
{
if (previous.Count == 0) return Array.Empty<string>();
var prev = previous.ToDictionary(x => x.Id, x => x.Status);
return next
.Where(n => n.Status == "완료" && prev.TryGetValue(n.Id, out var s) && s != "완료")
.Select(n => n.Id)
.ToList();
}
}

View file

@ -1,18 +0,0 @@
using System.Text.RegularExpressions;
namespace VideoDownloader.Mobile;
/// <summary>M1 공유 타겟(U1): 다른 앱에서 공유받은 텍스트에서 전송할 URL 추출.
/// 유튜브 공유 시트는 "제목 + https://youtu.be/..." 형식 — 텍스트 중 첫 http(s) URL.</summary>
public static partial class SharePayload
{
[GeneratedRegex(@"https?://[^\s]+")]
private static partial Regex UrlRegex();
public static string? ExtractUrl(string? sharedText)
{
if (string.IsNullOrWhiteSpace(sharedText)) return null;
var m = UrlRegex().Match(sharedText);
return m.Success ? m.Value.TrimEnd(')', ']', '}', ',', '.', ';') : null;
}
}

View file

@ -1,194 +0,0 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace VideoDownloader.Mobile;
/// <summary>데스크톱 원격 서버 API 클라이언트(M1). 토큰은 QR(#t=)/수동 입력에서 획득.</summary>
public sealed class ApiClient
{
public static readonly ApiClient Instance = new();
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) };
public string? BaseUrl { get; private set; }
public string? Token { get; private set; }
public string? LastError { get; private set; }
public bool IsConfigured => BaseUrl is { Length: > 0 } && Token is { Length: > 0 };
public event Action? ConnectionChanged;
/// <summary>QR 문자열(http://ip:port/#t=TOKEN) 또는 수동 입력(host:port + token) 구성.</summary>
public bool Configure(string? qrOrUrl, string? manualToken = null)
{
try
{
if (!string.IsNullOrWhiteSpace(manualToken))
{
var host = qrOrUrl!.Trim().TrimEnd('/');
if (!host.StartsWith("http", StringComparison.OrdinalIgnoreCase)) host = "http://" + host;
BaseUrl = host;
Token = manualToken.Trim();
}
else
{
var u = new Uri(qrOrUrl!.Trim());
var frag = u.Fragment.TrimStart('#');
var token = System.Web.HttpUtility.ParseQueryString(frag)["t"];
if (string.IsNullOrEmpty(token)) return false;
BaseUrl = $"http://{u.Authority}";
Token = token;
}
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token);
Preferences.Set("vd_base", BaseUrl);
Preferences.Set("vd_token", Token);
ConnectionChanged?.Invoke();
return true;
}
catch { return false; }
}
public void Restore()
{
var b = Preferences.Get("vd_base", "");
var t = Preferences.Get("vd_token", "");
if (b.Length > 0 && t.Length > 0)
{
BaseUrl = b;
Token = t;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", t);
}
}
public void Forget()
{
BaseUrl = null; Token = null;
Preferences.Remove("vd_base"); Preferences.Remove("vd_token");
ConnectionChanged?.Invoke();
}
public async Task<List<QueueItem>> GetQueueAsync()
{
try
{
LastError = null;
var r = await _http.GetAsync($"{BaseUrl}/api/queue");
if (!r.IsSuccessStatusCode) { LastError = $"HTTP {(int)r.StatusCode}"; return new(); }
return await r.Content.ReadFromJsonAsync<List<QueueItem>>() ?? new();
}
catch (Exception e) { LastError = e.Message; return new(); }
}
public async Task<bool> AddAsync(string url)
{
try
{
var r = await _http.PostAsJsonAsync($"{BaseUrl}/api/queue", new { url });
return r.IsSuccessStatusCode;
}
catch { return false; }
}
public Task<bool> ControlAsync(string id, string action) =>
PostAsync($"/api/queue/{id}/{action}");
public async Task<bool> PairAsync()
{
try
{
var r = await _http.PostAsJsonAsync($"{BaseUrl}/api/pair", new { token = Token, device = "Android" });
return r.IsSuccessStatusCode;
}
catch { return false; }
}
public async Task<List<LibraryItem>> GetLibraryAsync()
{
try
{
var r = await _http.GetAsync($"{BaseUrl}/api/library");
if (!r.IsSuccessStatusCode) return new();
return await r.Content.ReadFromJsonAsync<List<LibraryItem>>() ?? new();
}
catch { return new(); }
}
public string StreamUrl(LibraryItem item) => $"{BaseUrl}/stream/{item.Id}?t={Token}";
public string FileUrl(LibraryItem item) => $"{BaseUrl}/file/{item.Id}?t={Token}";
public string ThumbUrl(LibraryItem item) => $"{BaseUrl}/api/library/{item.Id}/thumb?t={Token}";
/// <summary>이어보기 위치 조회(U3). 실패/미저장 시 0.</summary>
public async Task<double> GetPositionAsync(string id)
{
var dto = await GetAsync<PositionDto>($"/api/position/{id}");
return dto?.PositionSec ?? 0;
}
/// <summary>이어보기 위치 저장(U3).</summary>
public Task<bool> PostPositionAsync(string id, double positionSec) =>
PostJsonAsync($"/api/position/{id}", new PositionDto(positionSec));
/// <summary>라이브러리 항목을 폰에 저장(U4). 진행률 0~1, 취소 지원. 임시 .part 로 받아 완료 시 rename.</summary>
public async Task DownloadFileAsync(LibraryItem item, string destPath, IProgress<double>? progress, CancellationToken ct)
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
var tmp = destPath + ".part";
try
{
using var resp = await _http.GetAsync(FileUrl(item), HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
var total = resp.Content.Headers.ContentLength ?? item.SizeBytes;
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(tmp);
var buf = new byte[64 * 1024];
long read = 0;
int n;
while ((n = await src.ReadAsync(buf, ct)) > 0)
{
await dst.WriteAsync(buf.AsMemory(0, n), ct);
read += n;
if (total > 0) progress?.Report((double)read / total);
}
File.Move(tmp, destPath, overwrite: true);
progress?.Report(1);
}
finally
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
}
}
private async Task<bool> PostJsonAsync(string path, object body)
{
try
{
var r = await _http.PostAsJsonAsync($"{BaseUrl}{path}", body);
return r.IsSuccessStatusCode;
}
catch { return false; }
}
private Task<bool> PostAsync(string path)
{
try { var t = _http.PostAsync($"{BaseUrl}{path}", null); return Continue(t); }
catch { return Task.FromResult(false); }
static async Task<bool> Continue(Task<HttpResponseMessage> t) { try { return (await t).IsSuccessStatusCode; } catch { return false; } }
}
private async Task<T?> GetAsync<T>(string path) where T : class
{
try
{
var r = await _http.GetAsync($"{BaseUrl}{path}");
return r.IsSuccessStatusCode ? await r.Content.ReadFromJsonAsync<T>() : null;
}
catch { return null; }
}
}
public sealed record QueueItem(string Id, string Title, string Url, string Status, double Percent);
public sealed record LibraryItem(string Id, string FileName, long SizeBytes, DateTime Modified, string? Resolution = null);
public sealed record PositionDto(double PositionSec);

View file

@ -1,14 +0,0 @@
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:VideoDownloader.Mobile"
x:Class="VideoDownloader.Mobile.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View file

@ -1,16 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace VideoDownloader.Mobile;
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new TabbedRootPage());
}
}

View file

@ -1,7 +0,0 @@
namespace VideoDownloader.Mobile;
/// <summary>앱 데이터 디렉터리를 주입하는 오프라인 저장소 기본 인스턴스(Core 는 MAUI 비의존).</summary>
public static class AppOfflineStore
{
public static OfflineStore Default { get; } = new(FileSystem.AppDataDirectory);
}

View file

@ -1,29 +0,0 @@
using Microsoft.Extensions.Logging;
using CommunityToolkit.Maui;
using ZXing.Net.Maui.Controls;
namespace VideoDownloader.Mobile;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseBarcodeReader()
.UseMauiCommunityToolkitMediaElement()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
}
}

View file

@ -1,486 +0,0 @@
using Microsoft.Maui.Controls;
using System.Net.Http;
namespace VideoDownloader.Mobile;
/// <summary>M1 연결 페이지: QR 스캔(ZXing) + 수동 입력 폴백.</summary>
public sealed class ConnectPage : ContentPage
{
private readonly Entry _manual = new() { Placeholder = "PC 주소 (예: 192.168.0.223:52100)", Keyboard = Keyboard.Url };
private readonly Entry _manualToken = new() { Placeholder = "토큰 (QR 못 쓸 때)", IsPassword = true };
private readonly Label _status = new() { Text = "PC 앱에서 '기기 연결'을 누르고 QR 을 스캔하세요", HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 12) };
public ConnectPage()
{
Title = "연결";
// 시뮬레이터는 카메라 하드웨어가 없어 세션 시작이 첫 프레임을 막음(UIApplication hang) → 실기기에서만 스캐너
View scannerArea = new Label
{
Text = "📷 시뮬레이터 — 수동 입력을 이용하세요",
HorizontalOptions = LayoutOptions.Fill, VerticalOptions = LayoutOptions.Center,
HorizontalTextAlignment = TextAlignment.Center, HeightRequest = 120,
BackgroundColor = Colors.Black, TextColor = Colors.LightGray,
};
// 게이트 대상은 "iOS 시뮬레이터" 뿐: Android 는 실기기·에뮬레이터 모두 스캐너 표시
// (DeviceType 휴리스틱이 실기기에서 가상으로 오판하면 QR 이 아예 안 뜨는 결함이 됨)
var isIosSimulator = DeviceInfo.Current.Platform == DevicePlatform.iOS
&& DeviceInfo.Current.DeviceType == DeviceType.Virtual;
if (!isIosSimulator)
{
var scanner = new ZXing.Net.Maui.Controls.CameraBarcodeReaderView
{
HorizontalOptions = LayoutOptions.Fill,
HeightRequest = 320,
Options = new ZXing.Net.Maui.BarcodeReaderOptions
{
Formats = ZXing.Net.Maui.BarcodeFormats.TwoDimensional,
AutoRotate = true,
},
};
scanner.BarcodesDetected += (s, e) =>
{
var text = e.Results?.FirstOrDefault()?.Value;
if (string.IsNullOrEmpty(text)) return;
MainThread.BeginInvokeOnMainThread(() =>
{
if (ApiClient.Instance.Configure(text))
{
_status.Text = "✅ 연결됨";
scanner.IsDetecting = false;
}
});
};
scannerArea = scanner;
}
var connectBtn = new Button { Text = "수동 연결" };
connectBtn.Clicked += (_, _) =>
{
if (ApiClient.Instance.Configure(_manual.Text, _manualToken.Text)) _status.Text = "✅ 연결됨";
else _status.Text = "❌ 연결 실패 — 주소/토큰 확인";
};
var forgetBtn = new Button { Text = "연결 해제", BackgroundColor = Colors.Gray };
forgetBtn.Clicked += (_, _) => { ApiClient.Instance.Forget(); _status.Text = "해제됨"; };
Content = new ScrollView
{
Content = new VerticalStackLayout
{
Padding = 20, Spacing = 10,
Children = { _status, scannerArea, _manual, _manualToken, connectBtn, forgetBtn }
}
};
}
}
/// <summary>M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어 + U5 완료 알림 + 외부망 안내.</summary>
public sealed class QueuePage : ContentPage
{
private readonly Entry _url = new() { Placeholder = "YouTube·영상 URL 붙여넣기", Keyboard = Keyboard.Url };
private readonly Label _summary = new();
private readonly Label _trouble = new() { FontSize = 12, TextColor = Colors.OrangeRed, IsVisible = false };
private readonly CollectionView _list = new();
private readonly IDispatcherTimer _timer;
private readonly QueueCompletionDetector _completions = new();
private List<QueueItem> _last = new();
public QueuePage()
{
Title = "다운로드 큐";
var addBtn = new Button { Text = "PC에서 받기" };
addBtn.Clicked += async (_, _) =>
{
if (string.IsNullOrWhiteSpace(_url.Text)) return;
_summary.Text = "전송 중…";
var ok = await ApiClient.Instance.AddAsync(_url.Text.Trim());
_summary.Text = ok ? "✅ PC 대기열에 추가됨" : "❌ 실패 (연결 확인)";
if (ok) _url.Text = "";
await Refresh();
};
_list.ItemTemplate = new DataTemplate(() =>
{
var title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
title.SetBinding(Label.TextProperty, "Title");
var status = new Label { FontSize = 12, TextColor = Colors.Gray };
status.SetBinding(Label.TextProperty, new Binding("Percent", stringFormat: "{0:0}%"));
var bar = new ProgressBar { HeightRequest = 6 };
bar.SetBinding(ProgressBar.ProgressProperty, new Binding("Percent", converter: new PercentConverter()));
var state = new Label { FontSize = 11, TextColor = Colors.Gray };
state.SetBinding(Label.TextProperty, "Status");
var pause = new Button { Text = "⏸" };
var resume = new Button { Text = "▶" };
var cancel = new Button { Text = "✕", BackgroundColor = Colors.Gray };
pause.Clicked += async (_, _) => { if (pause.BindingContext is QueueItem qi) { await ApiClient.Instance.ControlAsync(qi.Id, "pause"); await Refresh(); } };
resume.Clicked += async (_, _) => { if (resume.BindingContext is QueueItem qi) { await ApiClient.Instance.ControlAsync(qi.Id, "resume"); await Refresh(); } };
cancel.Clicked += async (_, _) => { if (cancel.BindingContext is QueueItem qi) { await ApiClient.Instance.ControlAsync(qi.Id, "cancel"); await Refresh(); } };
return new Frame
{
Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
Content = new VerticalStackLayout
{
Spacing = 6,
Children = { title, bar, new HorizontalStackLayout { Children = { state, new Label { Text = " " } } },
new HorizontalStackLayout { Spacing = 8, Children = { pause, resume, cancel } } }
}
};
});
_timer = Dispatcher.CreateTimer();
_timer.Interval = TimeSpan.FromSeconds(2);
_timer.Tick += async (_, _) => await Refresh();
Appearing += (_, _) => _timer.Start();
Disappearing += (_, _) => _timer.Stop();
Content = new VerticalStackLayout
{
Padding = 16, Spacing = 10,
Children = { _url, addBtn, _summary, _trouble, _list }
};
}
private async Task Refresh()
{
var trouble = ConnectionTroubleshoot.Message(ApiClient.Instance.IsConfigured, ApiClient.Instance.LastError);
_trouble.Text = trouble;
_trouble.IsVisible = trouble.Length > 0;
if (!ApiClient.Instance.IsConfigured) { _summary.Text = "⚠️ 먼저 연결하세요"; _last = new(); return; }
var items = await ApiClient.Instance.GetQueueAsync();
_list.ItemsSource = items;
// U5: 이전 스냅샷 대비 새로 완료된 항목 → 로컬 알림
var done = _completions.Detect(
_last.Select(i => (i.Id, i.Status)).ToList(),
items.Select(i => (i.Id, i.Status)).ToList());
foreach (var id in done)
{
var item = items.First(i => i.Id == id);
_ = NotifyCompletionAsync(item.Title);
_summary.Text = $"🔔 완료: {item.Title}";
}
_last = items;
var err = ApiClient.Instance.LastError;
_summary.Text = err != null ? $"❌ {err}" : items.Count == 0 ? "대기 중인 항목이 없습니다" : $"{items.Count}건";
}
/// <summary>U1: 공유받은 링크 처리 결과 표시(루트 타이머가 호출).</summary>
public void ShowSharedResult(string message) => _summary.Text = message;
/// <summary>U1: 공유 타겟으로 받은 텍스트에서 URL 을 추출해 PC 대기열에 추가.</summary>
private async Task HandleSharedUrlAsync()
{
var shared = ShareTarget.Consume();
if (shared == null || !ApiClient.Instance.IsConfigured) return;
var url = SharePayload.ExtractUrl(shared);
if (url == null) { _summary.Text = "🔗 공유받은 텍스트에 URL 이 없습니다"; return; }
_summary.Text = "🔗 공유 링크 전송 중…";
var ok = await ApiClient.Instance.AddAsync(url);
_summary.Text = ok ? $"🔗 공유 링크 전송됨: {url}" : "❌ 공유 링크 전송 실패";
}
/// <summary>U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청).</summary>
private static async Task NotifyCompletionAsync(string title)
{
try
{
if (OperatingSystem.IsAndroidVersionAtLeast(33))
await Plugin.LocalNotification.LocalNotificationCenter.Current.RequestNotificationPermission();
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
{
Title = "다운로드 완료",
Description = title,
});
}
catch { }
}
private sealed class PercentConverter : IValueConverter
{
public object Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c) => Math.Clamp(value is double d ? d / 100.0 : 0, 0, 1);
public object ConvertBack(object? v, Type t, object? p, System.Globalization.CultureInfo c) => throw new NotSupportedException();
}
}
/// <summary>M2 라이브러리 페이지: 목록 → 탭하여 스트리밍 재생.</summary>
public sealed class LibraryPage : ContentPage
{
private readonly CollectionView _list = new();
public LibraryPage()
{
Title = "라이브러리";
_list.ItemsLayout = new GridItemsLayout(2, ItemsLayoutOrientation.Vertical); // M2 썸네일 그리드
_list.ItemTemplate = new DataTemplate(() =>
{
var thumb = new Image { HeightRequest = 96, Aspect = Aspect.AspectFill, BackgroundColor = Colors.Black };
thumb.SetBinding(Image.SourceProperty, new Binding(".", converter: new ThumbConverter()));
var name = new Label { FontSize = 13, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation, LineHeight = 1.1 };
name.SetBinding(Label.TextProperty, "FileName");
var size = new Label { FontSize = 10, TextColor = Colors.Gray };
size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes"));
var date = new Label { FontSize = 9, TextColor = Colors.Gray }; // M2: 추가일(수정일)
date.SetBinding(Label.TextProperty, new Binding("Modified", stringFormat: "{0:yy.MM.dd}"));
// U4 폰 저장: 카드 우측 버튼, 진행률을 버튼 텍스트로 표시
var save = new Button { Text = "💾 저장", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center };
save.Clicked += async (_, _) => await SaveOffline(save, save.BindingContext as LibraryItem);
var card = new Frame
{
Margin = new Thickness(6, 4), Padding = 8, CornerRadius = 10,
Content = new VerticalStackLayout { Spacing = 4, Children = { thumb, name, size, date, save } }
};
// CollectionView.SelectionChanged 는 Android 에서 발화 불안정 → 템플릿 카드에 탭 제스처
var tap = new TapGestureRecognizer();
tap.Tapped += (_, _) => { if (card.BindingContext is LibraryItem item) OpenPlayer(item); };
card.GestureRecognizers.Add(tap);
return card;
});
_list.SelectionMode = SelectionMode.None;
var refresh = new Button { Text = "새로고침" };
refresh.Clicked += async (_, _) => await Reload();
Content = new VerticalStackLayout { Padding = 16, Children = { refresh, _list } };
Appearing += async (_, _) => await Reload();
}
/// <summary>U4: 항목을 폰 저장 폴더로 스트리밍 다운로드(진행률 % → 완료 ✓).</summary>
private static async Task SaveOffline(Button button, LibraryItem? item)
{
if (item == null) return;
if (AppOfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
button.Text = "0%";
var notifId = 5000 + Math.Abs(item.Id.GetHashCode() % 1000); // M2 진행 알림: 항목별 고정 id 로 갱신
try
{
var dest = AppOfflineStore.Default.PathFor(item.Id);
var progress = new Progress<double>(p => MainThread.BeginInvokeOnMainThread(() =>
{
button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%";
if (p < 1) ShowSaveProgress(notifId, item.FileName, p); // 100% 는 완료 노티가 대체(되덮어쓰기 방지)
}));
await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None);
AppOfflineStore.Default.Record(item.Id, item.FileName);
button.Text = "✓ 저장됨";
ShowSaveDone(notifId, item.FileName);
}
catch (Exception e)
{
MainThread.BeginInvokeOnMainThread(() => button.Text = $"❌ {e.Message[..Math.Min(20, e.Message.Length)]}");
}
}
/// <summary>M2: 오프라인 저장 진행 알림(같은 id 갱신) — 5% 단위 스로틀.</summary>
private static int _lastNotifiedPercent = -1;
private static void ShowSaveProgress(int notifId, string fileName, double p)
{
var percent = (int)(p * 100);
if (p < 1 && percent / 5 == _lastNotifiedPercent) return; // 알림 스팸 방지
_lastNotifiedPercent = percent / 5;
try
{
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
{
NotificationId = notifId,
Title = "폰에 저장 중",
Description = $"{fileName} — {percent}%",
Android =
{
ChannelId = "vd_save",
Ongoing = true,
AutoCancel = false,
ProgressBar = new Plugin.LocalNotification.AndroidOption.AndroidProgressBar
{
Max = 100,
Progress = percent,
IsIndeterminate = false,
},
},
});
}
catch { }
}
private static void ShowSaveDone(int notifId, string fileName)
{
try
{
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
{
NotificationId = notifId,
Title = "폰 저장 완료",
Description = fileName,
Android = { ChannelId = "vd_save", Ongoing = false, AutoCancel = true },
});
}
catch { }
}
private async void OpenPlayer(LibraryItem item)
{
// 탭 자식엔 NavigationPage 가 없어 PushAsync 불가 → 루트에서 모달 push (닫기는 상단바 뒤로)
await Application.Current!.MainPage!.Navigation.PushModalAsync(new NavigationPage(new PlayerPage(item)));
}
private async Task Reload()
{
if (!ApiClient.Instance.IsConfigured) { _list.ItemsSource = new[] { "먼저 연결하세요" }; return; }
_list.ItemsSource = await ApiClient.Instance.GetLibraryAsync();
}
}
/// <summary>M2-U4 저장 탭: 폰에 저장된 항목 목록 — 오프라인 재생/삭제.</summary>
public sealed class DownloadsPage : ContentPage
{
private readonly CollectionView _list = new();
private readonly Label _empty = new()
{
Text = "저장된 항목이 없습니다 — 라이브러리에서 💾 저장",
TextColor = Colors.Gray, HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 24),
};
public DownloadsPage()
{
Title = "저장";
_list.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
name.SetBinding(Label.TextProperty, "FileName");
var size = new Label { FontSize = 11, TextColor = Colors.Gray };
size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes — 오프라인"));
var del = new Button { Text = "🗑", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center };
del.Clicked += (_, _) =>
{
if ((del.BindingContext as OfflineItem) is { } it) { AppOfflineStore.Default.Delete(it.Id); Reload(); }
};
var card = new Frame
{
Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
Content = new HorizontalStackLayout { Spacing = 8, Children = { new VerticalStackLayout { Children = { name, size } }, del } }
};
var tap = new TapGestureRecognizer();
tap.Tapped += (_, _) =>
{
if ((card.BindingContext as OfflineItem) is { } it)
_ = Application.Current!.MainPage!.Navigation.PushModalAsync(
new NavigationPage(new PlayerPage(new LibraryItem(it.Id, it.FileName, it.SizeBytes, DateTime.MinValue), AppOfflineStore.Default.PathFor(it.Id))));
};
card.GestureRecognizers.Add(tap);
return card;
});
_list.SelectionMode = SelectionMode.None;
var refresh = new Button { Text = "새로고침" };
refresh.Clicked += (_, _) => Reload();
Content = new VerticalStackLayout { Padding = 16, Children = { refresh, _empty, _list } };
_empty.IsVisible = false;
Appearing += (_, _) => Reload();
}
private void Reload()
{
var items = AppOfflineStore.Default.List();
_list.ItemsSource = items.Count > 0 ? items : null;
_empty.IsVisible = items.Count == 0;
}
}
/// <summary>M2 재생 페이지: Range 스트리밍 재생(MediaElement) + 이어보기 위치 동기화(U3). localPath 있으면 오프라인 재생(U4).</summary>
public sealed class PlayerPage : ContentPage
{
private readonly CommunityToolkit.Maui.Views.MediaElement _media = new()
{
Aspect = Aspect.AspectFit,
ShouldAutoPlay = true,
ShouldShowPlaybackControls = true,
};
private readonly string? _itemId; // 스트리밍 재생 시 위치 동기화 대상
private bool _sync; // 위치 저장 활성(오프라인 재생은 서버 없이도 동작)
public PlayerPage(LibraryItem item, string? localPath = null)
{
Title = item.FileName;
if (localPath != null)
{
_media.Source = CommunityToolkit.Maui.Views.MediaSource.FromFile(localPath);
}
else
{
_itemId = item.Id;
_media.Source = CommunityToolkit.Maui.Views.MediaSource.FromUri(ApiClient.Instance.StreamUrl(item));
// 열림 시 저장된 위치로 시크 → 5초 주기 동기화(U3)
_media.MediaOpened += async (_, _) =>
{
var saved = await ApiClient.Instance.GetPositionAsync(item.Id);
if (saved > 1) await _media.SeekTo(TimeSpan.FromSeconds(saved));
_sync = true;
};
Device.StartTimer(TimeSpan.FromSeconds(5), () =>
{
if (!_sync || _media.Position <= TimeSpan.Zero) return true;
_ = ApiClient.Instance.PostPositionAsync(item.Id, _media.Position.TotalSeconds);
return true; // 페이지가 닫혀도 무해(타이머는 프로세스 수명)
});
}
Content = _media;
// M2: 재생 중 미디어 알림(ongoing 노티) — 재생은 앱 내(포그라운드) 한정
StartPlaybackNotification(item.FileName);
}
protected override void OnDisappearing()
{
if (_sync && _itemId != null && _media.Position > TimeSpan.Zero)
_ = ApiClient.Instance.PostPositionAsync(_itemId, _media.Position.TotalSeconds);
StopPlaybackNotification();
_media.Stop();
_media.Handler?.DisconnectHandler(); // 재생 리소스 해제
base.OnDisappearing();
}
private const int PlaybackNotificationId = 4711;
/// <summary>M2: 재생 중 미디어 알림 — ongoing 노티(닫으면 자동 소멸).</summary>
private static void StartPlaybackNotification(string title)
{
try
{
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
{
NotificationId = PlaybackNotificationId,
Title = "재생 중",
Description = title,
Android =
{
ChannelId = "vd_playback",
Ongoing = true,
AutoCancel = false,
},
});
}
catch { }
}
private static void StopPlaybackNotification()
{
try { Plugin.LocalNotification.LocalNotificationCenter.Current.Cancel(PlaybackNotificationId); } catch { }
}
}
/// <summary>M2: LibraryItem → 썸네일 URL 변환(바인딩 컨버터).</summary>
public sealed class ThumbConverter : IValueConverter
{
public object? Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c)
=> value is LibraryItem it && ApiClient.Instance.IsConfigured
? Microsoft.Maui.Controls.ImageSource.FromUri(new Uri(ApiClient.Instance.ThumbUrl(it)))
: null;
public object ConvertBack(object? v, Type t, object? p, System.Globalization.CultureInfo c) => throw new NotSupportedException();
}

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true" android:usesCleartextTraffic="true"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- U5 완료 알림(Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- M2 재생중 미디어 알림(포그라운드 서비스 mediaPlayback) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
</manifest>

View file

@ -1,31 +0,0 @@
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
namespace VideoDownloader.Mobile;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
// M1 공유 타겟(U1): 다른 앱의 공유 시트에 "PC에서 받기" 로 등록
[IntentFilter(new[] { Intent.ActionSend }, Categories = new[] { Intent.CategoryDefault }, DataMimeType = "text/plain", Label = "PC에서 받기")]
public class MainActivity : MauiAppCompatActivity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
ReceiveShare(Intent);
}
protected override void OnNewIntent(Intent? intent)
{
base.OnNewIntent(intent);
ReceiveShare(intent);
}
private static void ReceiveShare(Intent? intent)
{
if (intent?.Action != Intent.ActionSend) return;
var text = intent.GetStringExtra(Intent.ExtraText);
ShareTarget.Deliver(text);
}
}

View file

@ -1,15 +0,0 @@
using Android.App;
using Android.Runtime;
namespace VideoDownloader.Mobile;
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>

View file

@ -1,9 +0,0 @@
using Foundation;
namespace VideoDownloader.Mobile;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
<dict>
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View file

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>PC 와 페어링하기 위해 QR 코드를 스캔합니다.</string>
<!-- The Mac App Store requires you specify if the app uses encryption. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
<!-- Please indicate <true/> or <false/> here. -->
<!-- Specify the category for your app here. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
<!-- <key>LSApplicationCategoryType</key> -->
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
<key>UIDeviceFamily</key>
<array>
<integer>2</integer>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.lifestyle</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View file

@ -1,15 +0,0 @@
using ObjCRuntime;
using UIKit;
namespace VideoDownloader.Mobile;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}

View file

@ -1,8 +0,0 @@
<maui:MauiWinUIApplication
x:Class="VideoDownloader.Mobile.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:VideoDownloader.Mobile.WinUI">
</maui:MauiWinUIApplication>

View file

@ -1,24 +0,0 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace VideoDownloader.Mobile.WinUI;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View file

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="BF83F8D1-2F12-4749-A41C-B693AFE9B129" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="VideoDownloader.Mobile.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>

View file

@ -1,9 +0,0 @@
using Foundation;
namespace VideoDownloader.Mobile;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View file

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<!-- M3 키 3종: LAN http 통신 허용(페어링·스트리밍) + 로컬 네트워크 사유 설명 -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>PC 와 같은 네트워크에서 페어링하고 라이브러리를 스트리밍합니다.</string>
<key>NSCameraUsageDescription</key>
<string>PC 와 페어링하기 위해 QR 코드를 스캔합니다.</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View file

@ -1,15 +0,0 @@
using ObjCRuntime;
using UIKit;
namespace VideoDownloader.Mobile;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}

View file

@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
You are responsible for adding extra entries as needed for your application.
More information: https://aka.ms/maui-privacy-manifest
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<!--
The entry below is only needed when you're using the Preferences API in your app.
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>

View file

@ -1,8 +0,0 @@
{
"profiles": {
"Windows Machine": {
"commandName": "Project",
"nativeDebugging": false
}
}
}

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

Before

Width:  |  Height:  |  Size: 228 B

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View file

@ -1,15 +0,0 @@
Any raw assets you want to be deployed with your application can be placed in
this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd();
}

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,44 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml -->
<Color x:Key="Primary">#512BD4</Color>
<Color x:Key="PrimaryDark">#ac99ea</Color>
<Color x:Key="PrimaryDarkText">#242424</Color>
<Color x:Key="Secondary">#DFD8F7</Color>
<Color x:Key="SecondaryDarkText">#9880e5</Color>
<Color x:Key="Tertiary">#2B0B98</Color>
<Color x:Key="White">White</Color>
<Color x:Key="Black">Black</Color>
<Color x:Key="Magenta">#D600AA</Color>
<Color x:Key="MidnightBlue">#190649</Color>
<Color x:Key="OffBlack">#1f1f1f</Color>
<Color x:Key="Gray100">#E1E1E1</Color>
<Color x:Key="Gray200">#C8C8C8</Color>
<Color x:Key="Gray300">#ACACAC</Color>
<Color x:Key="Gray400">#919191</Color>
<Color x:Key="Gray500">#6E6E6E</Color>
<Color x:Key="Gray600">#404040</Color>
<Color x:Key="Gray900">#212121</Color>
<Color x:Key="Gray950">#141414</Color>
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
</ResourceDictionary>

View file

@ -1,434 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="IndicatorView">
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
</Style>
<Style TargetType="Border">
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="StrokeShape" Value="Rectangle"/>
<Setter Property="StrokeThickness" Value="1"/>
</Style>
<Style TargetType="BoxView">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="Button">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14,10"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="DatePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Editor">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Entry">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ImageButton">
<Setter Property="Opacity" Value="1" />
<Setter Property="BorderColor" Value="Transparent"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Opacity" Value="0.5" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label" x:Key="Headline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="32" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="Label" x:Key="SubHeadline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="24" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="Picker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RadioButton">
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RefreshView">
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="SearchBar">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SearchHandler">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Shadow">
<Setter Property="Radius" Value="15" />
<Setter Property="Opacity" Value="0.5" />
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
<Setter Property="Offset" Value="10,10" />
</Style>
<Style TargetType="Slider">
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SwipeItem">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
</Style>
<Style TargetType="Switch">
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="ThumbColor" Value="{StaticResource White}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="On">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Off">
<VisualState.Setters>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="TimePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<!--
<Style TargetType="TitleBar">
<Setter Property="MinimumHeightRequest" Value="32"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="TitleActiveStates">
<VisualState x:Name="TitleBarTitleActive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="TitleBarTitleInactive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
-->
<Style TargetType="Page" ApplyToDerivedTypes="True">
<Setter Property="Padding" Value="0"/>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
</Style>
<Style TargetType="Shell" ApplyToDerivedTypes="True">
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
<Setter Property="Shell.NavBarHasShadow" Value="False" />
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="TabbedPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
</ResourceDictionary>

View file

@ -1,16 +0,0 @@
namespace VideoDownloader.Mobile;
/// <summary>M1 공유 타겟(U1): 플랫폼(안드로이드 인텐트)이 전달한 공유 텍스트 큐.</summary>
public static class ShareTarget
{
private static string? _pending;
public static void Deliver(string? sharedText)
{
if (string.IsNullOrWhiteSpace(sharedText)) return;
Volatile.Write(ref _pending, sharedText);
}
/// <summary>대기 중 공유 텍스트를 꺼낸다(없으면 null).</summary>
public static string? Consume() => Interlocked.Exchange(ref _pending, null);
}

View file

@ -1,33 +0,0 @@
namespace VideoDownloader.Mobile;
/// <summary>컴팠니언 루트: 큐 / 라이브러리 / 저장 / 연결 탭 + 공유 타겟 처리.</summary>
public sealed class TabbedRootPage : TabbedPage
{
public TabbedRootPage()
{
ApiClient.Instance.Restore();
var queuePage = new QueuePage();
Children.Add(queuePage);
Children.Add(new LibraryPage());
Children.Add(new DownloadsPage());
Children.Add(new ConnectPage());
Title = "VD 원격";
// M1 공유 타겟(U1): 어떤 탭에 있든/다른 앱에서 공유받으면 URL 을 PC 큐로 전송 후 큐 탭 표시
Device.StartTimer(TimeSpan.FromSeconds(2), () =>
{
_ = HandleShareAsync(queuePage);
return true;
});
}
private static async Task HandleShareAsync(QueuePage queuePage)
{
var shared = ShareTarget.Consume();
if (shared == null || !ApiClient.Instance.IsConfigured) return;
var url = SharePayload.ExtractUrl(shared);
if (url == null) { queuePage.ShowSharedResult("🔗 공유받은 텍스트에 URL 이 없습니다"); return; }
var ok = await ApiClient.Instance.AddAsync(url);
queuePage.ShowSharedResult(ok ? $"🔗 공유 링크 전송됨: {url}" : "❌ 공유 링크 전송 실패");
}
}

View file

@ -1,88 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Windows 에선 ios 워크로드가 없으므로 android 만; macOS 빌드머신에서 ios TFM 추가 (M3) -->
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('OSX'))">net10.0-android;net10.0-ios</TargetFrameworks>
<TargetFrameworks Condition="!$([MSBuild]::IsOSPlatform('OSX'))">net10.0-android</TargetFrameworks>
<!-- CI 배포 APK 는 arm64 단독(실기기 표준). CLI 전역 -p:RuntimeIdentifiers 는 참조 프로젝트
(net8.0 Mobile.Core)까지 전파돼 MSB3030 으로 깨지므로, 게이트 속성을 받아 여기서만 좁힌다. -->
<RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' and '$(CiPackArm64Only)' == 'true'">android-arm64</RuntimeIdentifiers>
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
<OutputType>Exe</OutputType>
<RootNamespace>VideoDownloader.Mobile</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Enable XAML source generation for faster build times and improved performance.
This generates C# code from XAML at compile time instead of runtime inflation.
To disable, remove this line.
For individual files, you can override by setting Inflator metadata:
<MauiXaml Update="MyPage.xaml" Inflator="Default" /> (reverts to defaults: Runtime for Debug, XamlC for Release)
<MauiXaml Update="MyPage.xaml" Inflator="Runtime" /> (force runtime inflation) -->
<MauiXamlInflator>SourceGen</MauiXamlInflator>
<!-- Display name -->
<ApplicationTitle>MediaBrowser</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>com.companyname.videodownloader.mobile</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
</PropertyGroup>
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VideoDownloader.Mobile.Core\VideoDownloader.Mobile.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<!-- U5 완료 알림 -->
<PackageReference Include="Plugin.LocalNotification" Version="11.1.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
<PackageReference Include="ZXing.Net.Maui.Controls" Version="0.4.0" />
<PackageReference Include="CommunityToolkit.Maui.MediaElement" Version="3.2.0" />
<!-- 모달 NavigationPage 를 링커가 싣지 않아 FileNotFoundException 으로 크래시 → 명시 참조 (Android 전용) -->
<PackageReference Include="Xamarin.AndroidX.LocalBroadcastManager" Version="1.1.0.24" Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'" />
</ItemGroup>
</Project>

View file

@ -1,245 +0,0 @@
using System.Linq;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
namespace VideoDownloader.Server;
/// <summary>
/// M0 원격 접근 서버: 데스크톱 앱에 내장되는 Kestrel.
/// QR 페어링 토큰(Bearer) 인증 + 큐 API + 라이브러리/Range 스트리밍 + 제로 설치 웹 컴패니언.
/// LAN 직결(계정/클라우드 없음) — MOBILE_PLAN §3 아키텍처.
/// </summary>
public sealed class ServerHost : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly string _token;
private readonly ServerOptions _options;
private readonly Action? _unsubscribeQueueChanged;
private readonly MdnsAdvertiser? _mdns;
public int Port { get; }
/// <summary>QR 에 담을 페어링 URL 형식: http://&lt;lan-ip&gt;:&lt;port&gt;/#t=&lt;token&gt;</summary>
public string Token => _token;
/// <summary>mDNS 광고(_videodl._tcp) 동작 여부. EnableMdns=true 인데 false 면 MdnsError 를 본다.</summary>
public bool MdnsActive => _mdns != null;
/// <summary>mDNS 시작 실패 원인(발견 보조 기능이라 서버 기동은 막지 않고 여기로 드러냄).</summary>
public Exception? MdnsError { get; }
private ServerHost(WebApplication app, int port, string token, ServerOptions options,
Action? unsubscribeQueueChanged, MdnsAdvertiser? mdns, Exception? mdnsError)
{
_app = app;
Port = port;
_token = token;
_options = options;
_unsubscribeQueueChanged = unsubscribeQueueChanged;
_mdns = mdns;
MdnsError = mdnsError;
}
public static async Task<ServerHost> StartAsync(ServerOptions options)
{
var token = string.IsNullOrEmpty(options.Token)
? WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32))
: options.Token;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = new[] { "--urls", $"http://0.0.0.0:{(options.Port == 0 ? GetFreePort() : options.Port)}" } });
foreach (var prov in builder.Services.Where(sd => sd.ServiceType.FullName!.Contains("LoggerProvider")).ToList())
builder.Services.Remove(prov);
builder.Services.AddSingleton<IQueueAdapter>(options.Queue);
builder.Services.AddSignalR();
var app = builder.Build();
app.UseWebSockets(); // SignalR 웹소켓 전송용 (없으면 롱폴링만 됨)
// Bearer 토큰 인증(상수시간 비교) — / 와 /api/* 전체 적용
app.Use(async (ctx, next) =>
{
if (!ctx.Request.Path.StartsWithSegments("/api/pair") &&
(ctx.Request.Path.StartsWithSegments("/api") || ctx.Request.Path.StartsWithSegments("/stream") ||
ctx.Request.Path.StartsWithSegments("/file") || ctx.Request.Path.StartsWithSegments("/hubs")))
{
var auth = ctx.Request.Headers.Authorization.ToString();
var provided = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth[7..].Trim() : "";
// <video>/<a> 태그는 헤더를 못 실음 → 쿼리 토큰도 허용(LAN 한정)
if (provided.Length == 0 && ctx.Request.Query.TryGetValue("t", out var q)) provided = q.ToString();
// SignalR 클라이언트 관례(AccessTokenProvider)는 access_token 쿼리로 실어 보냄
if (provided.Length == 0 && ctx.Request.Query.TryGetValue("access_token", out var at)) provided = at.ToString();
if (!FixedTimeEquals(provided, token))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
}
await next();
});
// ── 페어링 ──
app.MapPost("/api/pair", async (HttpContext ctx) =>
{
var body = await ctx.Request.ReadFromJsonAsync<PairRequest>();
if (body == null || !FixedTimeEquals(body.Token ?? "", token))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
await ctx.Response.WriteAsJsonAsync(new { ok = true, device = options.DeviceName });
});
// ── 큐 ──
var queue = options.Queue;
app.MapGet("/api/queue", async (HttpContext ctx) =>
await ctx.Response.WriteAsJsonAsync(await queue.GetItemsAsync()));
app.MapPost("/api/queue", async (HttpContext ctx) =>
{
var body = await ctx.Request.ReadFromJsonAsync<QueueAddRequest>();
if (body == null || !Uri.TryCreate(body.Url, UriKind.Absolute, out _))
{
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var id = await queue.AddAsync(body.Url, body.Title);
await ctx.Response.WriteAsJsonAsync(new { id });
});
app.MapPost("/api/queue/{id}/pause", async (string id) => { await queue.PauseAsync(id); Results.Ok(); });
app.MapPost("/api/queue/{id}/resume", async (string id) => { await queue.ResumeAsync(id); Results.Ok(); });
app.MapPost("/api/queue/{id}/cancel", async (string id) => { await queue.CancelAsync(id); Results.Ok(); });
// ── 라이브러리 / 스트리밍 ──
// ── 라이브러리 + 썸네일/해상도(M2) ──
var thumbnailer = options.Thumbnailer ?? ServerOptionsDefaults.DefaultThumbnailer();
var ffmpeg = options.FfmpegPath ?? VideoDownloader.Core.Platform.DesktopMediaMuxer.Default.ResolveFfmpeg(new VideoDownloader.Core.Config.AppConfig());
app.MapGet("/api/library", async (HttpContext ctx) =>
{
var items = MediaLibrary.List(options.LibraryFolder);
if (ffmpeg != null)
{
for (var i = 0; i < items.Count; i++)
{
var res = await thumbnailer.GetResolutionAsync(ffmpeg, Path.Combine(options.LibraryFolder, items[i].FileName));
items[i] = items[i] with { Resolution = res };
}
}
await ctx.Response.WriteAsJsonAsync(items);
});
app.MapGet("/api/library/{id}/thumb", async (string id) =>
{
var file = MediaLibrary.Find(options.LibraryFolder, id);
if (file == null || ffmpeg == null) return Results.NotFound();
var jpg = await thumbnailer.GetThumbnailAsync(ffmpeg, file.Value.FullPath);
return jpg == null ? Results.NotFound() : Results.File(jpg, "image/jpeg");
});
app.MapGet("/stream/{id}", (string id) =>
{
var file = MediaLibrary.Find(options.LibraryFolder, id);
return file == null
? Results.NotFound()
: Results.File(file.Value.FullPath, "video/mp4", enableRangeProcessing: true); // 206 Partial Content → 시크 지원
});
app.MapGet("/file/{id}", (string id) =>
{
var file = MediaLibrary.Find(options.LibraryFolder, id);
return file == null ? Results.NotFound() : Results.File(file.Value.FullPath, "application/octet-stream", file.Value.FileName);
});
// ── 이어보기 위치 동기화 (U3) ──
var positions = new PositionStore(options.LibraryFolder);
app.MapGet("/api/position/{id}", (string id) => Results.Json(new PositionDto(positions.Get(id))));
app.MapPost("/api/position/{id}", async (string id, HttpContext ctx) =>
{
var body = await ctx.Request.ReadFromJsonAsync<PositionDto>();
if (body == null) return Results.BadRequest();
positions.Set(id, body.PositionSec);
return Results.Ok();
});
// ── 실시간 큐 허브 (MOBILE_PLAN §3 /hubs/queue) ──
app.MapHub<QueueHub>("/hubs/queue");
var hubContext = app.Services.GetRequiredService<Microsoft.AspNetCore.SignalR.IHubContext<QueueHub>>();
Action onQueueChanged = () => _ = BroadcastQueueAsync(hubContext, queue);
queue.Changed += onQueueChanged;
// ── 웹 컴패니언 (제로 설치 클라이언트) ──
app.MapGet("/", () => Results.Text(WebCompanion.Html, "text/html; charset=utf-8"));
await app.StartAsync();
var actualPort = options.Port;
if (actualPort == 0)
{
var m = app.Urls.SelectMany(u => System.Text.RegularExpressions.Regex.Matches(u, @":(\d+)"))
.Select(m2 => int.Parse(m2.Groups[1].Value)).FirstOrDefault();
actualPort = m;
}
// ── mDNS 광고 (_videodl._tcp) — 발견 보조. 실패는 MdnsError 로 드러내되 서버는 유지 ──
MdnsAdvertiser? mdns = null;
Exception? mdnsError = null;
if (options.EnableMdns)
{
try
{
mdns = new MdnsAdvertiser(options.DeviceName, actualPort);
mdns.Start();
}
catch (Exception ex)
{
mdnsError = ex;
mdns?.Dispose();
mdns = null;
}
}
return new ServerHost(app, actualPort, token, options, () => queue.Changed -= onQueueChanged, mdns, mdnsError);
}
/// <summary>큐 변경 → 전 클라이언트 푸시. 종료 경합만 무시(그 외 실패는 UnobservedTaskException 로 관찰됨).</summary>
private static async Task BroadcastQueueAsync(Microsoft.AspNetCore.SignalR.IHubContext<QueueHub> hub, IQueueAdapter queue)
{
try
{
var items = await queue.GetItemsAsync();
await hub.Clients.All.SendAsync("queueUpdated", items);
}
catch (ObjectDisposedException)
{
// 서버 종료와 경합한 마지막 푸시 — 클라이언트는 재접속/스냅샷으로 보정
}
}
public async ValueTask DisposeAsync()
{
_unsubscribeQueueChanged?.Invoke();
_mdns?.Dispose();
await _app.StopAsync();
await _app.DisposeAsync();
}
private static int GetFreePort()
{
using var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
l.Start();
var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
private static bool FixedTimeEquals(string a, string b)
{
var ha = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(a));
var hb = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(b));
return CryptographicOperations.FixedTimeEquals(ha, hb);
}
private sealed record PairRequest(string? Token, string? Device);
private sealed record QueueAddRequest(string Url, string? Title);
}

View file

@ -1,149 +0,0 @@
namespace VideoDownloader.Server;
/// <summary>
/// 제로 설치 웹 컴패니언(PairDrop/Jellyfin 패턴): 폰 카메라로 QR 스캔 →
/// 브라우저에서 바로 큐 조회/URL 추가. 토큰은 URL 프래그먼트(#t=)로 전달되어
/// 서버 로그/리퍼러에 남지 않는다.
/// </summary>
public static class WebCompanion
{
public const string Html = """
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VideoDownloader 원격</title>
<style>
:root { color-scheme: dark; }
body { background:#14151d; color:#e6e6ec; font-family:'Segoe UI',system-ui,sans-serif; margin:0; padding:20px 16px 40px; max-width:680px; margin-inline:auto; }
h1 { font-size:1.25rem; display:flex; align-items:center; gap:10px; }
.badge { background:#5b8def; color:#fff; border-radius:8px; padding:2px 10px; font-size:.75rem; }
.card { background:#1e2030; border:1px solid #2a2c42; border-radius:14px; padding:14px; margin:14px 0; }
input[type=text] { width:100%; box-sizing:border-box; padding:12px 14px; border-radius:10px; border:1px solid #3a3c55; background:#14151d; color:#e6e6ec; font-size:15px; }
button { background:#5b8def; color:#fff; border:none; border-radius:10px; padding:11px 18px; font-size:14px; font-weight:600; margin-top:10px; cursor:pointer; }
button.sec { background:#2a2c42; }
.item { background:#1e2030; border:1px solid #2a2c42; border-radius:12px; padding:12px; margin:8px 0; display:flex; justify-content:space-between; align-items:center; gap:10px; }
.item .t { font-weight:600; font-size:.95rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.item .s { color:#9a9ab0; font-size:.8rem; }
.pbar { height:6px; background:#2a2c42; border-radius:4px; overflow:hidden; flex:1; }
.pbar>div { height:100%; background:#5b8def; }
.row { display:flex; gap:8px; align-items:center; }
.muted { color:#9a9ab0; font-size:.85rem; }
a { color:#5b8def; }
video { width:100%; border-radius:12px; background:#000; margin-top:8px; }
.err { color:#ff6b6b; }
</style>
</head>
<body>
<h1>📺 VideoDownloader <span class="badge" id="dev">연결중</span></h1>
<div class="card">
<b>링크 보내 PC에서 받기</b>
<div class="muted">YouTube·웹페이지 URL 을 붙여넣으세요</div>
<input type="text" id="url" placeholder="https://... " autocomplete="off">
<button onclick="addUrl()">받기 요청</button>
<span id="addMsg" class="muted"></span>
</div>
<div class="card">
<b>다운로드 큐</b> <span class="muted" id="qcount"></span>
<div id="queue"><div class="muted">불러오는 중…</div></div>
</div>
<div class="card">
<b>PC 라이브러리</b> <span class="muted">탭하여 스트리밍 재생</span>
<div id="lib"><div class="muted">불러오는 중…</div></div>
</div>
<div class="muted" style="margin-top:24px">같은 Wi-Fi 에서만 접속됩니다 · LAN 직결(계정 없음)</div>
<script>
const token = (location.hash.match(/t=([^&]+)/) || [])[1] || '';
if (!token) document.body.innerHTML = '<div class="card err">토큰이 없습니다. PC 앱의 QR 코드로 다시 접속하세요.</div>';
const H = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
async function api(path, opts) {
const r = await fetch(path, Object.assign({ headers: H }, opts));
if (r.status === 401) { document.getElementById('dev').textContent = '인증 실패'; throw new Error('401'); }
return r;
}
async function addUrl() {
const url = document.getElementById('url').value.trim();
if (!url) return;
document.getElementById('addMsg').textContent = '전송 중…';
try {
const r = await api('/api/queue', { method: 'POST', body: JSON.stringify({ url, title: url }) });
document.getElementById('addMsg').textContent = r.ok ? '✅ PC 대기열에 추가됨' : '❌ 실패';
document.getElementById('url').value = '';
refreshQueue();
} catch (e) {}
}
async function refreshQueue() {
try {
const items = await (await api('/api/queue')).json();
const el = document.getElementById('queue');
document.getElementById('qcount').textContent = items.length + '건';
if (!items.length) { el.innerHTML = '<div class="muted">대기 중인 항목이 없습니다</div>'; return; }
el.innerHTML = items.map(i => `
<div class="item">
<div style="flex:1;min-width:0">
<div class="t">${esc(i.title)}</div>
<div class="row" style="margin-top:6px">
<div class="pbar"><div style="width:${Math.round(i.percent)}%"></div></div>
<span class="s">${esc(i.status)} ${Math.round(i.percent)}%</span>
</div>
</div>
<div class="row">
<button class="sec" onclick="ctl('${i.id}','pause')">⏸</button>
<button class="sec" onclick="ctl('${i.id}','resume')">▶</button>
<button class="sec" onclick="ctl('${i.id}','cancel')">✕</button>
</div>
</div>`).join('');
} catch (e) {}
}
async function ctl(id, action) { try { await api('/api/queue/' + id + '/' + action, { method: 'POST' }); refreshQueue(); } catch (e) {} }
async function refreshLib() {
try {
const items = await (await api('/api/library')).json();
const el = document.getElementById('lib');
if (!items.length) { el.innerHTML = '<div class="muted">저장 폴더가 비었습니다</div>'; return; }
el.innerHTML = items.map(i => `
<div class="item">
<div class="t" style="flex:1">${esc(i.fileName)}</div>
<span class="s">${fmtSize(i.sizeBytes)}</span>
<button class="sec" onclick="play('${i.id}')">▶ 재생</button>
</div>`).join('');
} catch (e) {}
}
function play(id) {
let v = document.getElementById('pv');
if (!v) {
v = document.createElement('video');
v.id = 'pv'; v.controls = true;
document.body.appendChild(v);
v.scrollIntoView({ behavior: 'smooth' });
}
v.src = '/stream/' + id + '?t=' + encodeURIComponent(token); // <video> 는 헤더 불가 → 쿼리 토큰
v.play().catch(()=>{});
}
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function fmtSize(b) { return b > 1048576 ? (b/1048576).toFixed(1) + ' MB' : (b/1024).toFixed(0) + ' KB'; }
(async function init() {
const r = await fetch('/api/pair', { method: 'POST', headers: H, body: JSON.stringify({ token, device: navigator.userAgent.slice(0, 40) }) });
document.getElementById('dev').textContent = r.ok ? '연결됨' : '인증 실패';
refreshQueue(); refreshLib();
setInterval(refreshQueue, 2000); // v1 폴링 (SignalR 는 앱 확장 시)
})();
</script>
</body>
</html>
""";
}

View file

@ -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 { } }
}

View file

@ -1,103 +0,0 @@
using VideoDownloader.App.Browser.Features;
namespace VideoDownloader.Tests.Browser;
/// <summary>페이지 다운로드 추상화(다운로드 매니저 UI 의 로직 코어) — fake op 로 검증.</summary>
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);
}
}

View file

@ -1,46 +0,0 @@
using VideoDownloader.Browser;
namespace VideoDownloader.Tests.Browser;
/// <summary>T3-11: 사이트별 권한 정책 — 기억된 상태 우선, 미기억은 Ask(프롬프트).</summary>
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"));
}

View file

@ -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);
}
}

View file

@ -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<TabItem>();
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, " "));
}
}

View file

@ -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);
}
}

View file

@ -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 { } }
}

View file

@ -1,40 +0,0 @@
using VideoDownloader.Browser;
namespace VideoDownloader.Tests.Browser;
/// <summary>T3-14: 추적 방지 — 블랙리스트 도메인 매칭(서브도메인 포함) + 차단 카운터.</summary>
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);
}
}

View file

@ -1,80 +0,0 @@
using VideoDownloader.Browser;
namespace VideoDownloader.Tests.Browser;
/// <summary>T3-15: 파라미터 제거 복사 + 주소창 진행 상태.</summary>
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));
}
}
/// <summary>T3-15: 탭 전체 네비게이션 진행 집계 — 주소창 진행 바 표시 상태.</summary>
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<string?>();
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);
}
}

View file

@ -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;
/// <summary>
/// 아이콘 tofu 실결함(사용자 보고 "아이콘은 절대 안고쳐지네") 회귀 테스트 —
/// 근본 원인: Shared.xaml 의 암시적 TextBlock 스타일이 FontFamily 를 UiFont 로 강제해,
/// 버튼 ContentPresenter 가 만드는 자동 TextBlock 이 IconFont 상속을 잃고 PUA 글리프가 notdef 가 됐다.
/// 아이콘 버튼의 실제 콘텐츠 TextBlock 이 아이콘 폰트로 해석되는지 실창에서 검증한다.
/// </summary>
[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 { }
}
}
}

View file

@ -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;
/// <summary>
/// 아이콘 tofu 실결함(사용자 보고: "아이콘은 절대 안고쳐지네") 재현 시도 —
/// 테마 사전을 앱과 같은 방식으로 로드했을 때 FluentIcon/IconButton 경유 글리프가
/// 명시적 Segoe Fluent Icons 렌더와 픽셀 단위로 같은지 검증한다.
/// 같지 않고 본문 폰트 렌더(tofu)와 같다면 리소스 해석이 깨진 것이다.
/// </summary>
[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 { }
}
}
}

View file

@ -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;
/// <summary>
/// E2E (T3-11/T3-14): 실제 WebView2 에서 권한 정책 응답과 추적 요청 차단·집계를 검증.
/// TabViewService 와 동일한 배선(PermissionGate/TrackerBlocklist/TrackerCounter)을 사용한다.
/// </summary>
[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 = "<html><body><script>Notification.requestPermission().then(p => { document.title = 'PERM=' + p; });</script></body></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 = "<html><body><p>tracker-e2e</p>" +
"<script src=\"https://www.blocked.test/a.js\"></script>" +
"<script src=\"https://www.blocked.test/b.js\"></script>" +
"<script src=\"https://ok.example.com/c.js\"></script>" +
"</body></html>";
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<CoreWebView2NavigationCompletedEventArgs> h = (_, _) => tcs.TrySetResult();
cv.NavigationCompleted += h;
if (tcs.Task.IsCompleted) return;
await Task.WhenAny(tcs.Task, Task.Delay(15000));
cv.NavigationCompleted -= h;
}
}

View file

@ -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;
/// <summary>
/// E2E (T2-4): 실제 WebView2 + 번들된 Mozilla Readability.js 로
/// 기사 페이지에서 본문을 추출해 리더 뷰로 교체하고 원본 URL 복귀까지 검증.
/// </summary>
[Collection("E2E")]
public class ReaderModeE2ETests
{
private const string ArticleHtml = """
<html><head><title>테스트 기사</title></head><body>
<nav>광고 광고 광고 내비게이션</nav>
<article>
<h1>리더 모드 E2E 기사</h1>
<p>첫 번째 문단입니다. 이 문단은 Readability 가 본문으로 추출해야 합니다. 충분한 길이의 텍스트가 필요하므로 몇 번 더 반복합니다. 본문 본문 본문.</p>
<p>두 번째 문단입니다. 리더 모드는 광고와 내비게이션을 제거하고 본문만 남깁니다. 추출 결과에 이 텍스트가 포함되어야 합니다.</p>
<p>세 번째 문단입니다. 문단이 세 개 이상이면 Readability 의 휴리스틱이 본문으로 판정합니다. 마커텍스트-고유문구-레인보우4747.</p>
</article>
<footer>푸터 링크 모음</footer>
</body></html>
""";
[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<CoreWebView2NavigationCompletedEventArgs>? h = null;
h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
cv.NavigationCompleted += h;
return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
}
}

View file

@ -1,47 +0,0 @@
using System.Windows;
using VideoDownloader.App;
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// E2E: 설정창 "앱 업데이트" 섹션(DEPLOYMENT_PLAN §6 자동 업데이트 v1) —
/// 실제 SettingsWindow 를 띄워 설정값 로드 배선과 잘못된 저장소 형식의 즉시 안내(네트워크 불필요 경로)를 검증.
/// </summary>
[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) { }
}
}
}

View file

@ -1,142 +0,0 @@
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §G 접근성 유즈케이스(docs/UI_USECASES.md §G, UC-A01~A20).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
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 (클릭 대상 식별 기준)");
}
}

View file

@ -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;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §E 다운로드 관리자 유즈케이스(docs/UI_USECASES.md §E, UC-D01~D30).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
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) 요소가 필요하다");
}

View file

@ -1,131 +0,0 @@
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §H 키보드·마우스 인터랙션 유즈케이스(docs/UI_USECASES.md §H, UC-K01~K20).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
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", "페이지 링크 우클릭 '링크 다운로드' 메뉴가 필요하다");
}

Some files were not shown because too many files have changed in this diff Show more