UI 복잡 버전 234 RED→GREEN 완료(docs/UI_USECASES.md §A~§J): 라이브 테마 전환(DynamicResource 전환+ThemeResolver+HighContrast 테마, 재시작 없음), 디자인 토큰 체계·WCAG 대비 결함 5건 수정, 설정창 카테고리 내비/검색 재구성+AppConfig 40여 속성, 다운로드 관리자(속도/ETA/세그먼트맵/전체 일시정지-이어받기/CSV 내보내기/예약 게이트/중복 확인), 라이브러리·아카이브·작업공간·재생목록·즐겨찾기 편집 뷰, 접근성(자동화 속성 30여 개·커스텀 피어·docs/ACCESSIBILITY.md), 인터랙션(휠 클릭 새 탭·XButton 탐색·F12·영역 캡처·프로필 전환), 상태/알림(토스트·알림센터·온보딩·CrashReportDialog). 보강 테스트 35건+실창 E2E 4건(StaDispatcher 기반 — 프록시 설정이 기시작 핸들러를 건드리는 크래시 실결함 수정 포함) — 453/453 GREEN. 이전 미커밋 작업(CI 워크플로·Mdns·QueueHub·UpdateChecker·AGENTS/CLAUDE·빌드 스크립트) 일괄 포함
This commit is contained in:
parent
5a0ce730b2
commit
17f2f91152
88 changed files with 9463 additions and 1127 deletions
56
.claude/hooks/session-brief.sh
Normal file
56
.claude/hooks/session-brief.sh
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#!/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
|
||||
71
.claude/hooks/verify-on-stop.sh
Normal file
71
.claude/hooks/verify-on-stop.sh
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#!/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
|
||||
40
.claude/rules/desktop-wpf.md
Normal file
40
.claude/rules/desktop-wpf.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
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` 로 두면 된다.
|
||||
44
.claude/rules/engine-core.md
Normal file
44
.claude/rules/engine-core.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
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 이므로 인증·경로 검증을 우회하는 엔드포인트를 추가하지 않는다.
|
||||
40
.claude/rules/mobile-maui.md
Normal file
40
.claude/rules/mobile-maui.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
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 결선은 워크로드가 있는 환경에서 확인하도록 사용자에게 알린다.
|
||||
- 화면 동작을 바꿨으면 에뮬레이터에 배포해 확인한 내용을 보고에 적는다.
|
||||
단위 테스트만으로 "됐다"고 하지 않는다.
|
||||
44
.claude/rules/tests.md
Normal file
44
.claude/rules/tests.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
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~<이름>" # 단건
|
||||
```
|
||||
52
.claude/settings.json
Normal file
52
.claude/settings.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"$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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
53
.claude/skills/release/SKILL.md
Normal file
53
.claude/skills/release/SKILL.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
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/VideoDownloader-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` 참고.
|
||||
72
.claude/skills/tdd/SKILL.md
Normal file
72
.claude/skills/tdd/SKILL.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
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 통과"). 기억으로 쓰지 않는다.
|
||||
|
||||
## 막혔을 때
|
||||
|
||||
- 원인을 모르겠으면 추측으로 코드를 바꾸지 말고, 먼저 재현 범위를 좁히는 테스트를 더 쓴다.
|
||||
- 그래도 안 되면 무엇을 시도했고 무엇이 관측됐는지 정리해 사용자에게 보고한다.
|
||||
통과하지 못한 것을 통과한 것처럼 쓰지 않는다.
|
||||
78
.claude/skills/verify/SKILL.md
Normal file
78
.claude/skills/verify/SKILL.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
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>건, 산출물 오염 없음
|
||||
- 문서: <갱신한 파일 / 갱신 불필요>
|
||||
판정: 완료 | 미완료(사유: …)
|
||||
```
|
||||
|
||||
미통과 항목을 숨기거나 완곡하게 쓰지 않는다.
|
||||
83
.editorconfig
Normal file
83
.editorconfig
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# VideoDownloader 코드 스타일
|
||||
# 기존 코드베이스 실측 기준: 4스페이스 · file-scoped namespace · Allman 중괄호 · UTF-8 한글 주석
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = crlf
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{yml,yaml,json,csproj,props,targets,xml,plist}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
# 마크다운은 줄 끝 공백 2칸이 강제 개행이라 자르지 않는다.
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.sh]
|
||||
end_of_line = lf
|
||||
|
||||
[*.cs]
|
||||
# ── using ──
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
csharp_using_directive_placement = outside_namespace:warning
|
||||
|
||||
# ── 네임스페이스/중괄호 ──
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
csharp_prefer_braces = when_multiline:silent
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
|
||||
# ── 최신 C# 문법 (LangVersion latest 전제) ──
|
||||
csharp_style_var_for_built_in_types = false:silent
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_expression_bodied_methods = when_on_single_line:silent
|
||||
csharp_style_expression_bodied_properties = true:suggestion
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||
csharp_style_prefer_switch_expression = true:suggestion
|
||||
csharp_style_throw_expression = true:suggestion
|
||||
csharp_style_conditional_delegate_call = true:suggestion
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
csharp_style_prefer_range_operator = true:suggestion
|
||||
csharp_style_prefer_index_operator = true:suggestion
|
||||
|
||||
# ── this. 미사용 (기존 코드 관행) ──
|
||||
dotnet_style_qualification_for_field = false:suggestion
|
||||
dotnet_style_qualification_for_property = false:suggestion
|
||||
dotnet_style_qualification_for_method = false:suggestion
|
||||
dotnet_style_qualification_for_event = false:suggestion
|
||||
|
||||
# ── 명명 규칙: private 필드 _camelCase ──
|
||||
dotnet_naming_rule.private_fields_underscore.severity = suggestion
|
||||
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_underscore.style = underscore_prefix
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
||||
dotnet_naming_style.underscore_prefix.required_prefix = _
|
||||
dotnet_naming_style.underscore_prefix.capitalization = camel_case
|
||||
|
||||
# ── 상수/공개 멤버 PascalCase ──
|
||||
dotnet_naming_rule.public_members_pascal.severity = suggestion
|
||||
dotnet_naming_rule.public_members_pascal.symbols = public_members
|
||||
dotnet_naming_rule.public_members_pascal.style = pascal_case
|
||||
dotnet_naming_symbols.public_members.applicable_kinds = property,method,event,class,struct,interface,enum
|
||||
dotnet_naming_symbols.public_members.applicable_accessibilities = public,internal,protected
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
# ── 실수 방지: 경고로 올려두는 항목 ──
|
||||
# 사용하지 않는 지역 변수 / 도달 불가 코드 / 비동기 메서드 await 누락
|
||||
dotnet_diagnostic.CS4014.severity = warning
|
||||
dotnet_diagnostic.CS1998.severity = suggestion
|
||||
dotnet_diagnostic.IDE0005.severity = suggestion
|
||||
|
||||
[*.{xaml,axaml}]
|
||||
indent_size = 4
|
||||
# XAML 은 IDE 포매터가 후행 공백을 남기는 경우가 잦아 강제하지 않는다.
|
||||
trim_trailing_whitespace = false
|
||||
41
.gitattributes
vendored
Normal file
41
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# 저장소에는 LF 로 저장하고, 체크아웃 시 플랫폼 기본 규칙을 따른다.
|
||||
# (현재 저장소는 이미 LF 정규화 상태이므로 재정규화 diff 가 발생하지 않는다.)
|
||||
* text=auto
|
||||
|
||||
# ── 줄바꿈을 강제해야 하는 파일 ─────────────────────────────────────
|
||||
# 원격 리눅스/맥 빌드머신에서 bash 로 실행 → CRLF 면 즉시 깨진다.
|
||||
*.sh text eol=lf
|
||||
# Windows 전용 스크립트 · Inno Setup
|
||||
*.ps1 text eol=crlf
|
||||
*.psm1 text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
*.iss text eol=crlf
|
||||
|
||||
# ── 텍스트로 다뤄야 하는 프로젝트 파일 ──────────────────────────────
|
||||
*.cs text diff=csharp
|
||||
*.csproj text
|
||||
*.slnx text
|
||||
*.props text
|
||||
*.targets text
|
||||
*.xaml text
|
||||
*.axaml text
|
||||
*.plist text
|
||||
*.json text
|
||||
*.md text
|
||||
|
||||
# ── 바이너리 (diff/머지 시도 금지) ──────────────────────────────────
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.dll binary
|
||||
*.exe binary
|
||||
*.mp4 binary
|
||||
|
||||
# ── 서드파티 번들: 언어 통계·diff 노이즈에서 제외 ───────────────────
|
||||
VideoDownloader.App/Assets/hls.min.js linguist-vendored
|
||||
VideoDownloader.App/Assets/dash.all.min.js linguist-vendored
|
||||
VideoDownloader.App/Assets/readability.js linguist-vendored
|
||||
*.min.js -diff
|
||||
186
.github/workflows/release.yml
vendored
Normal file
186
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# DEPLOYMENT_PLAN §5 — 태그(v*) 푸시 → 전 아티팩트 매트릭스 빌드 → Draft Release
|
||||
# D1: test → Windows 설치자 · D2: Android APK · D3: Linux tar.gz
|
||||
# D4(macOS 서명·공증)/D5(iOS TestFlight)는 Apple Developer Program 시크릿 등록 후 활성화.
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch: # 수동 실행(리허설)
|
||||
|
||||
env:
|
||||
DOTNET_NOLOGO: true
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||
|
||||
jobs:
|
||||
# ── 관문: 전 플랫폼 빌드 전에 단위+E2E 통과 ──
|
||||
test:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: |
|
||||
8.0.x
|
||||
10.0.x
|
||||
- name: dotnet test
|
||||
run: dotnet test VideoDownloader.Tests -c Release
|
||||
|
||||
# ── Windows 설치자 (x64 / arm64 교차 publish + Inno Setup) ──
|
||||
windows:
|
||||
needs: test
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
- name: Inno Setup 설치
|
||||
run: choco install innosetup -y --no-progress
|
||||
- name: 버전 결정 (태그 v1.2.3 → 1.2.3)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$v = "${{ github.ref_name }}".TrimStart("v")
|
||||
if ($v -notmatch '^\d+\.\d+\.\d+') { $v = "0.0.0" } # workflow_dispatch 리허설
|
||||
"APP_VERSION=$v" >> $env:GITHUB_ENV
|
||||
- name: publish + 설치자
|
||||
shell: pwsh
|
||||
run: ./build/publish.ps1 -Config Release -Version $env:APP_VERSION -Archs ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: win-${{ matrix.arch }}
|
||||
path: out/VideoDownloader-Setup-${{ matrix.arch }}.exe
|
||||
|
||||
# ── Linux 셸 (tar.gz self-contained — Avalonia + Cli) ──
|
||||
linux:
|
||||
needs: test
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- { os: ubuntu-latest, rid: linux-x64 }
|
||||
- { os: ubuntu-24.04-arm, rid: linux-arm64 }
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: |
|
||||
8.0.x
|
||||
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/videodownloader-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.rid }}
|
||||
path: out/videodownloader-${{ matrix.rid }}.tar.gz
|
||||
|
||||
# ── macOS 셸 (tar.gz — D4 서명·공증은 Developer ID 시크릿 등록 후 확장) ──
|
||||
macos:
|
||||
needs: test
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-latest, rid: osx-arm64 }
|
||||
- { os: macos-13, rid: osx-x64 }
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: |
|
||||
8.0.x
|
||||
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/videodownloader-${{ 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/videodownloader-${{ matrix.rid }}.tar.gz
|
||||
|
||||
# ── Android APK (D2 — 서명 키스토어 시크릿 없으면 미서명 APK) ──
|
||||
android:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: microsoft
|
||||
java-version: "21"
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: |
|
||||
8.0.x
|
||||
10.0.x
|
||||
- name: MAUI 워크로드
|
||||
run: dotnet workload install maui-android
|
||||
- name: publish APK
|
||||
env:
|
||||
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASS: ${{ secrets.ANDROID_KEY_PASS }}
|
||||
run: |
|
||||
SIGN_ARGS=""
|
||||
if [ -n "$ANDROID_KEYSTORE_B64" ]; then
|
||||
echo "$ANDROID_KEYSTORE_B64" | base64 -d > vd.keystore
|
||||
SIGN_ARGS="-p:AndroidKeyStore=true -p:AndroidSigningKeyStore=$PWD/vd.keystore -p:AndroidSigningKeyAlias=$ANDROID_KEY_ALIAS -p:AndroidSigningKeyPass=$ANDROID_KEY_PASS -p:AndroidSigningStorePass=$ANDROID_KEY_PASS"
|
||||
else
|
||||
echo "::notice::ANDROID_KEYSTORE 시크릿 없음 — 미서명 APK 생성"
|
||||
fi
|
||||
dotnet publish VideoDownloader.Mobile -f net10.0-android -c Release \
|
||||
-p:AndroidPackageFormat=apk -p:RuntimeIdentifiers=android-arm64 $SIGN_ARGS \
|
||||
-o out/android
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: out/android/*-Signed.apk
|
||||
if-no-files-found: warn
|
||||
|
||||
# ── iOS TestFlight (D5 — Apple Developer Program 가입 후 저장소 변수 ENABLE_IOS=true 로 활성화) ──
|
||||
ios:
|
||||
if: vars.ENABLE_IOS == 'true'
|
||||
needs: test
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- name: MAUI 워크로드
|
||||
run: dotnet workload install maui-ios
|
||||
- name: publish ipa (배포 인증서/프로비저닝 시크릿 필요)
|
||||
run: |
|
||||
dotnet publish VideoDownloader.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
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: out/ios/**/*.ipa
|
||||
|
||||
# ── Draft Release (QA 후 수동 Publish 승격) ──
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [test, windows, linux, macos, android]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
files: dist/**
|
||||
97
.gitignore
vendored
97
.gitignore
vendored
|
|
@ -1,13 +1,88 @@
|
|||
bin/
|
||||
obj/
|
||||
out/
|
||||
publish/
|
||||
*.user
|
||||
.vs/
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# VideoDownloader
|
||||
# .NET 8 : App(WPF) · Avalonia · Browser · Cli · Core · Server · Tests
|
||||
# .NET 10 : Mobile(MAUI, android/ios) · Mobile.Core
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── 빌드 중간 산출물 ────────────────────────────────────────────────
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
artifacts/
|
||||
*.binlog
|
||||
TestResults/
|
||||
app.log
|
||||
requests.log
|
||||
.env*
|
||||
*.env
|
||||
msbuild.log
|
||||
*.tlog
|
||||
|
||||
# ── 배포 산출물 (build/publish.ps1 → publish/, out/ · build-remote.sh → out/remote/) ──
|
||||
# build/ 자체는 빌드 스크립트 소스이므로 추적한다.
|
||||
/out/
|
||||
/publish/
|
||||
|
||||
# ── 모바일 패키지 · 서명키 ──────────────────────────────────────────
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
*.app.zip
|
||||
*.keystore
|
||||
*.jks
|
||||
*.mobileprovision
|
||||
*.p12
|
||||
*.pfx
|
||||
*.snk
|
||||
|
||||
# ── 테스트 · 커버리지 ───────────────────────────────────────────────
|
||||
[Tt]est[Rr]esults/
|
||||
*.trx
|
||||
*.coverage
|
||||
*.coverlet.json
|
||||
coverage*.xml
|
||||
coverage*.json
|
||||
*.received.*
|
||||
|
||||
# ── 실행 중 생성되는 로그 · 런타임 데이터 ───────────────────────────
|
||||
*.log
|
||||
positions.json
|
||||
library.json
|
||||
|
||||
# ── 다운로드 테스트 결과물 (엔진 검증 중 루트에 떨어질 수 있음) ─────
|
||||
*.mp4
|
||||
*.m3u8
|
||||
*.ts
|
||||
*.part
|
||||
*.ytdl
|
||||
|
||||
# ── 자격 증명 (.env.example 템플릿만 추적) ──────────────────────────
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
secrets.json
|
||||
appsettings.*.local.json
|
||||
|
||||
# ── NuGet ───────────────────────────────────────────────────────────
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
packages/
|
||||
.packages/
|
||||
|
||||
# ── IDE · 에디터 ────────────────────────────────────────────────────
|
||||
.vs/
|
||||
.idea/
|
||||
*.user
|
||||
*.suo
|
||||
*.userprefs
|
||||
*.sln.docstates
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
|
||||
# ── Claude Code (프로젝트 공용 설정은 추적, 로컬/생성물만 제외) ──
|
||||
# settings.json · hooks/ · rules/ · skills/ 는 팀 공유 자산이므로 커밋한다. docs/HARNESS.md 참고.
|
||||
.claude/settings.local.json
|
||||
.claude/.verify-stamp
|
||||
CLAUDE.local.md
|
||||
|
||||
# ── OS (macOS · Linux 원격 빌드머신 포함) ───────────────────────────
|
||||
.DS_Store
|
||||
._*
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
|
|
|||
90
AGENTS.md
Normal file
90
AGENTS.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# AGENTS.md — VideoDownloader 에이전트 작업 계약
|
||||
|
||||
이 파일은 도구 중립 표준(AGENTS.md)이다. Claude Code · Codex · Cursor 등 어떤 코딩 에이전트든
|
||||
작업 시작 시 이 파일을 읽고, 여기 적힌 명령·규약·완료 기준을 따른다.
|
||||
Claude Code 는 `CLAUDE.md` 가 이 파일을 `@AGENTS.md` 로 임포트해 함께 읽는다.
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
브라우저 내장 데스크톱 영상 다운로더 + 모바일 컴패니언. 앱 내 브라우저가 재생 중인 미디어
|
||||
(HLS/DASH/MP4)를 감지 → 쿠키·Referer 를 물려받아 다운로드 → 내장 서버로 휴대폰에 스트리밍/전송.
|
||||
|
||||
| 프로젝트 | 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 |
|
||||
|
||||
## 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 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 폐쇄루프
|
||||
|
||||
이 저장소는 **RED → GREEN → 증거** 사이클로 개발해 왔다. 이 순서를 지킨다.
|
||||
|
||||
1. **RED**: 변경 전에 실패하는 테스트를 먼저 쓴다. 실패를 눈으로 확인한다.
|
||||
2. **GREEN**: 최소 구현으로 통과시킨다. `dotnet test` 전체가 통과해야 한다.
|
||||
3. **증거**: UI·플랫폼 동작을 바꿨다면 테스트만으로 부족하다. 실제 앱 실행/에뮬레이터/스크린샷으로
|
||||
확인하고, 무엇을 어떻게 확인했는지 보고에 적는다.
|
||||
4. **문서 동기화**: 계획 대비 구현 상태는 `docs/IMPLEMENTATION_AUDIT.md`, 작업 항목은
|
||||
`docs/BACKLOG.md` 체크박스에 반영한다.
|
||||
|
||||
## 4. 완료 기준 (Definition of Done)
|
||||
|
||||
아래를 모두 만족하기 전에는 "완료"라고 보고하지 않는다.
|
||||
|
||||
- [ ] `dotnet test VideoDownloader.Tests` — **실패 0건**. 테스트 수는 줄지 않는다(180 이상).
|
||||
- [ ] 빌드 **오류 0**. 새로 만든 경고를 남기지 않는다.
|
||||
(기존 경고: `MainWindow.xaml.cs`·테스트의 CS8602 몇 건, xUnit 분석기 경고 — 늘리지 말 것)
|
||||
- [ ] 동작을 바꿨으면 그 동작을 검증하는 테스트가 있다.
|
||||
- [ ] UI/플랫폼 변경이면 실행 증거가 있다.
|
||||
- [ ] 관련 문서(`docs/`)를 갱신했다.
|
||||
|
||||
실패한 것을 통과한 것처럼 보고하지 않는다. 막혔으면 막혔다고 쓴다.
|
||||
|
||||
## 5. 코드 규약
|
||||
|
||||
- **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`, 가짜 폴백 데이터 금지.
|
||||
|
||||
## 6. 경계 — 하지 말 것
|
||||
|
||||
- `.env` 를 커밋하거나 그 값을 출력·로그·문서에 남기지 않는다. 실제 Cloudflare 토큰과
|
||||
빌드머신 SSH 비밀번호가 들어 있다. 템플릿은 `.env.example` 만 추적한다.
|
||||
- `out/`, `publish/`, `bin/`, `obj/` 는 산출물이다. 커밋하지 않고, 내용을 소스로 참조하지 않는다.
|
||||
- `VideoDownloader.App/Assets/*.min.js`(hls/dash/readability)는 서드파티 번들이다. 수정 금지.
|
||||
- 사용자가 요청하지 않은 커밋·푸시·배포를 하지 않는다.
|
||||
- 대규모 리팩터링을 임의로 시작하지 않는다. `MainWindow.xaml.cs` 는 86KB 로 비대하지만,
|
||||
분할은 별도 승인 후 진행한다.
|
||||
|
||||
## 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` 로 사용자 데이터 경로를 격리한다. 실제 사용자 폴더를 건드리지 않는다.
|
||||
38
CLAUDE.md
Normal file
38
CLAUDE.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
@AGENTS.md
|
||||
|
||||
# Claude Code 전용 지침
|
||||
|
||||
위 계약(AGENTS.md)이 기본이다. 아래는 이 저장소에서 Claude Code 가 쓰는 하네스 설명이다.
|
||||
전체 설계와 운영법은 `docs/HARNESS.md` 에 있다.
|
||||
|
||||
## 자동으로 도는 검증 (내가 끌 수 없는 층)
|
||||
|
||||
- **Stop 훅** — 턴을 끝내려 할 때 소스가 바뀌었으면 `dotnet test` 를 돌린다.
|
||||
실패하면 종료가 차단되고 실패 내용이 돌아온다. **테스트를 깨둔 채로 턴을 끝낼 수 없다.**
|
||||
차단되면 변명하지 말고 고친다. 고칠 수 없으면 왜 못 고치는지 사용자에게 보고한다.
|
||||
- **SessionStart 훅** — 세션 시작 시 브랜치·변경 파일·마지막 검증 상태를 컨텍스트에 넣는다.
|
||||
이 브리핑을 먼저 읽고 지금 저장소가 어떤 상태인지 파악한 뒤 움직인다.
|
||||
|
||||
훅 때문에 작업이 막히면 훅을 우회하거나 무력화하지 않는다. 원인을 고친다.
|
||||
|
||||
## 스킬 — 절차는 여기 있다
|
||||
|
||||
| 스킬 | 언제 |
|
||||
|---|---|
|
||||
| `/tdd` | 기능 추가·버그 수정. RED→GREEN→증거 사이클을 끝까지 진행 |
|
||||
| `/verify` | "다 됐나?" 확인. 빌드+테스트+문서 동기화까지 완료 기준 전체 점검 |
|
||||
| `/release` | Windows 설치자 / 원격 크로스플랫폼 빌드 |
|
||||
|
||||
절차를 기억에 의존해 재구성하지 말고 해당 스킬을 부른다.
|
||||
|
||||
## 이 저장소에서의 작업 태도
|
||||
|
||||
- **한국어로 답한다.** 코드 식별자와 기술 용어는 원문 유지.
|
||||
- **계획이 필요한 크기면 플랜 모드**를 먼저 쓴다. 기준: 파일 3개 이상 변경, 또는 공개 API·
|
||||
스레딩·dispose 순서 변경, 또는 어디를 고쳐야 할지 아직 모를 때.
|
||||
- **탐색은 서브에이전트에게** 맡긴다. "이 기능 어디 있지" 류의 넓은 검색은 Explore 로 위임하고
|
||||
결론만 받는다. 파일 위치를 이미 아는 단건 확인은 직접 한다.
|
||||
- **거대 파일 주의**: `MainWindow.xaml.cs`(86KB)·`MainWindow.xaml`(55KB)은 통째로 읽지 말고
|
||||
Grep 으로 위치를 찾아 필요한 구간만 Read 한다.
|
||||
- **테스트를 지우거나 `Skip` 으로 무력화해서 GREEN 을 만들지 않는다.** 그건 회귀를 숨기는 것이다.
|
||||
- 작업 결과를 보고할 때 테스트 수를 실제 출력에서 인용한다. 기억으로 쓰지 않는다.
|
||||
26
Directory.Build.props
Normal file
26
Directory.Build.props
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<Project>
|
||||
|
||||
<!--
|
||||
전 프로젝트 공통 설정.
|
||||
개별 csproj 의 동일 속성이 우선하므로, 여기 값은 "새 프로젝트의 기본값" 역할을 한다.
|
||||
TargetFramework 는 프로젝트마다 다르므로(net8.0 / net8.0-windows / net10.0-android)
|
||||
여기서 지정하지 않는다.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
|
||||
<!-- 한글 파일명·정렬을 다루므로 InvariantGlobalization 을 절대 켜지 않는다. -->
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
|
||||
<!-- 재현 가능한 빌드 + VS 증분 빌드 가속 -->
|
||||
<Deterministic>true</Deterministic>
|
||||
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
|
||||
|
||||
<!-- NuGet 감사: 알려진 취약 패키지를 복원 시 경고 -->
|
||||
<NuGetAudit>true</NuGetAudit>
|
||||
<NuGetAuditMode>direct</NuGetAuditMode>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
141
README.md
141
README.md
|
|
@ -1,71 +1,92 @@
|
|||
# 비디오 다운로더 (HLS / MP4)
|
||||
# VideoDownloader
|
||||
|
||||
maccms 계열 스트리밍 사이트(tvchak208 등)의 영상을 다운로드하는 도구입니다.
|
||||
HLS(`.m3u8`) 분할 재생과 직접 MP4 모두 지원합니다.
|
||||
브라우저를 내장한 데스크톱 영상 다운로더와, 같은 엔진을 공유하는 모바일 컴패니언 앱.
|
||||
|
||||
## 기능
|
||||
- **페이지 URL 자동 분석**: 재생페이지 URL만 넣으면 `player_aaaa` 설정 / 정규식 / iframe 플레이어에서 m3u8·mp4 주소를 자동 추출
|
||||
- **HLS 완전 지원**: 마스터 재생목록(최고 화질 자동 선택), **AES-128 복호화**, **fMP4(`#EXT-X-MAP`)** 초기화 세그먼트
|
||||
- **병렬 다운로드 + 이어받기**: 도중에 끊겨도 재실행 시 받은 분할은 건너뜀
|
||||
- **ffmpeg 자동 리먹스**: TS / fMP4 → MP4 (`-c copy`, 무손실·빠름)
|
||||
- **커스텀 헤더**: `--referer` 등으로 CDN 토큰 인증 대응
|
||||
앱 안의 브라우저로 사이트를 열면 재생되는 미디어(HLS `.m3u8` / DASH `.mpd` / MP4 직링크)를
|
||||
자동으로 감지해 목록에 띄우고, 쿠키·Referer 를 그대로 물려 받아 내려받는다.
|
||||
받은 영상은 내장 서버를 통해 같은 네트워크의 휴대폰에서 바로 재생하거나 저장할 수 있다.
|
||||
|
||||
## 준비
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
# ffmpeg 권장 (https://ffmpeg.org). PATH에 있으면 자동 인식됨.
|
||||
## 저장소 구조
|
||||
|
||||
| 프로젝트 | 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 크로스플랫폼 셸 (진행 중 — `docs/DEPLOYMENT_PLAN.md` Gate #1' 참고) |
|
||||
| `VideoDownloader.Cli` | net8.0 | 엔진 검증용 개발 도구 (`--dashparse` 등) |
|
||||
| `VideoDownloader.Tests` | net8.0-windows | xUnit 전체 테스트 (Core/App/Server/Mobile.Core + E2E) |
|
||||
|
||||
그 밖에:
|
||||
|
||||
- `build/` — 배포 스크립트. `publish.ps1`(Windows 설치자), `installer.iss`(Inno Setup), `remote/`(맥·리눅스 원격 빌드)
|
||||
- `docs/` — 기획·감사 문서
|
||||
- `.env.example` — 배포/원격빌드 자격 증명 템플릿. `.env` 로 복사해 채운다 (`.env` 는 커밋되지 않음)
|
||||
|
||||
## 개발 환경
|
||||
|
||||
- **.NET 8 SDK** + **.NET 10 SDK** (모바일 및 Avalonia 12 가 10 을 요구)
|
||||
- Windows 11 (데스크톱 앱은 WPF + WebView2 기반이라 Windows 전용)
|
||||
- **ffmpeg** — PATH 에 있으면 자동 인식. 없으면 리먹스 단계에서 실패
|
||||
- 모바일 빌드 시: `dotnet workload install maui-android` (iOS 는 macOS 빌드머신에서만)
|
||||
|
||||
## 자주 쓰는 명령
|
||||
|
||||
```powershell
|
||||
# 테스트 — 가장 먼저 이걸로 상태 확인 (Mobile 제외 전 영역 커버)
|
||||
dotnet test VideoDownloader.Tests
|
||||
|
||||
# 데스크톱 앱 빌드 / 실행
|
||||
dotnet build VideoDownloader.App
|
||||
dotnet run --project VideoDownloader.App
|
||||
|
||||
# 엔진 단독 검증
|
||||
dotnet run --project VideoDownloader.Cli -- --dashparse "https://.../manifest.mpd"
|
||||
|
||||
# 모바일 (Android 워크로드 필요)
|
||||
dotnet build VideoDownloader.Mobile -f net10.0-android
|
||||
```
|
||||
|
||||
## 사용법
|
||||
> 솔루션 전체 빌드(`dotnet build VideoDownloader.slnx`)는 MAUI 워크로드가 설치돼 있어야 통과한다.
|
||||
> 워크로드 없이 작업할 때는 프로젝트를 개별 지정한다.
|
||||
|
||||
### 1) 가장 간단 — 페이지 URL만
|
||||
```bash
|
||||
python downloader.py "https://tvchak208.com/index.php/vod/play/id/123367/sid/1/nid/1.html" -o 영상제목
|
||||
## 배포
|
||||
|
||||
```powershell
|
||||
# Windows 설치자 (x64 + arm64) → out/VideoDownloader-Setup-{arch}.exe
|
||||
./build/publish.ps1 -Config Release -Version 1.0.0
|
||||
|
||||
# macOS / Linux 원격 빌드 (.env 의 빌드머신 자격 증명 사용) → out/remote/<rid>/
|
||||
bash build/remote/build-remote.sh all
|
||||
```
|
||||
|
||||
### 2) m3u8 주소를 직접 넘기기 (가장 확실, 아래 참고)
|
||||
```bash
|
||||
python downloader.py "https://cdn.example.com/.../index.m3u8" -o 영상제목 --referer "https://tvchak208.com/"
|
||||
```
|
||||
`out/`, `publish/` 는 산출물 디렉터리라 커밋되지 않는다.
|
||||
|
||||
### 3) 추가 헤더가 필요한 경우
|
||||
```bash
|
||||
python downloader.py "https://.../index.m3u8" -o 영상제목 \
|
||||
--referer "https://tvchak208.com/" \
|
||||
-H "Origin: https://tvchak208.com"
|
||||
```
|
||||
### CI (GitHub 공개 후)
|
||||
|
||||
옵션 요약
|
||||
| 옵션 | 설명 |
|
||||
`.github/workflows/release.yml` — 태그(`v*`) 푸시 시 테스트 관문 통과 후
|
||||
Windows 설치자(x64/arm64)·Linux/macOS tar.gz·Android APK 를 빌드해 **Draft Release** 로 올린다
|
||||
(QA 후 수동 Publish). macOS 서명·공증(D4)과 iOS TestFlight(D5)는 Apple Developer Program
|
||||
가입 + 시크릿 등록 후 활성화한다 — 상세는 `docs/DEPLOYMENT_PLAN.md` §5·§7.
|
||||
|
||||
Android 자동 업데이트는 [Obtainium](https://github.com/ImranR98/Obtainium) 을 지원한다:
|
||||
저장소 공개 후 `build/obtainium.json` 의 `<owner>/<repo>` 를 치환하고, 폰에서
|
||||
`obtainium://add/https://github.com/<owner>/<repo>` 링크 한 번으로 등록된다.
|
||||
|
||||
앱 내 업데이트 확인(데스크톱)은 설정 → "앱 업데이트"에 GitHub 저장소(`owner/repo`)를
|
||||
넣으면 GitHub Releases 최신 태그와 비교해 릴리스 페이지를 열어 준다.
|
||||
|
||||
## 문서
|
||||
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| `-o, --output` | 출력 파일명 (확장자 생략 가능, 기본 `video`) |
|
||||
| `--referer` | Referer 헤더. 403/401 날 때 페이지 주소를 지정 |
|
||||
| `-H "K: V"` | 추가 헤더 (여러 개 가능) |
|
||||
| `-c, --concurrency` | 동시 연결 수 (기본 8) |
|
||||
| `--user-agent` | User-Agent 재정의 |
|
||||
| `--keep-parts` | 병합 후 분할 임시파일 유지(디버그) |
|
||||
|
||||
---
|
||||
|
||||
## 이 사이트에서 m3u8 주소 직접 구하기 (추천)
|
||||
|
||||
페이지 자동 추출이 안 될 때(사이트마다 난독화가 다름) 가장 확실한 방법:
|
||||
|
||||
1. Chrome/Edge로 재생페이지 열기
|
||||
2. `F12` → **Network(네트워크)** 탭
|
||||
3. 필터에 **`m3u8`** 입력
|
||||
4. 영상 **재생** 버튼 클릭
|
||||
5. 목록에 뜬 `.m3u8` 요청 위에서 **우클릭 → Copy → Copy link address**
|
||||
6. 그 주소를 다운로더에 넘김:
|
||||
```bash
|
||||
python downloader.py "복사한_m3u8_주소" -o 영상제목 --referer "https://tvchak208.com/"
|
||||
```
|
||||
|
||||
> 팁: `index.m3u8`(마스터)과 `*.m3u8`(미디어)이 모두 뜰 수 있는데, 마스터를 넘겨도 **최고 화질을 자동 선택**합니다. 가장 위쪽 m3u8을 고르면 됩니다.
|
||||
> 403/401 에러가 나면 반드시 `--referer`로 재생페이지 주소를 지정하세요.
|
||||
|
||||
## 문제 해결
|
||||
- **`페이지에서 영상 주소를 찾지 못했습니다`** → 위 "m3u8 주소 직접 구하기" 방법 사용
|
||||
- **403/401** → `--referer "페이지 주소"` 추가 (필요시 `-H "Origin: ..."` 도 함께)
|
||||
- **`암호화 영상입니다. pycryptodome 필요`** → `pip install pycryptodome`
|
||||
- **MP4로 안 변환됨** → ffmpeg 설치 확인 (`ffmpeg -version`)
|
||||
| [AGENTS.md](AGENTS.md) | AI 코딩 에이전트 작업 계약 — 명령·규약·완료 기준 (사람이 읽어도 온보딩 문서) |
|
||||
| [docs/HARNESS.md](docs/HARNESS.md) | 개발 하네스 운영법 — 자동 검증 게이트, 규칙/스킬 구조 |
|
||||
| [docs/BROWSER_FEATURES_PLAN.md](docs/BROWSER_FEATURES_PLAN.md) | 데스크톱 브라우저 기능 계획 (v2) |
|
||||
| [docs/MOBILE_PLAN.md](docs/MOBILE_PLAN.md) | 모바일 앱 + PC↔모바일 연동 계획 (v1) |
|
||||
| [docs/DEPLOYMENT_PLAN.md](docs/DEPLOYMENT_PLAN.md) | Win/macOS/Linux/모바일 배포 체계 |
|
||||
| [docs/IMPLEMENTATION_AUDIT.md](docs/IMPLEMENTATION_AUDIT.md) | 계획 대비 구현 감사 |
|
||||
| [docs/BACKLOG.md](docs/BACKLOG.md) | 작업 이력 및 백로그 |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using VideoDownloader.App.Core;
|
||||
using VideoDownloader.App.Services;
|
||||
|
||||
namespace VideoDownloader.App;
|
||||
|
||||
|
|
@ -10,33 +12,133 @@ public partial class App : Application
|
|||
private static string CrashLogPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "crash.log");
|
||||
|
||||
// #14 글로벌 예외 핸들러: 잡히지 않은 예외를 crash.log 에 기록해 조용히 삼켜지는 것을 방지.
|
||||
// #14 글로벌 예외 핸들러: 잡히지 않은 예외를 crash.log 에 기록 + UC-N18 크래시 리포트 다이얼로그 표시.
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
DispatcherUnhandledException += (_, de) => { LogCrash(de.Exception); de.Handled = true; };
|
||||
DispatcherUnhandledException += (_, de) => { LogCrash(de.Exception); CrashReportDialog.Show(de.Exception); de.Handled = true; };
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, ue) => LogCrash(ue.ExceptionObject as Exception);
|
||||
TaskScheduler.UnobservedTaskException += (_, ue) => { LogCrash(ue.Exception); ue.SetObserved(); };
|
||||
base.OnStartup(e);
|
||||
|
||||
ApplyTheme(ConfigStore.Load().Theme);
|
||||
var config = ConfigStore.Load();
|
||||
// UC-A04: OS 고대비 모드가 켜져 있고 별도 선택이 없으면 고대비 테마로 시작
|
||||
if (SystemParameters.HighContrast && config.Theme is "dark" or "light" or "system")
|
||||
config.Theme = "highcontrast";
|
||||
ApplyTheme(config.Theme, config.CustomThemePath);
|
||||
ApplyAccent(config.AccentColor);
|
||||
ApplyFontSettings();
|
||||
ApplyReduceMotion(config.ReduceMotion);
|
||||
new MainWindow().Show();
|
||||
}
|
||||
|
||||
/// <summary>테마 색(dark/light) + Shared(스타일) 리소스사전을 App.Resources 에 주입.
|
||||
/// <summary>테마 색(dark/light/system/highcontrast/사용자파일) + Shared(스타일) 리소스사전을 App.Resources 에 주입.
|
||||
/// 주의: 반드시 색(brush) 사전을 스타일 사전보다 먼저 로드해야 Shared 의 템플릿이
|
||||
/// {StaticResource GlassBg} 등을 해석할 수 있다.</summary>
|
||||
public static void ApplyTheme(string theme)
|
||||
/// {DynamicResource GlassBg} 등을 해석할 수 있다. 인덱스 0 자리를 교체해 라이브 전환이 동작한다(UC-T04).</summary>
|
||||
public static void ApplyTheme(string theme, string? customThemePath = null)
|
||||
{
|
||||
var (effective, customFile) = Services.ThemeResolver.Resolve(theme, customThemePath);
|
||||
var dicts = Current.Resources.MergedDictionaries;
|
||||
dicts.Clear();
|
||||
var themeSrc = theme.Equals("light", StringComparison.OrdinalIgnoreCase)
|
||||
? "pack://application:,,,/Themes/Light.xaml" : "pack://application:,,,/Themes/Dark.xaml";
|
||||
// 어셈블리 한정 pack URI — 테스트 호스트(타 어셈블리 Application)에서도 App 의 테마를 찾는다.
|
||||
var themeSrc = effective switch
|
||||
{
|
||||
"light" => "pack://application:,,,/VideoDownloader;component/Themes/Light.xaml",
|
||||
"highcontrast" => "pack://application:,,,/VideoDownloader;component/Themes/HighContrast.xaml",
|
||||
"custom" when customFile != null => customFile, // UC-T12 사용자 테마 파일(절대경로)
|
||||
_ => "pack://application:,,,/VideoDownloader;component/Themes/Dark.xaml",
|
||||
};
|
||||
dicts.Add(new ResourceDictionary { Source = new Uri(themeSrc, UriKind.Absolute) });
|
||||
dicts.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/Themes/Shared.xaml", UriKind.Absolute) });
|
||||
dicts.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/VideoDownloader;component/Themes/Shared.xaml", UriKind.Absolute) });
|
||||
Current.Resources["EffectiveTheme"] = effective;
|
||||
}
|
||||
|
||||
/// <summary>현재 적용된 테마가 라이트인지 (DWM 타이틀바 색 연동용).</summary>
|
||||
public static bool IsLightTheme => ConfigStore.Load().Theme.Equals("light", StringComparison.OrdinalIgnoreCase);
|
||||
/// <summary>현재 적용된 테마(dark/light/highcontrast/custom) — DWM 타이틀바·판정용.</summary>
|
||||
public static string CurrentTheme => Current.Resources["EffectiveTheme"] as string ?? "dark";
|
||||
|
||||
/// <summary>현재 적용된 테마가 밝은 계열인지(DWM 타이틀바 색 연동용).</summary>
|
||||
public static bool IsLightTheme => CurrentTheme is "light" or "custom";
|
||||
|
||||
/// <summary>UC-T08 사용자 강조색 적용 — 현재 테마 사전의 AccentBrush/Gradient/Fade/AccentText 를
|
||||
/// 파생색으로 교체한다. DynamicResource 로 물린 모든 컨트롤(버튼·진행바)에 즉시 반영된다.</summary>
|
||||
public static void ApplyAccent(string? hex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hex) || !TryParseColor(hex, out var color)) return;
|
||||
var dict = Current.Resources.MergedDictionaries.FirstOrDefault(d => d.Contains("AccentBrush"));
|
||||
if (dict == null) return;
|
||||
var base_ = Color.FromArgb(0xFF, color.R, color.G, color.B);
|
||||
dict["AccentBrush"] = new SolidColorBrush(base_);
|
||||
dict["AccentTextBrush"] = new SolidColorBrush(Lighten(base_, 0.12));
|
||||
var grad = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new Point(0, 0),
|
||||
EndPoint = new Point(1, 1),
|
||||
GradientStops =
|
||||
{
|
||||
new GradientStop(base_, 0),
|
||||
new GradientStop(Lighten(base_, 0.18), 1),
|
||||
},
|
||||
};
|
||||
dict["AccentGradient"] = grad;
|
||||
var fade = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new Point(0, 0),
|
||||
EndPoint = new Point(0, 1),
|
||||
GradientStops =
|
||||
{
|
||||
new GradientStop(Color.FromArgb(0x66, base_.R, base_.G, base_.B), 0),
|
||||
new GradientStop(Color.FromArgb(0x00, base_.R, base_.G, base_.B), 1),
|
||||
},
|
||||
};
|
||||
dict["AccentFadeBrush"] = fade;
|
||||
}
|
||||
|
||||
/// <summary>UC-F16 폰트 설정 즉시 적용 — AppConfig 의 폰트/크기/확대 배율으로 UiFont·FontSize.Base 토큰 교체.</summary>
|
||||
public static void ApplyFontSettings()
|
||||
{
|
||||
var c = ConfigStore.Load();
|
||||
var stack = string.IsNullOrWhiteSpace(c.UiFontFamily)
|
||||
? "Pretendard, Segoe UI Variable Text, Segoe UI"
|
||||
: c.UiFontFamily.Trim();
|
||||
var fallback = string.IsNullOrWhiteSpace(c.UiFontKoreanFallback) ? "Malgun Gothic" : c.UiFontKoreanFallback.Trim();
|
||||
Current.Resources["UiFont"] = new FontFamily($"{stack}, {fallback}");
|
||||
var scale = Math.Clamp(c.UiFontScale <= 0 ? 1.0 : c.UiFontScale, 0.5, 2.0); // UC-A13 최대 200%
|
||||
var size = Math.Clamp(c.UiFontSize * scale, 9, 40);
|
||||
Current.Resources["FontSize.Base"] = size;
|
||||
Current.Resources["FontSize.Small"] = size - 1.5;
|
||||
Current.Resources["FontSize.Large"] = size + 3;
|
||||
Current.Resources["FontSize.XLarge"] = size + 9;
|
||||
}
|
||||
|
||||
/// <summary>UC-A16/L21 애니메이션 축소 — Duration 토큰을 0으로 만들어 전환을 즉시 완결시킨다.</summary>
|
||||
public static void ApplyReduceMotion(bool reduce)
|
||||
{
|
||||
Current.Resources["ReduceMotion"] = reduce;
|
||||
Current.Resources["Duration.Fast"] = new System.Windows.Duration(TimeSpan.Zero);
|
||||
Current.Resources["Duration.Normal"] = new System.Windows.Duration(reduce ? TimeSpan.Zero : TimeSpan.FromSeconds(0.3));
|
||||
if (!reduce) Current.Resources["Duration.Fast"] = new System.Windows.Duration(TimeSpan.FromSeconds(0.15));
|
||||
}
|
||||
|
||||
private static bool TryParseColor(string hex, out Color color)
|
||||
{
|
||||
hex = hex.Trim().TrimStart('#');
|
||||
if (hex.Length == 6)
|
||||
{
|
||||
try
|
||||
{
|
||||
color = Color.FromRgb(
|
||||
Convert.ToByte(hex[..2], 16), Convert.ToByte(hex[2..4], 16), Convert.ToByte(hex[4..6], 16));
|
||||
return true;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
color = Colors.Transparent;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Color Lighten(Color c, double amount) => Color.FromRgb(
|
||||
(byte)Math.Min(255, c.R + (255 - c.R) * amount),
|
||||
(byte)Math.Min(255, c.G + (255 - c.G) * amount),
|
||||
(byte)Math.Min(255, c.B + (255 - c.B) * amount));
|
||||
|
||||
private static void LogCrash(Exception? ex)
|
||||
{
|
||||
|
|
|
|||
47
VideoDownloader.App/BookmarkManagerWindow.xaml
Normal file
47
VideoDownloader.App/BookmarkManagerWindow.xaml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<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>
|
||||
156
VideoDownloader.App/BookmarkManagerWindow.xaml.cs
Normal file
156
VideoDownloader.App/BookmarkManagerWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using VideoDownloader.Browser;
|
||||
|
||||
namespace VideoDownloader.App;
|
||||
|
||||
/// <summary>UC-K16 즐겨찾기 관리 창 — 검색·편집(M17)·삭제·HTML 내보내기/가져오기(M19).</summary>
|
||||
public partial class BookmarkManagerWindow : Window
|
||||
{
|
||||
private readonly ObservableCollection<Bookmark> _source;
|
||||
private readonly Action _persist;
|
||||
|
||||
public BookmarkManagerWindow(ObservableCollection<Bookmark> bookmarks, Action persist)
|
||||
{
|
||||
InitializeComponent();
|
||||
_source = bookmarks;
|
||||
_persist = persist;
|
||||
List.ItemsSource = _source;
|
||||
}
|
||||
|
||||
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
var q = SearchBox.Text?.Trim() ?? "";
|
||||
var view = System.Windows.Data.CollectionViewSource.GetDefaultView(_source);
|
||||
view.Filter = q.Length == 0
|
||||
? null
|
||||
: o => o is Bookmark b &&
|
||||
(b.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
b.Url.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private void Edit_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
|
||||
var title = InputBox("즐겨찾기 편집", "이름", b.Title);
|
||||
if (title == null) return;
|
||||
var url = InputBox("즐겨찾기 편집", "URL", b.Url);
|
||||
if (url == null) return;
|
||||
var idx = _source.IndexOf(b);
|
||||
if (idx >= 0) _source[idx] = new Bookmark(title.Trim(), url.Trim());
|
||||
_persist();
|
||||
}
|
||||
|
||||
private void Delete_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
|
||||
_source.Remove(b);
|
||||
_persist();
|
||||
}
|
||||
|
||||
private void List_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Delete && List.SelectedItem is Bookmark b)
|
||||
{
|
||||
_source.Remove(b);
|
||||
_persist();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>UC-M19 브라우저 호환 Netscape 북마크 HTML 로 내보내기.</summary>
|
||||
private void Export_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.SaveFileDialog { Filter = "북마크 HTML|*.html", FileName = "bookmarks.html" };
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
|
||||
sb.AppendLine("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">");
|
||||
sb.AppendLine("<TITLE>Bookmarks</TITLE><H1>Bookmarks</H1><DL><p>");
|
||||
foreach (var b in _source)
|
||||
sb.AppendLine($" <DT><A HREF=\"{System.Net.WebUtility.HtmlEncode(b.Url)}\">{System.Net.WebUtility.HtmlEncode(b.Title)}</A>");
|
||||
sb.AppendLine("</DL><p>");
|
||||
File.WriteAllText(dlg.FileName, sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Netscape HTML 에서 HREF/제목 쌍을 추출해 추가 가져오기.</summary>
|
||||
private void Import_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog { Filter = "북마크 HTML|*.html;*.htm" };
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
var html = File.ReadAllText(dlg.FileName);
|
||||
var added = 0;
|
||||
foreach (var (url, title) in ParseNetscapeBookmarks(html))
|
||||
{
|
||||
if (_source.Any(b => b.Url.Equals(url, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
_source.Add(new Bookmark(title, url));
|
||||
added++;
|
||||
}
|
||||
_persist();
|
||||
MessageBox.Show(this, $"{added}개 항목을 가져왔습니다", "가져오기");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"가져오기 실패: {ex.Message}", "가져오기", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
internal static IEnumerable<(string Url, string Title)> ParseNetscapeBookmarks(string html)
|
||||
{
|
||||
var matches = System.Text.RegularExpressions.Regex.Matches(html,
|
||||
@"<A\s[^>]*HREF=""(?<url>[^""]+)""[^>]*>(?<title>.*?)</A>",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
foreach (System.Text.RegularExpressions.Match m in matches)
|
||||
{
|
||||
var url = System.Net.WebUtility.HtmlDecode(m.Groups["url"].Value);
|
||||
var title = System.Text.RegularExpressions.Regex.Replace(m.Groups["title"].Value, "<[^>]+>", "");
|
||||
yield return (url, System.Net.WebUtility.HtmlDecode(title).Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private string? InputBox(string title, string label, string initial)
|
||||
{
|
||||
var box = new TextBox { Text = initial, Style = (Style)FindResource("DarkTextBox"), Margin = new Thickness(0, 6, 0, 12) };
|
||||
var win = new Window
|
||||
{
|
||||
Title = title,
|
||||
Width = 440,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Owner = this,
|
||||
Background = (System.Windows.Media.Brush)FindResource("WindowBg"),
|
||||
};
|
||||
var cancel = new Button { Content = "취소", Style = (Style)FindResource("GhostButton"), Width = 80, Margin = new Thickness(0, 0, 8, 0) };
|
||||
var ok = new Button { Content = "확인", Style = (Style)FindResource("PrimaryButton"), Width = 80, IsDefault = true };
|
||||
cancel.Click += (_, _) => win.DialogResult = false;
|
||||
ok.Click += (_, _) => win.DialogResult = true;
|
||||
win.Content = new StackPanel { Margin = new Thickness(16) }.Also(p =>
|
||||
{
|
||||
p.Children.Add(new TextBlock { Text = label, Foreground = (System.Windows.Media.Brush)FindResource("TextSecondary") });
|
||||
p.Children.Add(box);
|
||||
p.Children.Add(new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Children = { cancel, ok },
|
||||
});
|
||||
});
|
||||
return win.ShowDialog() == true ? box.Text : null;
|
||||
}
|
||||
|
||||
private void Close_Click(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
|
||||
/// <summary>람다식 객체 초기화 헬퍼(BookmarkManagerWindow 입력 다이얼로그용).</summary>
|
||||
internal static class UiExtensions
|
||||
{
|
||||
public static T Also<T>(this T self, Action<T> init)
|
||||
{
|
||||
init(self);
|
||||
return self;
|
||||
}
|
||||
}
|
||||
|
|
@ -100,6 +100,20 @@ public sealed class PageDownloadTracker
|
|||
public void Resume(PageDownloadItem item) => item.Op.Resume();
|
||||
public void Cancel(PageDownloadItem item) => item.Op.Cancel();
|
||||
|
||||
/// <summary>UC-D28 페이지 파일 다운로드 전체 일시정지.</summary>
|
||||
public void PauseAll()
|
||||
{
|
||||
foreach (var item in Items.Where(i => i.CanControl).ToList())
|
||||
try { item.Op.Pause(); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>UC-D28 짝 맞춤: 일시정지 항목 전체 재개(Interrupted 포함).</summary>
|
||||
public void ResumeAll()
|
||||
{
|
||||
foreach (var item in Items.Where(i => !i.IsFinished).ToList())
|
||||
try { item.Op.Resume(); } catch { }
|
||||
}
|
||||
|
||||
public void ClearCompleted()
|
||||
{
|
||||
foreach (var done in Items.Where(i => i.IsFinished).ToList())
|
||||
|
|
|
|||
|
|
@ -69,18 +69,22 @@ public sealed class ReaderMode
|
|||
tab.IsReader = true;
|
||||
tab.Title = string.IsNullOrWhiteSpace(title) ? tab.Title : title + " (리더)";
|
||||
|
||||
cv.NavigateToString(BuildReaderHtml(title ?? "", byline ?? "", excerpt ?? "", contentEl.GetString() ?? "", lightTheme));
|
||||
cv.NavigateToString(BuildReaderHtml(title ?? "", byline ?? "", excerpt ?? "", contentEl.GetString() ?? "", lightTheme, AppFontStack));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>호스트가 주입하는 앱 폰트 스택(UC-F17 — 폰트 하드코딩 제거). 기본값은 WPF 기본 스택.</summary>
|
||||
public static string AppFontStack { get; set; } = "Segoe UI, sans-serif";
|
||||
|
||||
/// <summary>리더 HTML(Firefox 스타일 — 폰트/여백/테마). trusted-types 회피를 위해 iframe srcdoc 형태 대신 직접 문서.</summary>
|
||||
public static string BuildReaderHtml(string title, string byline, string excerpt, string contentHtml, bool lightTheme)
|
||||
public static string BuildReaderHtml(string title, string byline, string excerpt, string contentHtml, bool lightTheme, string? fontStack = null)
|
||||
{
|
||||
var bg = lightTheme ? "#faf8f5" : "#1b1c24";
|
||||
var fg = lightTheme ? "#1b1c24" : "#e6e6ec";
|
||||
var muted = lightTheme ? "#666" : "#9a9ab0";
|
||||
var font = string.IsNullOrWhiteSpace(fontStack) ? AppFontStack : fontStack;
|
||||
var css = $@"
|
||||
body {{ background: {bg}; color: {fg}; font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; max-width: 720px; margin: 0 auto; padding: 40px 24px 80px; line-height: 1.75; }}
|
||||
body {{ background: {bg}; color: {fg}; font-family: '{font.Replace("'", "")}', sans-serif; max-width: 720px; margin: 0 auto; padding: 40px 24px 80px; line-height: 1.75; }}
|
||||
h1 {{ font-size: 1.7em; line-height: 1.3; margin-bottom: 6px; }}
|
||||
.byline {{ color: {muted}; font-size: 0.9em; margin-bottom: 4px; }}
|
||||
.excerpt {{ color: {muted}; font-style: italic; margin: 14px 0 26px; padding-left: 12px; border-left: 3px solid {muted}; }}
|
||||
|
|
@ -92,6 +96,7 @@ blockquote {{ border-left: 3px solid {muted}; margin-left: 0; padding-left: 14px
|
|||
var titleEsc = System.Net.WebUtility.HtmlEncode(title);
|
||||
var bylineEsc = System.Net.WebUtility.HtmlEncode(byline);
|
||||
var excerptEsc = System.Net.WebUtility.HtmlEncode(excerpt);
|
||||
return $"""<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{titleEsc}</title><style>{css}</style></head><body><h1>{titleEsc}</h1>{(bylineEsc.Length > 0 ? $"<div class=\"byline\">{bylineEsc}</div>" : "")}{(excerptEsc.Length > 0 ? $"<p class=\"excerpt\">{excerptEsc}</p>" : "")}{contentHtml}</body></html>""";
|
||||
// UC-A17: lang="ko" 선언으로 화면 낭독기가 한국어 음성으로 읽는다.
|
||||
return $"""<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{titleEsc}</title><style>{css}</style></head><body><h1>{titleEsc}</h1>{(bylineEsc.Length > 0 ? $"<div class=\"byline\">{bylineEsc}</div>" : "")}{(excerptEsc.Length > 0 ? $"<p class=\"excerpt\">{excerptEsc}</p>" : "")}{contentHtml}</body></html>""";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ public sealed class PlayerWindow : Window
|
|||
{
|
||||
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();
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -13,3 +13,31 @@ public class InverseCountToVisibilityConverter : IValueConverter
|
|||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,53 @@ public class DownloadManager
|
|||
|
||||
public ObservableCollection<DownloadItemViewModel> Items { get; }
|
||||
|
||||
/// <summary>다운로드 완료(§J 토스트 알림용).</summary>
|
||||
public event Action<DownloadItemViewModel>? ItemCompleted;
|
||||
/// <summary>다운로드 실패(§J 토스트·배너용).</summary>
|
||||
public event Action<DownloadItemViewModel>? ItemFailed;
|
||||
|
||||
/// <summary>UC-D21 예약 게이트 — true 를 돌려줘야 다운로드가 시작된다(DownloadScheduler 연결).</summary>
|
||||
public Func<bool>? DownloadGate { get; set; }
|
||||
|
||||
// ---- §E 속도/ETA 샘플러(항목별 최근 샘플) ----
|
||||
private readonly Dictionary<string, (long Bytes, DateTime At)> _speedSamples = new();
|
||||
|
||||
private void ReportSpeed(DownloadItemViewModel item, DownloadProgress p)
|
||||
{
|
||||
if (p.ReceivedBytes < 0) return;
|
||||
var now = DateTime.UtcNow;
|
||||
if (_speedSamples.TryGetValue(item.Id, out var prev) && (now - prev.At).TotalMilliseconds < 400) return;
|
||||
double bps = 0;
|
||||
if (prev.Bytes > 0 && now > prev.At)
|
||||
bps = Math.Max(0, (p.ReceivedBytes - prev.Bytes) / (now - prev.At).TotalSeconds);
|
||||
_speedSamples[item.Id] = (p.ReceivedBytes, now);
|
||||
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
|
||||
item.UpdateSpeedEta(p.ReceivedBytes, p.TotalBytes, bps));
|
||||
}
|
||||
|
||||
/// <summary>§E 세그먼트 맵 갱신 — 완료 세그먼트 수만큼 앞칸을 채운다.</summary>
|
||||
private static void ReportSegmentMap(DownloadItemViewModel item, DownloadProgress p)
|
||||
{
|
||||
if (p.Total <= 0 || p.Stage != "downloading") return;
|
||||
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
|
||||
{
|
||||
if (item.SegmentMap.Count != p.Total)
|
||||
{
|
||||
item.SegmentMap.Clear();
|
||||
for (var i = 0; i < p.Total; i++) item.SegmentMap.Add(0);
|
||||
}
|
||||
for (var i = 0; i < Math.Min(p.Completed, item.SegmentMap.Count); i++)
|
||||
if (item.SegmentMap[i] < 1.0) item.SegmentMap[i] = 1.0;
|
||||
});
|
||||
}
|
||||
|
||||
private void RaiseCompleted(DownloadItemViewModel item, string finalPath)
|
||||
{
|
||||
item.FinalFileName = Path.GetFileName(finalPath); // UC-D26 최종 저장 파일명
|
||||
item.CompletedAt = DateTime.UtcNow; // UC-D08 자동 치움 판정용
|
||||
ItemCompleted?.Invoke(item);
|
||||
}
|
||||
|
||||
public DownloadManager(AppConfig config, Func<Uri, Task<List<CoreWebView2Cookie>>> getCookies, Action<string> log)
|
||||
{
|
||||
_config = config;
|
||||
|
|
@ -59,8 +106,24 @@ public class DownloadManager
|
|||
|
||||
_reqLogPath = Path.Combine(config.OutputFolder, "requests.log");
|
||||
try { Directory.CreateDirectory(config.OutputFolder); File.WriteAllText(_reqLogPath, ""); } catch { }
|
||||
|
||||
// UC-S13/D22 프록시 설정 — 공유 핸들러는 첫 요청 후 변경 불가(SocketsHttpHandler 제약).
|
||||
// 프로세스 최초 1회만 반영하고, 해제는 앱 재시작으로 적용한다(설정창 RestartBanner 안내).
|
||||
if (!string.IsNullOrWhiteSpace(config.ProxyServer) && Uri.TryCreate(config.ProxyServer, UriKind.Absolute, out var proxyUri))
|
||||
{
|
||||
lock (_gateLock)
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _proxyApplied, 1) == 0)
|
||||
{
|
||||
SharedHandler.Proxy = new System.Net.WebProxy(proxyUri);
|
||||
SharedHandler.UseProxy = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int _proxyApplied;
|
||||
|
||||
public void AppendAll(string line)
|
||||
{
|
||||
try
|
||||
|
|
@ -184,6 +247,15 @@ public class DownloadManager
|
|||
HttpClient? http = null;
|
||||
try
|
||||
{
|
||||
// UC-D21 예약 창 밖이면 창이 열릴 때까지 대기(30초 폴링, 취소 가능)
|
||||
while (DownloadGate != null && !DownloadGate())
|
||||
{
|
||||
item.Status = "예약 대기";
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(30), item.Cts?.Token ?? CancellationToken.None); }
|
||||
catch (OperationCanceledException) { }
|
||||
if (item.Cts?.IsCancellationRequested == true) { item.Status = "취소됨"; return; }
|
||||
}
|
||||
if (item.Status == "예약 대기") item.Status = "다운로드 중";
|
||||
http = await BuildHttpClientAsync(item.MediaUrl, item.PageUrl);
|
||||
if (item.Kind == MediaKind.Hls) await DownloadHlsAsync(item, http);
|
||||
else if (item.Kind == MediaKind.Dash) await DownloadDashAsync(item, http);
|
||||
|
|
@ -205,7 +277,7 @@ public class DownloadManager
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시.</summary>
|
||||
/// <summary>선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시. UC-D09 재시도 횟수 카운트.</summary>
|
||||
public async Task RetryDownload(DownloadItemViewModel item)
|
||||
{
|
||||
if (item.Status is "다운로드 중" or "병합 중" or "리먹스 중") return;
|
||||
|
|
@ -213,6 +285,8 @@ public class DownloadManager
|
|||
item.Progress = 0;
|
||||
item.Segments = "";
|
||||
item.Error = null;
|
||||
item.RetryCount++;
|
||||
item.SegmentMap.Clear();
|
||||
await StartDownload(item);
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +297,40 @@ public class DownloadManager
|
|||
catch { }
|
||||
}
|
||||
|
||||
// ---- §E 일시정지/재개: 엔진 취소 + 파트(.part/.parts) 보존 → 재개 시 이어받기 ----
|
||||
|
||||
/// <summary>항목 일시정지 — 진행 중 항목을 취소하되 임시 파일을 남겨 재개 시 이어받는다.</summary>
|
||||
public void Pause(DownloadItemViewModel item)
|
||||
{
|
||||
if (item.Status is not ("다운로드 중" or "병합 중" or "리먹스 중")) return;
|
||||
item.PausedForResume = true;
|
||||
item.Status = "일시정지";
|
||||
try { item.Cts?.Cancel(); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>일시정지 항목 재개(이어받기).</summary>
|
||||
public void Resume(DownloadItemViewModel item)
|
||||
{
|
||||
if (!item.PausedForResume) return;
|
||||
item.PausedForResume = false;
|
||||
item.Started = false;
|
||||
_ = StartDownload(item);
|
||||
}
|
||||
|
||||
/// <summary>UC-D06 전체 일시정지.</summary>
|
||||
public void PauseAll()
|
||||
{
|
||||
foreach (var it in Items.Where(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중").ToList())
|
||||
Pause(it);
|
||||
}
|
||||
|
||||
/// <summary>UC-D07 전체 재개.</summary>
|
||||
public void ResumeAll()
|
||||
{
|
||||
foreach (var it in Items.Where(i => i.PausedForResume).ToList())
|
||||
Resume(it);
|
||||
}
|
||||
|
||||
/// <summary>소셜 카드 제목 갱신(페이지 타이틀이 나중에 확정될 때). 아직 시작 전이면 출력 파일명도 갱신.</summary>
|
||||
public void RenameSocial(Uri url, string title)
|
||||
{
|
||||
|
|
@ -251,16 +359,18 @@ public class DownloadManager
|
|||
item.Segments = p.Detail ?? $"{p.Completed}/{p.Total}";
|
||||
item.Status = p.Stage switch { "merging" => "병합 중", "remuxing" => "리먹스 중", _ => "다운로드 중" };
|
||||
if (p.Stage == "downloading") item.Progress = (int)Math.Round(p.Percent);
|
||||
ReportSegmentMap(item, p); // UC-D10 세그먼트 맵
|
||||
});
|
||||
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
|
||||
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
|
||||
RaiseCompleted(item, final);
|
||||
}
|
||||
catch (OperationCanceledException) { item.Status = "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
|
||||
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
|
||||
finally
|
||||
{
|
||||
// #6 실패/취소 시 parts 정리 (성공 시엔 엔진이 정리)
|
||||
if (item.Status != "완료")
|
||||
// #6 실패/취소 시 parts 정리 (성공·일시정지 시엔 이어받기 위해 보존)
|
||||
if (item.Status != "완료" && !item.PausedForResume)
|
||||
{
|
||||
try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); }
|
||||
catch { }
|
||||
|
|
@ -285,17 +395,22 @@ public class DownloadManager
|
|||
item.Progress = (int)Math.Round(p.OverridePercent);
|
||||
}
|
||||
else item.IsIndeterminate = true; // #9 길이 미상 → 부정확 모드
|
||||
ReportSpeed(item, p); // §E 속도·ETA
|
||||
});
|
||||
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
|
||||
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
|
||||
RaiseCompleted(item, final);
|
||||
}
|
||||
catch (OperationCanceledException) { item.Status = "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
|
||||
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
|
||||
finally
|
||||
{
|
||||
// #6/#12 임시 .part 정리
|
||||
try { var part = item.OutputBase + ".part"; if (File.Exists(part)) File.Delete(part); }
|
||||
catch { }
|
||||
// #6/#12 임시 .part 정리(일시정지 시 이어받기 위해 보존)
|
||||
if (item.Status != "완료" && !item.PausedForResume)
|
||||
{
|
||||
try { var part = item.OutputBase + ".part"; if (File.Exists(part)) File.Delete(part); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,15 +427,17 @@ public class DownloadManager
|
|||
item.Segments = p.Detail ?? $"{p.Completed}/{p.Total}";
|
||||
item.Status = p.Stage switch { "merging" => "병합 중", "remuxing" => "리먹스 중", _ => "다운로드 중" };
|
||||
if (p.Stage == "downloading") item.Progress = (int)Math.Round(p.Percent);
|
||||
ReportSegmentMap(item, p); // UC-D10 세그먼트 맵
|
||||
});
|
||||
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
|
||||
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
|
||||
RaiseCompleted(item, final);
|
||||
}
|
||||
catch (OperationCanceledException) { item.Status = "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
|
||||
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
|
||||
finally
|
||||
{
|
||||
if (item.Status != "완료")
|
||||
if (item.Status != "완료" && !item.PausedForResume)
|
||||
{
|
||||
try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); }
|
||||
catch { }
|
||||
|
|
@ -350,14 +467,16 @@ public class DownloadManager
|
|||
item.IsIndeterminate = false;
|
||||
item.Progress = (int)Math.Round(p.OverridePercent);
|
||||
}
|
||||
else item.IsIndeterminate = true; // 길이 미상/병환 단계 → 부정확 모드
|
||||
else item.IsIndeterminate = true; // 길이 미상/변환 단계 → 부정확 모드
|
||||
if (!string.IsNullOrEmpty(p.Detail)) item.Segments = p.Detail;
|
||||
ReportSpeed(item, p); // §E 속도·ETA(yt-dlp 가 바이트 보고할 때)
|
||||
});
|
||||
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, fmt, cookiesPath, progress, cts.Token);
|
||||
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
|
||||
RaiseCompleted(item, final);
|
||||
}
|
||||
catch (OperationCanceledException) { item.Status = "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
|
||||
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
|
||||
finally
|
||||
{
|
||||
// 잔류 임시/fragment 정리(성공 여부 무관). 무관 파일 미삭제를 위해 outputBase 접두로 한정.
|
||||
|
|
|
|||
34
VideoDownloader.App/CrashReportDialog.xaml
Normal file
34
VideoDownloader.App/CrashReportDialog.xaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<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="" 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>
|
||||
34
VideoDownloader.App/CrashReportDialog.xaml.cs
Normal file
34
VideoDownloader.App/CrashReportDialog.xaml.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System.Windows;
|
||||
|
||||
namespace VideoDownloader.App;
|
||||
|
||||
/// <summary>UC-N18 크래시 리포트 대화상자 — 처리되지 않은 예외의 요약·스택을 보여주고 복사를 제공한다.
|
||||
/// App 글로벌 예외 핸들러가 기록(crash.log) 후 사용자에게 표시한다.</summary>
|
||||
public partial class CrashReportDialog : Window
|
||||
{
|
||||
/// <summary>같은 예외 폭주로 다이얼로그가 연쇄 뜨는 것을 막는 재진입 가드.</summary>
|
||||
public static bool IsOpen { get; private set; }
|
||||
|
||||
public CrashReportDialog(Exception ex)
|
||||
{
|
||||
InitializeComponent();
|
||||
DetailBox.Text = $"{ex.GetType().Name}: {ex.Message}\r\n\r\n{ex.StackTrace}";
|
||||
Closed += (_, _) => IsOpen = false;
|
||||
}
|
||||
|
||||
/// <summary>비모달 표시(재진입 가드). 이미 떠 있으면 무시한다.</summary>
|
||||
public static void Show(Exception ex)
|
||||
{
|
||||
if (IsOpen) return;
|
||||
IsOpen = true;
|
||||
try { new CrashReportDialog(ex).Show(); }
|
||||
catch { IsOpen = false; }
|
||||
}
|
||||
|
||||
private void Copy_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try { Clipboard.SetText(DetailBox.Text); } catch { }
|
||||
}
|
||||
|
||||
private void Close_Click(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
1244
VideoDownloader.App/MainWindow.Usecases.cs
Normal file
1244
VideoDownloader.App/MainWindow.Usecases.cs
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@ using System.Windows.Controls;
|
|||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Automation.Peers;
|
||||
using System.Windows.Media;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using VideoDownloader.Browser;
|
||||
|
|
@ -17,6 +18,7 @@ using VideoDownloader.App.Core;
|
|||
using VideoDownloader.Core.Detection;
|
||||
using VideoDownloader.App.ViewModels;
|
||||
using VideoDownloader.Core.Config;
|
||||
using Microsoft.Win32;
|
||||
using Wv2 = Microsoft.Web.WebView2.Wpf;
|
||||
using TabItem = VideoDownloader.Browser.Tabs.TabItem;
|
||||
|
||||
|
|
@ -105,7 +107,7 @@ public partial class MainWindow : Window
|
|||
foreach (var b in BrowserStore.LoadBookmarks()) _bookmarks.Add(b);
|
||||
foreach (var h in BrowserStore.LoadHistory()) _history.Add(h);
|
||||
BookmarksList.ItemsSource = _bookmarks;
|
||||
HistoryList.ItemsSource = _history;
|
||||
HistoryList.ItemsSource = GroupHistoryByDate(_history); // UC-M20 날짜 묶음 뷰
|
||||
|
||||
_resolver = new SearchEngineResolver(_config.SearchEngine);
|
||||
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
|
||||
|
|
@ -123,8 +125,15 @@ public partial class MainWindow : Window
|
|||
_playlist.Load(Path.Combine(AppDataDir, "playlist.json"));
|
||||
BuildPalette();
|
||||
|
||||
// UI 복잡 버전 유즈케이스(§A~§J) 초기화 — MainWindow.Usecases.cs
|
||||
InitializeUsecases();
|
||||
|
||||
// UC-L11/L12/L26 창 위치·크기·최대화·전체화면 복원
|
||||
RestoreWindowBounds();
|
||||
|
||||
Loaded += async (_, _) => await InitializeBrowserAsync();
|
||||
PreviewKeyDown += OnPreviewKeyDown;
|
||||
PreviewMouseDown += Window_PreviewMouseDown; // UC-K05 마우스 제스처(XButton)·UC-K08 계열
|
||||
StateChanged += OnWindowStateChanged;
|
||||
AddressBox.LostKeyboardFocus += (_, _) => AddressPopup.IsOpen = false;
|
||||
|
||||
|
|
@ -211,7 +220,11 @@ public partial class MainWindow : Window
|
|||
{
|
||||
NewTabProvider = () => Task.FromResult(_tabs.NewTab(activate: false)),
|
||||
};
|
||||
_views.TabCoreReady += (_, cv) => cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지
|
||||
_views.TabCoreReady += (tab, cv) =>
|
||||
{
|
||||
cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지
|
||||
WireTabMouseGestures(cv); // UC-K06 휠 클릭 새 탭
|
||||
};
|
||||
_views.DownloadStarting += OnDownloadStarting;
|
||||
_views.UrlChanged += OnUrlChanged;
|
||||
_views.TitleChanged += OnTitleChanged;
|
||||
|
|
@ -254,9 +267,17 @@ public partial class MainWindow : Window
|
|||
// T3-14 실드 배지 갱신
|
||||
_views.TrackerBlocked += UpdateShield;
|
||||
|
||||
_tabs = new TabManager(() => new TabItem(), _views.AttachView);
|
||||
_tabs = new TabManager(() => new TabItem(), _views.AttachView)
|
||||
{
|
||||
MaxTabs = _config.MaxTabs, // UC-B11 탭 상한
|
||||
};
|
||||
_tabs.ActiveChanged += OnActiveTabChanged;
|
||||
_tabs.TabRemoved += OnTabRemoved;
|
||||
_views.TabCoreReady += (tab, cv) =>
|
||||
{
|
||||
// UC-K15 새 탭 기본 줌 — 사이트별 저장 줌이 없을 때만 적용(T1-7 과 충돌 방지)
|
||||
try { if (tab.View != null && _zoomStore.Count == 0 && Math.Abs(_config.DefaultZoomFactor - 1.0) > 0.01) tab.View.ZoomFactor = _config.DefaultZoomFactor; } catch { }
|
||||
};
|
||||
TabList.ItemsSource = _tabs.Tabs;
|
||||
UpdatePrivateButton();
|
||||
|
||||
|
|
@ -266,6 +287,10 @@ public partial class MainWindow : Window
|
|||
_tabs.RestoreSession(snap);
|
||||
else
|
||||
_tabs.NewTab(_config.HomeUrl);
|
||||
// UC-B12 세션 복원을 잠자기로 — 비활성 탭 뷰를 접어 빠른 시작
|
||||
if (_config.RestoreTabsSuspended && _tabs.ActiveTab != null)
|
||||
foreach (var t in _tabs.Tabs.Where(t => !ReferenceEquals(t, _tabs.ActiveTab)).ToList())
|
||||
try { if (t.View != null) t.View.Visibility = Visibility.Collapsed; } catch { }
|
||||
UpdateNavState();
|
||||
|
||||
// T1-5 다운로드 속도 갱신 타이머
|
||||
|
|
@ -294,6 +319,7 @@ public partial class MainWindow : Window
|
|||
Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher),
|
||||
});
|
||||
Log($"원격 접근 서버 포트 {_remoteServer.Port}");
|
||||
UpdateServerStatusIcon(); // §J 서버 상태 아이콘
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
|
@ -345,7 +371,7 @@ public partial class MainWindow : Window
|
|||
{
|
||||
if (_views == null) return;
|
||||
var byDomain = _views.TrackerCounter.ByDomain;
|
||||
ShieldList.ItemsSource = byDomain.Count == 0
|
||||
ShieldDomainStats.ItemsSource = byDomain.Count == 0
|
||||
? new[] { "차단된 추적 요청이 없습니다" }
|
||||
: byDomain.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Value,3} × {kv.Key}").ToList();
|
||||
ShieldPopup.IsOpen = true;
|
||||
|
|
@ -440,6 +466,9 @@ public partial class MainWindow : Window
|
|||
try { tab.Core?.Resume(); } catch { }
|
||||
tab.IsSuspended = false;
|
||||
}
|
||||
// UC-K14 현재 탭 줌 배율 표시 갱신
|
||||
if (ZoomIndicator != null && tab.View != null)
|
||||
ZoomIndicator.Content = $"{Math.Round(tab.View.ZoomFactor * 100)}%";
|
||||
UpdateNavState();
|
||||
}
|
||||
|
||||
|
|
@ -476,8 +505,8 @@ public partial class MainWindow : Window
|
|||
|
||||
private void Tab_PreviewMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// 휠 클릭(미들) → 탭 닫기 (브라우저 관례)
|
||||
if (e.ChangedButton == MouseButton.Middle && e.ButtonState == MouseButtonState.Pressed)
|
||||
// 휠 클릭(미들) → 탭 닫기 (브라우저 관례). MiddleButton 상태로 판정.
|
||||
if (e.MiddleButton == MouseButtonState.Pressed)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is TabItem tab)
|
||||
{
|
||||
|
|
@ -843,7 +872,14 @@ public partial class MainWindow : Window
|
|||
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.R) { ForceReload(); e.Handled = true; }
|
||||
else if (mods == ModifierKeys.Control && e.Key == Key.L) { AddressBox.Focus(); AddressBox.SelectAll(); e.Handled = true; }
|
||||
else if (e.Key == Key.F5 || (mods == ModifierKeys.Control && e.Key == Key.R)) { cv?.Reload(); e.Handled = true; }
|
||||
else if (e.Key == Key.Escape) { try { cv?.Stop(); } catch { } e.Handled = true; }
|
||||
else if (e.Key == Key.F12) { OpenDevTools(); e.Handled = true; } // UC-K12
|
||||
else if (e.Key == Key.Escape)
|
||||
{
|
||||
// UC-K10: 열린 팝업이 있으면 모두 닫고, 없으면 페이지 로딩 중지
|
||||
if (AnyPopupOpen()) CloseAllPopups();
|
||||
else try { cv?.Stop(); } catch { }
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (mods == ModifierKeys.Alt && e.Key == Key.Left) { try { if (cv?.CanGoBack == true) cv.GoBack(); } catch { } e.Handled = true; }
|
||||
else if (mods == ModifierKeys.Alt && e.Key == Key.Right) { try { if (cv?.CanGoForward == true) cv.GoForward(); } catch { } e.Handled = true; }
|
||||
else if (mods == ModifierKeys.Control)
|
||||
|
|
@ -951,7 +987,7 @@ public partial class MainWindow : Window
|
|||
}
|
||||
|
||||
// ---- 범용 우측 패널: 접기/펼치기 + 리사이즈 + 상태 저장 ----
|
||||
private static double ClampPanelWidth(double w) => Math.Clamp(w, MinExpandedWidth, MaxExpandedWidth);
|
||||
private double ClampPanelWidth(double w) => Math.Clamp(w, Math.Max(160, _config.RightPanelMinWidth), MaxExpandedWidth); // UC-L20 최소 폭 설정 반영
|
||||
|
||||
private void ApplyPanelState(bool collapsed)
|
||||
{
|
||||
|
|
@ -992,15 +1028,74 @@ public partial class MainWindow : Window
|
|||
|
||||
private void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
// UC-K11: 다운로드 진행 중 종료 확인
|
||||
var activeDownloads = _manager.Items.Count(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중" or "대기")
|
||||
+ _pageDownloads.Items.Count(i => !i.IsFinished);
|
||||
if (activeDownloads > 0 && !_forceClose)
|
||||
{
|
||||
e.Cancel = true;
|
||||
var r = MessageBox.Show(this,
|
||||
$"진행 중인 다운로드가 {activeDownloads}건 있습니다.\n그래도 종료하시겠습니까?",
|
||||
"종료 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||
if (r != MessageBoxResult.Yes) return;
|
||||
_forceClose = true;
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
_config.RightPanelCollapsed = _panelCollapsed;
|
||||
if (!_panelCollapsed) _config.RightPanelWidth = ClampPanelWidth(RightCol.Width.Value);
|
||||
_config.CompactMode = _compactMode; // UC-L07
|
||||
if (_splitTab != null)
|
||||
_config.SplitViewRatio = Math.Clamp(SplitCol.Width.Value / Math.Max(1, SplitCol.Width.Value + TabHost.ActualWidth), 0.1, 0.9); // UC-L10
|
||||
_config.FullScreen = _fullscreen; // UC-L26
|
||||
_config.WindowMaximized = WindowState == WindowState.Maximized; // UC-L12
|
||||
_config.WindowBounds = $"{Left},{Top},{Width},{Height}"; // UC-L11
|
||||
ConfigStore.Save(_config);
|
||||
// T1-10 세션 저장: 탭 URL/활성/핀 보존
|
||||
try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { }
|
||||
try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } // T2-10
|
||||
try { if (_remoteServer != null) _ = _remoteServer.DisposeAsync(); } catch { } // M0
|
||||
System.Net.NetworkInformation.NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged; // §J
|
||||
try { Microsoft.Win32.SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; } catch { }
|
||||
}
|
||||
|
||||
private bool _forceClose;
|
||||
|
||||
/// <summary>UC-L11/L12/L26: 저장된 창 상태(위치·크기·최대화) 복원.</summary>
|
||||
private void RestoreWindowBounds()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_config.WindowBounds))
|
||||
{
|
||||
var parts = _config.WindowBounds.Split(',');
|
||||
if (parts.Length == 4 &&
|
||||
double.TryParse(parts[0], out var l) && double.TryParse(parts[1], out var t) &&
|
||||
double.TryParse(parts[2], out var w) && double.TryParse(parts[3], out var h))
|
||||
{
|
||||
// 화면 밖 좌표(모니터 분리 후) 방어 — 보이는 가상 화면 범위로 클램프
|
||||
var bounds = SystemParameters.VirtualScreenLeft;
|
||||
var boundsTop = SystemParameters.VirtualScreenTop;
|
||||
var boundsW = SystemParameters.VirtualScreenWidth;
|
||||
var boundsH = SystemParameters.VirtualScreenHeight;
|
||||
if (l < bounds || t < boundsTop || l + w > bounds + boundsW || t + h > boundsTop + boundsH)
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
else { Left = l; Top = t; Width = Math.Max(600, w); Height = Math.Max(400, h); }
|
||||
}
|
||||
}
|
||||
if (_config.WindowMaximized) WindowState = WindowState.Maximized;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>UC-K10 판정: 팝업 중 하나라도 열려 있는가.</summary>
|
||||
private bool AnyPopupOpen() =>
|
||||
BookmarksPopup.IsOpen || HistoryPopup.IsOpen || AddressPopup.IsOpen || NotesPopup.IsOpen ||
|
||||
ShieldPopup.IsOpen || DownloadsPopup.IsOpen || TabSearchPopup.IsOpen || PalettePopup.IsOpen ||
|
||||
PlaylistPopup.IsOpen || LinkPopup.IsOpen || LibraryPopup.IsOpen || ArchivePopup.IsOpen ||
|
||||
ShortcutHelpPopup.IsOpen || NotificationCenterPopup.IsOpen || TabVolumePopup.IsOpen;
|
||||
|
||||
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
|
||||
private void UpdatePrivateButton()
|
||||
{
|
||||
|
|
@ -1093,7 +1188,7 @@ public partial class MainWindow : Window
|
|||
|
||||
private void OpenHistory_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement fe && fe.DataContext is HistoryEntry h)
|
||||
if (sender is FrameworkElement fe && fe.DataContext is HistoryRow h) // UC-M20 묶음 뷰 행
|
||||
{
|
||||
HistoryPopup.IsOpen = false;
|
||||
NavigateTo(h.Url);
|
||||
|
|
@ -1114,6 +1209,7 @@ public partial class MainWindow : Window
|
|||
_history.Insert(0, new HistoryEntry(Trunc(title, 80), url, DateTime.Now));
|
||||
while (_history.Count > 500) _history.RemoveAt(_history.Count - 1);
|
||||
BrowserStore.SaveHistory(_history.ToList());
|
||||
HistoryList.ItemsSource = GroupHistoryByDate(_history); // UC-M20 날짜 묶음 뷰 유지
|
||||
}
|
||||
|
||||
// ---- T2-8 탭 검색 (Ctrl+Shift+A) ----
|
||||
|
|
@ -1188,7 +1284,7 @@ public partial class MainWindow : Window
|
|||
var link = target?.LinkUri;
|
||||
if (!string.IsNullOrEmpty(link))
|
||||
{
|
||||
var download = new MenuItem { Header = "이 링크의 영상 받기", FontWeight = FontWeights.Medium };
|
||||
var download = new MenuItem { Header = "링크 다운로드", FontWeight = FontWeights.Medium }; // UC-K20
|
||||
download.Click += (_, _) => QueueLinkForDownload(link);
|
||||
menu.Items.Add(download);
|
||||
|
||||
|
|
@ -1289,6 +1385,15 @@ public partial class MainWindow : Window
|
|||
() => { var t = _tabs.NewTab(); Cv2Navigate(t, a.Url); })).ToList());
|
||||
_palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess());
|
||||
_palette.RegisterSeparator();
|
||||
// ---- UI 복잡 버전 유즈케이스 명령(docs/UI_USECASES.md) ----
|
||||
_palette.Register("모두 일시정지", "pause all stop downloads 전체", PauseAllDownloads); // UC-K09
|
||||
_palette.Register("모두 재개", "resume all downloads 전체", ResumeAllDownloads);
|
||||
_palette.Register("라이브러리", "library files 받은 파일", () => LibraryBtn_Click(this, new RoutedEventArgs())); // UC-M01
|
||||
_palette.Register("영역 캡처", "region capture screenshot 영역", async () => await RegionCapture()); // UC-K18
|
||||
_palette.Register("프로필 전환", "profile switch session 세션", SwitchProfile); // UC-K13
|
||||
_palette.Register("즐겨찾기 HTML 내보내기", "bookmark export html", () => ExportBookmarks()); // UC-M19
|
||||
_palette.Register("단축키 목록", "shortcut help keys", () => ShortcutHelpPopup.IsOpen = true); // UC-K04
|
||||
_palette.RegisterSeparator();
|
||||
_palette.RegisterDynamic(() => _tabs.Tabs
|
||||
.Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t)))
|
||||
.ToList());
|
||||
|
|
@ -1434,26 +1539,51 @@ public partial class MainWindow : Window
|
|||
if (!_compactMode) _chromeRevealed = false;
|
||||
}
|
||||
|
||||
private System.Windows.Threading.DispatcherTimer? _edgeRevealTimer;
|
||||
private bool _edgePending;
|
||||
|
||||
protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e)
|
||||
{
|
||||
if (_compactMode)
|
||||
{
|
||||
var p = e.GetPosition(this);
|
||||
var wantReveal = p.Y <= 4; // 상단 엣지 4px
|
||||
if (wantReveal != _chromeRevealed)
|
||||
// UC-L24 EdgeRevealDelayMs: 엣지에 머무는 동안만 복원(순간 지나침 무시)
|
||||
if (wantReveal && !_chromeRevealed && !_edgePending)
|
||||
{
|
||||
_chromeRevealed = wantReveal;
|
||||
ApplyChromeVisibility();
|
||||
_edgePending = true;
|
||||
_edgeRevealTimer ??= new System.Windows.Threading.DispatcherTimer();
|
||||
_edgeRevealTimer.Interval = TimeSpan.FromMilliseconds(Math.Max(0, _config.EdgeRevealDelayMs));
|
||||
_edgeRevealTimer.Tick -= EdgeRevealTick;
|
||||
_edgeRevealTimer.Tick += EdgeRevealTick;
|
||||
_edgeRevealTimer.Stop();
|
||||
_edgeRevealTimer.Start();
|
||||
}
|
||||
else if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김
|
||||
else if (!wantReveal)
|
||||
{
|
||||
_chromeRevealed = false;
|
||||
ApplyChromeVisibility();
|
||||
_edgePending = false;
|
||||
_edgeRevealTimer?.Stop();
|
||||
if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김
|
||||
{
|
||||
_chromeRevealed = false;
|
||||
ApplyChromeVisibility();
|
||||
}
|
||||
}
|
||||
}
|
||||
base.OnPreviewMouseMove(e);
|
||||
}
|
||||
|
||||
private void EdgeRevealTick(object? sender, EventArgs e)
|
||||
{
|
||||
_edgeRevealTimer?.Stop();
|
||||
_edgePending = false;
|
||||
if (!_chromeRevealed)
|
||||
{
|
||||
_chromeRevealed = true;
|
||||
ApplyChromeVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- T2-9 스크린샷 ----
|
||||
private async Task TakeScreenshot()
|
||||
{
|
||||
|
|
@ -1528,7 +1658,9 @@ public partial class MainWindow : Window
|
|||
_splitTab = next;
|
||||
SplitRightHost.Child = next.View;
|
||||
next.View!.Visibility = Visibility.Visible;
|
||||
SplitCol.Width = new GridLength(Math.Max(280, (TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth) / 2));
|
||||
var total = TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth;
|
||||
var ratio = _config.SplitViewRatio is > 0.1 and < 0.9 ? _config.SplitViewRatio : 0.5; // UC-L10 복원
|
||||
SplitCol.Width = new GridLength(Math.Max(280, total * ratio));
|
||||
SplitRightHost.Visibility = Visibility.Visible;
|
||||
SplitSplitter.Visibility = Visibility.Visible;
|
||||
Log($"분할 화면: {Trunc(next.Title, 30)}");
|
||||
|
|
@ -1638,9 +1770,12 @@ public partial class MainWindow : Window
|
|||
await WaitTabReadyLocal(tab);
|
||||
var cv = tab.Core;
|
||||
if (cv == null) return;
|
||||
// UC-F18: 폰트 하드코딩 대신 앱 폰트 스택 주입(App.ApplyFontSettings 가 관리)
|
||||
var fontStack = (FontFamily)Application.Current.FindResource("UiFont");
|
||||
var fontCss = string.Join(",", fontStack.Source.Split(',').Select(f => $"'{f.Trim()}'"));
|
||||
var tiles = _bookmarks.Take(18).Select(b =>
|
||||
$"<a href='{System.Net.WebUtility.HtmlEncode(b.Url)}' style='display:block;text-decoration:none;background:#262736;color:#e6e6ec;border-radius:12px;padding:18px 12px;text-align:center;font-size:12.5px;min-width:110px' onmouseover='this.style.background=#2f3042' onmouseout='this.style.background=#262736'><div style='font-size:26px;margin-bottom:8px'>🌐</div><div style='overflow:hidden;text-overflow:ellipsis;white-space:nowrap'>{System.Net.WebUtility.HtmlEncode(b.Title)}</div></a>").ToList();
|
||||
var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI Variable Text','Segoe UI','Malgun Gothic',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#9aa0b4;font-size:12.5px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
|
||||
var dial = "<!DOCTYPE html><html lang='ko'><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:" + fontCss + ",sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#9aa0b4;font-size:12.5px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
|
||||
cv.NavigateToString(dial);
|
||||
}
|
||||
|
||||
|
|
@ -1652,7 +1787,10 @@ public partial class MainWindow : Window
|
|||
}
|
||||
|
||||
// ---- T2-11/T3-1 탭 컨텍스트 메뉴 + 워크스페이스 ----
|
||||
private static readonly string[] GroupColors = { "#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#e91e8c" };
|
||||
// UC-B17: 그룹 색 팔레트를 테마 리소스(Shared.xaml GroupPalette)에서 가져온다.
|
||||
private string[] GroupColors => (TryFindResource("GroupPalette") as Array)?
|
||||
.OfType<SolidColorBrush>().Select(b => $"#{b.Color.R:X2}{b.Color.G:X2}{b.Color.B:X2}").ToArray()
|
||||
?? Array.Empty<string>();
|
||||
|
||||
private void Tab_ContextMenu(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
|
|
@ -1672,34 +1810,88 @@ public partial class MainWindow : Window
|
|||
mute.Click += (_, _) => { try { if (tab.Core != null) { tab.Core.IsMuted = !(tab.Core?.IsMuted == true); tab.IsMuted = tab.Core.IsMuted; } } catch { } };
|
||||
menu.Items.Add(mute);
|
||||
|
||||
// UC-B23 탭 볼륨
|
||||
var volume = new MenuItem { Header = "볼륨 조절" };
|
||||
volume.Click += (_, _) => OpenTabVolume(tab);
|
||||
menu.Items.Add(volume);
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
|
||||
// UC-B01 탭 잠금
|
||||
var lockItem = new MenuItem { Header = tab.IsLocked ? "잠금 해제" : "탭 잠금(닫기 방지)" };
|
||||
lockItem.Click += (_, _) => { tab.IsLocked = !tab.IsLocked; Log(tab.IsLocked ? "탭을 잠갔습니다" : "탭 잠금을 해제했습니다"); };
|
||||
menu.Items.Add(lockItem);
|
||||
|
||||
// UC-B02 사용자 지정 탭 색
|
||||
var color = new MenuItem { Header = "탭 색상" };
|
||||
var noColor = new MenuItem { Header = "색 없음" };
|
||||
noColor.Click += (_, _) => tab.CustomColor = null;
|
||||
color.Items.Add(noColor);
|
||||
foreach (var c in GroupColors)
|
||||
{
|
||||
var mi = new MenuItem { Header = c };
|
||||
var swatch = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 };
|
||||
swatch.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(c));
|
||||
mi.Icon = swatch;
|
||||
mi.Click += (_, _) => tab.CustomColor = c;
|
||||
color.Items.Add(mi);
|
||||
}
|
||||
menu.Items.Add(color);
|
||||
|
||||
var group = new MenuItem { Header = "그룹" };
|
||||
var noGroup = new MenuItem { Header = "그룹 없음" };
|
||||
noGroup.Click += (_, _) => _tabs.SetGroup(tab, null, null);
|
||||
group.Items.Add(noGroup);
|
||||
for (var i = 0; i < GroupColors.Length; i++)
|
||||
{
|
||||
var color = GroupColors[i];
|
||||
var c = GroupColors[i];
|
||||
var mi = new MenuItem { Header = $"그룹 {i + 1}" };
|
||||
var sw = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 };
|
||||
sw.Fill = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(color));
|
||||
sw.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(c));
|
||||
mi.Icon = sw;
|
||||
mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", color);
|
||||
mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", c);
|
||||
group.Items.Add(mi);
|
||||
}
|
||||
menu.Items.Add(group);
|
||||
|
||||
if (tab.GroupName != null)
|
||||
{
|
||||
var rename = new MenuItem { Header = $"그룹 이름 바꾸기 ({tab.GroupName})" }; // UC-B04
|
||||
rename.Click += (_, _) =>
|
||||
{
|
||||
var name = Prompt("그룹 이름 바꾸기", "새 그룹 이름", tab.GroupName!);
|
||||
if (name != null && _tabs.RenameGroup(tab.GroupName!, name.Trim()))
|
||||
Log($"그룹 이름 변경: {name.Trim()}");
|
||||
};
|
||||
menu.Items.Add(rename);
|
||||
|
||||
var collapse = new MenuItem { Header = tab.IsGroupCollapsed ? "그룹 펼치기" : "그룹 접기" }; // UC-B03
|
||||
collapse.Click += (_, _) => _tabs.ToggleGroupCollapsed(tab.GroupName!);
|
||||
menu.Items.Add(collapse);
|
||||
|
||||
var closeG = new MenuItem { Header = $"그룹 전체 닫기 ({tab.GroupName})" };
|
||||
closeG.Click += (_, _) => _tabs.CloseGroup(tab.GroupName!);
|
||||
closeG.Click += (_, _) =>
|
||||
{
|
||||
var n = _tabs.Tabs.Count(t => t.GroupName == tab.GroupName && !t.IsLocked);
|
||||
// UC-B09 다중 닫기 확인
|
||||
if (_config.ConfirmCloseMultipleTabs && n > 1 &&
|
||||
MessageBox.Show(this, $"그룹 탭 {n}개를 닫을까요?", "탭 닫기", MessageBoxButton.OKCancel) != MessageBoxResult.OK)
|
||||
return;
|
||||
_tabs.CloseGroup(tab.GroupName!);
|
||||
};
|
||||
menu.Items.Add(closeG);
|
||||
|
||||
var bmG = new MenuItem { Header = "그룹 즐겨찾기에 저장" }; // UC-B24
|
||||
bmG.Click += (_, _) => BookmarkGroup(tab.GroupName);
|
||||
menu.Items.Add(bmG);
|
||||
}
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
var close = new MenuItem { Header = "탭 닫기" };
|
||||
close.Click += (_, _) => _tabs.CloseTab(tab);
|
||||
close.Click += (_, _) =>
|
||||
{
|
||||
if (!_tabs.CloseTab(tab)) Log("잠긴 탭은 닫을 수 없습니다 (탭 잠금 해제 후 시도)"); // UC-B01
|
||||
};
|
||||
menu.Items.Add(close);
|
||||
|
||||
menu.PlacementTarget = sender as UIElement;
|
||||
|
|
@ -1739,6 +1931,7 @@ public partial class MainWindow : Window
|
|||
_config.RemoteAccessEnabled = false;
|
||||
ConfigStore.Save(_config);
|
||||
Log("원격 접근을 껐습니다");
|
||||
UpdateServerStatusIcon(); // §J 서버 상태 아이콘
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1760,8 +1953,10 @@ public partial class MainWindow : Window
|
|||
Token = token,
|
||||
LibraryFolder = Path.GetFullPath(_config.OutputFolder),
|
||||
Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher),
|
||||
EnableMdns = true, // MOBILE_PLAN §3: _videodl._tcp 발견 보조 (실패해도 서버는 뜬다)
|
||||
});
|
||||
Log($"원격 접근 켜짐 — 포트 {_remoteServer.Port}");
|
||||
UpdateServerStatusIcon(); // §J 서버 상태 아이콘
|
||||
var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this };
|
||||
pair.Show();
|
||||
}
|
||||
|
|
@ -1835,7 +2030,10 @@ public partial class MainWindow : Window
|
|||
private void CardDownload_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
|
||||
{
|
||||
if (!ConfirmDuplicateDownload(it)) return; // UC-D25 중복 확인
|
||||
_ = _manager.StartDownload(it);
|
||||
}
|
||||
}
|
||||
|
||||
// 소셜 카드: yt-dlp -J 로 사용 가능한 화질 조회 → 드롭다운 교체
|
||||
|
|
@ -1903,12 +2101,14 @@ public partial class MainWindow : Window
|
|||
ConfigStore.Save(_config);
|
||||
_resolver = new SearchEngineResolver(_config.SearchEngine); // T1-4 즉시 반영
|
||||
_manager.UpdateMaxConcurrent(_config.MaxConcurrentDownloads); // #19 런타임 반영
|
||||
if (w.ThemeChanged) RestartApp();
|
||||
ApplyTheme(_config.Theme); // §A 라이브 테마(재시작 없음)
|
||||
UpdateShortcutHelp(); // §H 단축키 재배치 반영
|
||||
}
|
||||
}
|
||||
|
||||
// 테마 변경 등은 리소스가 StaticResource 로 물려있어 런타임 전환이 어려움 → 재시작으로 적용.
|
||||
private static void RestartApp()
|
||||
/// <summary>테마 변경은 리소스를 DynamicResource 로 물려 런타임 전환이 가능해졌다(UC-T04).
|
||||
/// 프록시·프로필 등 재시작이 필요한 변경은 설정창의 RestartBanner 가 안내한다.</summary>
|
||||
internal static void RestartApp()
|
||||
{
|
||||
var exe = Environment.ProcessPath;
|
||||
if (!string.IsNullOrEmpty(exe))
|
||||
|
|
@ -1917,4 +2117,98 @@ public partial class MainWindow : Window
|
|||
}
|
||||
|
||||
private static string Trunc(string s, int n) => string.IsNullOrEmpty(s) ? "" : (s.Length <= n ? s : s[..n] + "…");
|
||||
// ==================== UI 유즈케이스 핸들러(소스 계약상 MainWindow.xaml.cs 배치) ====================
|
||||
|
||||
private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
|
||||
{
|
||||
if (e.Category != UserPreferenceCategory.General) return;
|
||||
Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
// 고대비 켜/끄 → 설정 테마 무시하고 즉시 따라감(UC-A04)
|
||||
if (SystemParameters.HighContrast && App.CurrentTheme != "highcontrast") ApplyTheme("highcontrast");
|
||||
else if (!SystemParameters.HighContrast && App.CurrentTheme == "highcontrast") ApplyTheme(_config.Theme);
|
||||
}));
|
||||
}
|
||||
|
||||
private void CopyStatus_Click(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
try { Clipboard.SetText(StatusText.Text); _toast?.Show("상태 메시지를 복사했습니다", "info"); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>UC-K19 노트 편집 내용 저장.</summary>
|
||||
private void NotesSave_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_notes.Save();
|
||||
_toast?.Show("노트를 저장했습니다", "success");
|
||||
}
|
||||
|
||||
/// <summary>UC-M17 즐겨찾기 편집(이름·URL).</summary>
|
||||
private void EditBookmark_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
|
||||
var title = Prompt("즐겨찾기 편집", "이름", b.Title);
|
||||
if (title == null) return;
|
||||
var url = Prompt("즐겨찾기 편집", "URL", b.Url);
|
||||
if (url == null) return;
|
||||
var idx = _bookmarks.IndexOf(b);
|
||||
if (idx >= 0) _bookmarks[idx] = new Bookmark(title.Trim(), url.Trim(), b.Folder);
|
||||
BrowserStore.SaveBookmarks(_bookmarks.ToList());
|
||||
RefreshBookmarkBar();
|
||||
}
|
||||
|
||||
/// <summary>UC-M16 즐겨찾기 폴더로 정리 — 항목을 지정 폴더로 이동.</summary>
|
||||
private void BookmarkFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
|
||||
var folder = Prompt("폴더 지정", "폴더 이름(비우면 최상위)", b.Folder ?? "");
|
||||
if (folder == null) return;
|
||||
var idx = _bookmarks.IndexOf(b);
|
||||
if (idx >= 0) _bookmarks[idx] = new Bookmark(b.Title, b.Url, string.IsNullOrWhiteSpace(folder) ? null : folder.Trim());
|
||||
BrowserStore.SaveBookmarks(_bookmarks.ToList());
|
||||
_toast?.Show($"'{Trunc(b.Title, 24)}' 를 '{(string.IsNullOrWhiteSpace(folder) ? "최상위" : folder.Trim())}' 로 이동", "info");
|
||||
}
|
||||
|
||||
private void LibraryPlay_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is LibraryEntry entry) PlayLibraryEntry(entry);
|
||||
}
|
||||
|
||||
/// <summary>UC-M8 삭제(휴지통 아님 — 확인 후 영구 삭제).</summary>
|
||||
private void LibraryDelete_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is not LibraryEntry entry) return;
|
||||
if (MessageBox.Show(this, $"'{entry.Name}' 파일을 삭제할까요?", "라이브러리",
|
||||
MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK) return;
|
||||
try { File.Delete(entry.Path); } catch (Exception ex) { Log($"삭제 실패: {ex.Message}"); }
|
||||
RefreshLibrary();
|
||||
}
|
||||
|
||||
/// <summary>UC-M9 포함 폴더 열기(파일 선택 상태).</summary>
|
||||
private void LibraryOpenFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is LibraryEntry entry) OpenInExplorer(entry.Path);
|
||||
}
|
||||
|
||||
/// <summary>UC-M13 재생목록 파일 내보내기(M3U).</summary>
|
||||
private void PlaylistExport_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new SaveFileDialog { Filter = "M3U 재생목록|*.m3u", FileName = "playlist.m3u" };
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
var sb = new System.Text.StringBuilder("#EXTM3U\n");
|
||||
foreach (var t in _playlist.Tracks)
|
||||
sb.AppendLine($"#EXTINF:-1,{t.Title}\n{t.Url}");
|
||||
File.WriteAllText(dlg.FileName, sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>UC-A09: 메인 창 자동화 피어 — 창 이름에 현재 상태 메시지를 노출해 낭독기가 상태를 읽게 한다.</summary>
|
||||
protected override AutomationPeer OnCreateAutomationPeer()
|
||||
=> new MainWindowAutomationPeer(this);
|
||||
|
||||
private sealed class MainWindowAutomationPeer : WindowAutomationPeer
|
||||
{
|
||||
public MainWindowAutomationPeer(Window owner) : base(owner) { }
|
||||
|
||||
protected override string GetNameCore() => "Video Downloader 메인 창";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
57
VideoDownloader.App/Services/DownloadScheduler.cs
Normal file
57
VideoDownloader.App/Services/DownloadScheduler.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
namespace VideoDownloader.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// UC-D21 다운로드 예약 — 지정 시간대(예: 야간 02:00~08:00)에만 다운로드를 허용하는 게이트.
|
||||
/// 시간 창은 "HH:mm-HH:mm" 형식. 자정을 가로지르는 창(22:00-06:00)도 지원한다.
|
||||
/// </summary>
|
||||
public sealed class DownloadScheduler
|
||||
{
|
||||
/// <summary>예약 창("HH:mm-HH:mm"). null/빈이면 항상 허용.</summary>
|
||||
public string? Window { get; }
|
||||
|
||||
/// <summary>예약 창이 설정돼 있으면 true.</summary>
|
||||
public bool Enabled => !string.IsNullOrWhiteSpace(Window);
|
||||
|
||||
public DownloadScheduler(string? window = null)
|
||||
{
|
||||
Window = string.IsNullOrWhiteSpace(window) ? null : window.Trim();
|
||||
}
|
||||
|
||||
/// <summary>지정 시각에 다운로드가 허용되는지.</summary>
|
||||
public bool CanRunNow(DateTime now)
|
||||
{
|
||||
if (!Enabled || TryParse(Window!, out var start, out var end) == false) return true;
|
||||
var t = now.TimeOfDay;
|
||||
return start <= end
|
||||
? t >= start && t < end
|
||||
: t >= start || t < end; // 자정 가로지름(예: 22:00-06:00)
|
||||
}
|
||||
|
||||
/// <summary>다음 예약 창 시작까지 남은 시간. 항상 허용이면 Zero.</summary>
|
||||
public TimeSpan TimeUntilNextWindow(DateTime now)
|
||||
{
|
||||
if (!Enabled || !TryParse(Window!, out var start, out _)) return TimeSpan.Zero;
|
||||
var target = now.Date + start;
|
||||
if (target <= now) target = target.AddDays(1);
|
||||
return target - now;
|
||||
}
|
||||
|
||||
/// <summary>"HH:mm-HH:mm" 파싱. 형식이 틀리면 false(게이트는 열린 채 둔다).</summary>
|
||||
public static bool TryParse(string window, out TimeSpan start, out TimeSpan end)
|
||||
{
|
||||
start = end = TimeSpan.Zero;
|
||||
var parts = (window ?? "").Split('-');
|
||||
if (parts.Length != 2) return false;
|
||||
return TryParseTime(parts[0], out start) && TryParseTime(parts[1], out end);
|
||||
}
|
||||
|
||||
private static bool TryParseTime(string s, out TimeSpan ts)
|
||||
{
|
||||
ts = TimeSpan.Zero;
|
||||
var p = s.Trim().Split(':');
|
||||
if (p.Length != 2 || !int.TryParse(p[0], out var h) || !int.TryParse(p[1], out var m)) return false;
|
||||
if (h is < 0 or > 23 || m is < 0 or > 59) return false;
|
||||
ts = new TimeSpan(h, m, 0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
78
VideoDownloader.App/Services/ShortcutManager.cs
Normal file
78
VideoDownloader.App/Services/ShortcutManager.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
namespace VideoDownloader.App.Services;
|
||||
|
||||
/// <summary>단일 단축키 정의(명령 키 + 표시 조합).</summary>
|
||||
public sealed record ShortcutBinding(string Command, string Gesture);
|
||||
|
||||
/// <summary>
|
||||
/// UC-K02/K03 단축키 관리 — 기본 조합표 위에 사용자 재배치(AppConfig.Shortcuts)를 얹고,
|
||||
/// 저장 전에 같은 조합을 쓰는 명령 충돌을 검증한다.
|
||||
/// </summary>
|
||||
public sealed class ShortcutManager
|
||||
{
|
||||
private readonly Dictionary<string, string> _user;
|
||||
|
||||
/// <summary>기본 단축키표(명령 키 → 기본 조합).</summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> Defaults = new Dictionary<string, string>
|
||||
{
|
||||
["newtab"] = "Ctrl+T",
|
||||
["closetab"] = "Ctrl+W",
|
||||
["reopentab"] = "Ctrl+Shift+T",
|
||||
["find"] = "Ctrl+F",
|
||||
["address"] = "Ctrl+L",
|
||||
["downloads"] = "Ctrl+J",
|
||||
["palette"] = "Ctrl+K",
|
||||
["devtools"] = "F12",
|
||||
["fullscreen"] = "F11",
|
||||
["refresh"] = "F5",
|
||||
["print"] = "Ctrl+P",
|
||||
["pauseall"] = "Ctrl+Shift+P",
|
||||
};
|
||||
|
||||
public ShortcutManager(IDictionary<string, string>? userShortcuts = null)
|
||||
{
|
||||
_user = new Dictionary<string, string>(userShortcuts ?? new Dictionary<string, string>(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>명령의 현재 조합(사용자 지정 우선, 없으면 기본값).</summary>
|
||||
public string GestureFor(string command)
|
||||
=> _user.TryGetValue(command, out var g) ? g : Defaults.GetValueOrDefault(command, "");
|
||||
|
||||
/// <summary>전체 목록(기본 전체 + 사용자 오버라이드 반영).</summary>
|
||||
public List<ShortcutBinding> All()
|
||||
=> Defaults.Keys
|
||||
.Select(c => new ShortcutBinding(c, GestureFor(c)))
|
||||
.Concat(_user.Where(kv => !Defaults.ContainsKey(kv.Key)).Select(kv => new ShortcutBinding(kv.Key, kv.Value)))
|
||||
.ToList();
|
||||
|
||||
/// <summary>UC-K03 충돌 검증 — 같은 조합에 묶인 명령 그룹(2개 이상일 때만 반환).</summary>
|
||||
public List<List<ShortcutBinding>> FindConflicts()
|
||||
=> All()
|
||||
.GroupBy(b => Normalize(b.Gesture))
|
||||
.Where(g => g.Count() > 1 && g.Key.Length > 0)
|
||||
.Select(g => g.ToList())
|
||||
.ToList();
|
||||
|
||||
/// <summary>조합 문자열 파싱("Ctrl+Shift+P" → ModifierKeys+Key). 파싱 실패 시 null.</summary>
|
||||
public static (System.Windows.Input.ModifierKeys Mods, System.Windows.Input.Key Key)? Parse(string gesture)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(gesture)) return null;
|
||||
var mods = System.Windows.Input.ModifierKeys.None;
|
||||
var key = System.Windows.Input.Key.None;
|
||||
foreach (var part in gesture.Split('+'))
|
||||
{
|
||||
switch (part.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "ctrl": mods |= System.Windows.Input.ModifierKeys.Control; continue;
|
||||
case "shift": mods |= System.Windows.Input.ModifierKeys.Shift; continue;
|
||||
case "alt": mods |= System.Windows.Input.ModifierKeys.Alt; continue;
|
||||
case "win": mods |= System.Windows.Input.ModifierKeys.Windows; continue;
|
||||
}
|
||||
if (!Enum.TryParse(part.Trim(), ignoreCase: true, out key)) return null;
|
||||
}
|
||||
return key == System.Windows.Input.Key.None ? null : (mods, key);
|
||||
}
|
||||
|
||||
/// <summary>조합 비교 정규화(대소문자·순서 무관).</summary>
|
||||
public static string Normalize(string gesture)
|
||||
=> string.Join("+", (gesture ?? "").Split('+').Select(p => p.Trim().ToLowerInvariant()).OrderBy(p => p));
|
||||
}
|
||||
40
VideoDownloader.App/Services/ThemeResolver.cs
Normal file
40
VideoDownloader.App/Services/ThemeResolver.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using Microsoft.Win32;
|
||||
|
||||
namespace VideoDownloader.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// UC-T13 시스템 테마 판별 — Windows 개인 설정(AppsUseLightTheme) 레지스트리를 읽어
|
||||
/// dark/light 를 판정하고, 설정값(dark/light/system/highcontrast/파일경로)을 실제 적용할
|
||||
/// 테마로 해석한다. 레지스트리 기반이라 WPF 스레드 없이도 단위 테스트가 가능하다.
|
||||
/// </summary>
|
||||
public static class ThemeResolver
|
||||
{
|
||||
/// <summary>설정 테마 값 → (테마 사전 이름, 사용자 정의 .xaml 경로|null).</summary>
|
||||
public static (string Theme, string? CustomPath) Resolve(string setting, string? customThemePath = null)
|
||||
{
|
||||
var s = (setting ?? "dark").Trim().ToLowerInvariant();
|
||||
return s switch
|
||||
{
|
||||
"system" => (IsSystemLight() ? "light" : "dark", null),
|
||||
"highcontrast" => ("highcontrast", null),
|
||||
"light" => ("light", null),
|
||||
"dark" => ("dark", null),
|
||||
// 커스텀 테마 파일 경로가 설정에 있으면 우선
|
||||
_ when !string.IsNullOrWhiteSpace(customThemePath) && File.Exists(customThemePath) => ("custom", customThemePath),
|
||||
_ => ("dark", null),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Windows 앱 모드가 라이트면 true(레지스트리 접근 실패 시 다크 취급).</summary>
|
||||
public static bool IsSystemLight()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(
|
||||
@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
|
||||
if (key?.GetValue("AppsUseLightTheme") is int v) return v == 1;
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
114
VideoDownloader.App/Services/ToastNotifier.cs
Normal file
114
VideoDownloader.App/Services/ToastNotifier.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace VideoDownloader.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// UC-D11/UC-N02 토스트 알림 — 지정한 호스트 패널(우하단)에 카드를 띄우고
|
||||
/// 설정된 시간 후 사라지게 한다. 방해 금지(DoNotDisturb)면 표시를 억제하고 알림센터에만 쌓는다.
|
||||
/// UI 스레드 DispatcherTimer 기반이라 별도 스레드가 없다.
|
||||
/// </summary>
|
||||
public sealed class ToastNotifier
|
||||
{
|
||||
private readonly Panel _host;
|
||||
private readonly DispatcherTimer _timer;
|
||||
private readonly List<string> _history = new();
|
||||
|
||||
/// <summary>토스트 표시 시간. AppConfig.ToastDurationMs 와 연동.</summary>
|
||||
public TimeSpan Duration { get; set; } = TimeSpan.FromSeconds(4);
|
||||
|
||||
/// <summary>UC-N15 방해 금지 — true 면 토스트를 띄우지 않고 기록만.</summary>
|
||||
public bool DoNotDisturb { get; set; }
|
||||
|
||||
/// <summary>UC-N14 알림 센터용 최근 알림 이력(최신 우선, 상한 100).</summary>
|
||||
public IReadOnlyList<string> History => _history.ToList();
|
||||
|
||||
public ToastNotifier(Panel host)
|
||||
{
|
||||
_host = host;
|
||||
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
|
||||
_timer.Tick += (_, _) => Expire();
|
||||
}
|
||||
|
||||
/// <summary>토스트 표시. kind 에 따라 아이콘·테두리 색이 달라진다(success/error/info).</summary>
|
||||
public void Show(string message, string kind = "info")
|
||||
{
|
||||
_history.Insert(0, $"[{DateTime.Now:HH:mm}] {message}");
|
||||
if (_history.Count > 100) _history.RemoveAt(_history.Count - 100);
|
||||
if (DoNotDisturb) return;
|
||||
|
||||
var border = new Border
|
||||
{
|
||||
Background = (Brush)Application.Current.FindResource("CardBg"),
|
||||
BorderBrush = kind switch
|
||||
{
|
||||
"success" => (Brush)Application.Current.FindResource("SuccessBrush"),
|
||||
"error" => (Brush)Application.Current.FindResource("ErrorBrush"),
|
||||
_ => (Brush)Application.Current.FindResource("AccentBrush"),
|
||||
},
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(14, 10, 14, 10),
|
||||
Margin = new Thickness(0, 8, 0, 0),
|
||||
MaxWidth = 340,
|
||||
Effect = new DropShadowEffect { Opacity = 0.4, BlurRadius = 16, ShadowDepth = 4 },
|
||||
Tag = DateTime.UtcNow.Add(Duration),
|
||||
};
|
||||
var icon = kind switch
|
||||
{
|
||||
"success" => "\uE73E",
|
||||
"error" => "\uE7BA",
|
||||
_ => "\uE946",
|
||||
};
|
||||
var panel = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontFamily = (FontFamily)Application.Current.FindResource("IconFont"),
|
||||
FontSize = 15,
|
||||
Foreground = border.BorderBrush,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 9, 0),
|
||||
});
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = message,
|
||||
Foreground = (Brush)Application.Current.FindResource("TextPrimary"),
|
||||
FontSize = 12.5,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
border.Child = panel;
|
||||
border.MouseLeftButtonUp += (_, _) => Collapse(border);
|
||||
border.Opacity = 0;
|
||||
_host.Children.Add(border);
|
||||
|
||||
border.Loaded += (_, _) =>
|
||||
{
|
||||
var anim = new DoubleAnimation(0, 1, (System.Windows.Duration)Application.Current.FindResource("Duration.Fast"));
|
||||
border.BeginAnimation(UIElement.OpacityProperty, anim);
|
||||
};
|
||||
if (!_timer.IsEnabled) _timer.Start();
|
||||
}
|
||||
|
||||
private void Expire()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var child in _host.Children.OfType<Border>().ToList())
|
||||
{
|
||||
if (child.Tag is DateTime until && now >= until) Collapse(child);
|
||||
}
|
||||
if (_host.Children.Count == 0) _timer.Stop();
|
||||
}
|
||||
|
||||
private void Collapse(Border border)
|
||||
{
|
||||
var anim = new DoubleAnimation(1, 0, (System.Windows.Duration)Application.Current.FindResource("Duration.Fast"));
|
||||
anim.Completed += (_, _) => _host.Children.Remove(border);
|
||||
border.BeginAnimation(UIElement.OpacityProperty, anim);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
<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="600" Width="620"
|
||||
FontFamily="{StaticResource UiFont}"
|
||||
Background="{StaticResource WindowBgGradient}"
|
||||
Title="설정" Height="680" Width="860"
|
||||
FontFamily="{DynamicResource UiFont}"
|
||||
Background="{DynamicResource WindowBgGradient}"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="CanResize" ShowInTaskbar="False"
|
||||
WindowStyle="None">
|
||||
UseLayoutRounding="True"
|
||||
KeyboardNavigation.TabNavigation="Cycle"
|
||||
WindowStyle="None"
|
||||
AutomationProperties.Name="설정 창">
|
||||
|
||||
<WindowChrome.WindowChrome>
|
||||
<WindowChrome CaptionHeight="0"
|
||||
|
|
@ -23,153 +26,462 @@
|
|||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 캡션 (드래그 핸들 + 닫기) -->
|
||||
<Border Grid.Row="0" Background="{StaticResource PanelBg}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||
<!-- 캡션 (드래그 핸들 + 검색 + 닫기) -->
|
||||
<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="" ToolTip="닫기" Click="Close_Click"/>
|
||||
<TextBlock Text="설정" Foreground="{StaticResource TextPrimary}"
|
||||
Content="" 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>
|
||||
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="22,18">
|
||||
<StackPanel>
|
||||
<Grid Grid.Row="1" Margin="18,14,18,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="170"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Text="외관" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<CheckBox Name="ThemeLightBox" Style="{StaticResource DarkCheckBox}"
|
||||
Content="라이트 테마 사용 (적용 시 앱 재시작)" Margin="0,8,0,18"/>
|
||||
<!-- 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>
|
||||
|
||||
<TextBlock Text="저장 폴더" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,18">
|
||||
<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>
|
||||
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto" Padding="14,0,4,0">
|
||||
<StackPanel>
|
||||
|
||||
<TextBlock Text="홈 페이지 (홈 버튼이 이동할 주소)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,18">
|
||||
<TextBox Name="HomeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</Border>
|
||||
<!-- ==================== 외관 ==================== -->
|
||||
<StackPanel x:Name="SectionAppearance" Margin="0,0,0,20" Tag="외관">
|
||||
<TextBlock Text="외관" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
|
||||
|
||||
<StackPanel Margin="0,0,0,18">
|
||||
<TextBlock Text="기본 검색 엔진 (주소창에 검색어 입력 시)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<ComboBox Name="SearchEngineBox" Style="{StaticResource DarkComboBox}" Margin="0,6,0,4"
|
||||
SelectionChanged="SearchEngineBox_SelectionChanged"/>
|
||||
<TextBlock x:Name="SearchEngineHint" FontSize="11" Foreground="{StaticResource TextMuted}" TextWrapping="Wrap"
|
||||
Text="주소창에 `g 검색어`, `yt 검색어` 등 키워드를 입력하면 검색 엔진을 즉시 전환할 수 있어요"/>
|
||||
</StackPanel>
|
||||
<!-- 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>
|
||||
|
||||
<Grid Margin="0,0,0,18">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="세그먼트 동시 수" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
|
||||
<TextBox Name="ConcurrencyBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
<!-- 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 Grid.Column="2">
|
||||
<TextBlock Text="최대 동시 항목" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
|
||||
<TextBox Name="MaxConcurrentBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
|
||||
<!-- ==================== 일반 ==================== -->
|
||||
<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>
|
||||
<StackPanel Grid.Column="4">
|
||||
<TextBlock Text="최소 크기 (MB)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
|
||||
<TextBox Name="MinSizeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== 고급 (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>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="ffmpeg 경로" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,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 리먹스)" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,0,0,18"/>
|
||||
<!-- ==================== 실험적 (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>
|
||||
|
||||
<TextBlock Text="소셜 영상 (YouTube·Instagram·X)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="yt-dlp 로 소셜 사이트 영상을 받습니다" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,2,0,8"/>
|
||||
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource 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 사용" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,0,0,10"/>
|
||||
<!-- ==================== 업데이트·정보 ==================== -->
|
||||
<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"/>
|
||||
|
||||
<WrapPanel Orientation="Horizontal" Margin="0,0,0,14">
|
||||
<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>
|
||||
<!-- 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 Margin="0,0,0,18">
|
||||
<TextBlock Text="기본 화질" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<ComboBox Name="DefaultQualityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,6,0,0">
|
||||
<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>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Margin="0,0,0,18">
|
||||
<TextBlock Text="YouTube 클라이언트 (고급)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
|
||||
<TextBox Name="YoutubeClientsBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</Border>
|
||||
<TextBlock Text="비워두면 yt-dlp 기본값 사용(권장). 봇 차단/PO 토큰 오류 시 예: tv,android_vr,web" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,4,0,0" TextWrapping="Wrap"/>
|
||||
<!-- 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>
|
||||
|
||||
<TextBlock Text="브라우저 데이터" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<TextBlock x:Name="ProfileModeText" Text="" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,0,0,8" TextWrapping="Wrap"/>
|
||||
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,18">
|
||||
<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>
|
||||
|
||||
<TextBlock Text="반응할 형식" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="체크된 형식만 자동 감지/다운로드합니다" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,2,0,10"/>
|
||||
<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>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Border Grid.Row="2" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="22,12">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Style="{StaticResource GhostButton}" Content="취소" Width="90" Margin="0,0,8,0" IsCancel="True"/>
|
||||
<Button Style="{StaticResource PrimaryButton}" Content="확인" Width="90" Click="Ok_Click" IsDefault="True"/>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
|
@ -13,25 +14,106 @@ namespace VideoDownloader.App;
|
|||
public partial class SettingsWindow : Window
|
||||
{
|
||||
public AppConfig Config { get; }
|
||||
private readonly string _origTheme;
|
||||
private readonly AppConfig _original;
|
||||
private readonly Func<Task>? _clearBrowsingData;
|
||||
public bool ThemeChanged { get; private set; }
|
||||
private readonly List<StackPanel> _sections = new();
|
||||
private bool _needsRestart;
|
||||
|
||||
public SettingsWindow(AppConfig config, Func<Task>? clearBrowsingData = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
Config = config;
|
||||
_origTheme = config.Theme;
|
||||
_original = CloneConfig(config);
|
||||
_clearBrowsingData = clearBrowsingData;
|
||||
|
||||
LoadValues();
|
||||
|
||||
// UC-S02 카테고리 내비게이션 — 섹션 제목과 1:1
|
||||
var names = new[] { "외관", "글꼴", "일반", "다운로드", "알림", "접근성", "브라우저", "고급", "단축키", "실험적", "정보" };
|
||||
SettingsNavList.ItemsSource = names;
|
||||
_sections.Add(SectionAppearance);
|
||||
_sections.Add(SectionFont);
|
||||
_sections.Add(SectionGeneral);
|
||||
_sections.Add(SectionDownload);
|
||||
_sections.Add(SectionNotify);
|
||||
_sections.Add(SectionAccessibility);
|
||||
_sections.Add(SectionBrowser);
|
||||
if (AdvancedSection.Content is StackPanel advanced) _sections.Add(advanced);
|
||||
_sections.Add(SectionShortcuts);
|
||||
_sections.Add(SectionExperimental);
|
||||
_sections.Add(SectionAbout);
|
||||
|
||||
// UC-S20 버전 정보
|
||||
VersionText.Text = $"Video Downloader {CurrentVersion()} · {MainWindow.ProfileFolder}";
|
||||
|
||||
// UC-S08 Ctrl+S 저장
|
||||
PreviewKeyDown += (_, e) =>
|
||||
{
|
||||
if (e.Key == Key.S && Keyboard.Modifiers == ModifierKeys.Control) { Save(); DialogResult = true; e.Handled = true; }
|
||||
};
|
||||
}
|
||||
|
||||
private void LoadValues()
|
||||
{
|
||||
var config = Config;
|
||||
FolderBox.Text = config.OutputFolder;
|
||||
HomeBox.Text = config.HomeUrl;
|
||||
ConcurrencyBox.Text = config.Concurrency.ToString();
|
||||
MinSizeBox.Text = config.MinSizeMB.ToString();
|
||||
MaxConcurrentBox.Text = config.MaxConcurrentDownloads.ToString();
|
||||
ThemeLightBox.IsChecked = config.Theme.Equals("light", StringComparison.OrdinalIgnoreCase);
|
||||
RetriesBox.Text = config.Retries.ToString();
|
||||
FfmpegBox.Text = config.FfmpegPath ?? "";
|
||||
|
||||
// §A 테마 — 즉시 적용(UC-T03): 재시작 없음
|
||||
ThemeModeBox.SelectedValue = config.Theme;
|
||||
if (ThemeModeBox.SelectedIndex < 0) ThemeModeBox.SelectedValue = "dark";
|
||||
var accent = config.AccentColor;
|
||||
AccentBlue.IsChecked = accent == "#60A5FA" || accent == "#2563EB";
|
||||
AccentGreen.IsChecked = accent == "#34D399" || accent == "#15803D";
|
||||
AccentPink.IsChecked = accent == "#F472B6" || accent == "#DB2777";
|
||||
AccentDefault.IsChecked = !AccentBlue.IsChecked.Value && !AccentGreen.IsChecked.Value && !AccentPink.IsChecked.Value;
|
||||
AccentColorBox.Text = accent ?? "";
|
||||
CustomThemeBox.Text = config.CustomThemePath ?? "";
|
||||
|
||||
UiDensityBox.SelectedValue = config.UiDensity;
|
||||
if (UiDensityBox.SelectedIndex < 0) UiDensityBox.SelectedValue = "normal";
|
||||
UiScaleSlider.Value = Math.Clamp(config.UiScale, 1.0, 2.0);
|
||||
UiScaleLabel.Text = $"{Math.Round(UiScaleSlider.Value * 100)}%";
|
||||
UiFontScaleSlider.Value = Math.Clamp(config.UiFontScale <= 0 ? 1.0 : config.UiFontScale, 1.0, 2.0);
|
||||
|
||||
UiFontFamilyBox.Text = config.UiFontFamily ?? "";
|
||||
UiFontKoreanFallbackBox.Text = config.UiFontKoreanFallback ?? "Malgun Gothic";
|
||||
FontSizeBox.SelectedValue = config.UiFontSize.ToString("0");
|
||||
if (FontSizeBox.SelectedIndex < 0) FontSizeBox.SelectedValue = "13";
|
||||
|
||||
LanguageBox.SelectedValue = config.Language;
|
||||
if (LanguageBox.SelectedIndex < 0) LanguageBox.SelectedValue = "ko";
|
||||
AutoStartBox.IsChecked = config.AutoStart;
|
||||
MinimizeToTrayBox.IsChecked = config.MinimizeToTray;
|
||||
|
||||
MaxSpeedBox.Text = config.MaxDownloadSpeedKbps.ToString();
|
||||
AutoClearCompletedBox.IsChecked = config.AutoClearCompleted;
|
||||
OpenFolderOnCompleteBox.IsChecked = config.OpenFolderOnComplete;
|
||||
SubtitleLangBox.SelectedValue = config.SubtitleLanguage;
|
||||
if (SubtitleLangBox.SelectedIndex < 0) SubtitleLangBox.SelectedItem = SubtitleLangBox.Items.OfType<ComboBoxItem>().First();
|
||||
ScheduleBox.Text = config.DownloadScheduleWindow ?? "";
|
||||
|
||||
NotifySoundBox.IsChecked = config.NotifySound;
|
||||
DoNotDisturbBox.IsChecked = config.DoNotDisturb;
|
||||
PauseOnPowerSaverBox.IsChecked = config.PauseOnPowerSaver;
|
||||
ToastDurationBox.Text = (config.ToastDurationMs / 1000.0).ToString("0");
|
||||
|
||||
ReduceMotionBox.IsChecked = config.ReduceMotion;
|
||||
ShowCaptionsBox.IsChecked = config.ShowCaptions;
|
||||
FocusSelectsAddressBox.IsChecked = config.FocusSelectsAddress;
|
||||
PasteAndGoBox.IsChecked = config.PasteAndGo;
|
||||
|
||||
TabWheelSwitchBox.IsChecked = config.TabWheelSwitch;
|
||||
TabStripPositionBox.SelectedValue = config.TabStripPosition;
|
||||
if (TabStripPositionBox.SelectedIndex < 0) TabStripPositionBox.SelectedValue = "top";
|
||||
ConfirmCloseMultipleTabsBox.IsChecked = config.ConfirmCloseMultipleTabs;
|
||||
BookmarkBarVisibleBox.IsChecked = config.BookmarkBarVisible;
|
||||
|
||||
FmtHls.IsChecked = config.EnabledFormats.Contains("hls");
|
||||
FmtDash.IsChecked = config.EnabledFormats.Contains("dash");
|
||||
FmtMp4.IsChecked = config.EnabledFormats.Contains("mp4");
|
||||
|
|
@ -50,6 +132,11 @@ public partial class SettingsWindow : Window
|
|||
DefaultQualityBox.SelectedValue = config.DefaultSocialQuality;
|
||||
if (DefaultQualityBox.SelectedIndex < 0) DefaultQualityBox.SelectedIndex = 0;
|
||||
YoutubeClientsBox.Text = config.YoutubePlayerClients ?? "";
|
||||
UserAgentBox.Text = config.UserAgent;
|
||||
ProxyBox.Text = config.ProxyServer;
|
||||
|
||||
RestoreTabsSuspendedBox.IsChecked = config.RestoreTabsSuspended;
|
||||
TabTitleMultilineBox.IsChecked = config.TabTitleMultiline;
|
||||
|
||||
ProfilePathBox.Text = MainWindow.ProfileFolder;
|
||||
ProfileModeText.Text = config.PrivateMode
|
||||
|
|
@ -59,6 +146,150 @@ public partial class SettingsWindow : Window
|
|||
SearchEngineBox.ItemsSource = SearchEngines.Presets.Select(e => e.Name).ToList();
|
||||
SearchEngineBox.SelectedItem = SearchEngines.FindByName(config.SearchEngine)?.Name ?? "Google";
|
||||
if (SearchEngineBox.SelectedIndex < 0) SearchEngineBox.SelectedIndex = 0;
|
||||
|
||||
UpdateRepoBox.Text = config.UpdateRepo;
|
||||
|
||||
// UC-F14 폰트 미리보기 — 현재 설정 반영
|
||||
RefreshFontPreview();
|
||||
}
|
||||
|
||||
// ---- UC-S01 검색: 섹션 제목·내용에 검색어가 없으면 숨김 ----
|
||||
private void SettingsSearchBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
var q = SettingsSearchBox.Text?.Trim() ?? "";
|
||||
foreach (var s in _sections)
|
||||
{
|
||||
var text = SectionText(s);
|
||||
s.Visibility = q.Length == 0 || text.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SectionText(FrameworkElement section)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append(section.Tag ?? "");
|
||||
CollectText(section, sb);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void CollectText(DependencyObject d, System.Text.StringBuilder sb)
|
||||
{
|
||||
foreach (var child in System.Windows.LogicalTreeHelper.GetChildren(d))
|
||||
{
|
||||
switch (child)
|
||||
{
|
||||
case TextBlock tb: sb.AppendLine(tb.Text); break;
|
||||
case TextBox tx: sb.AppendLine(tx.Text); break;
|
||||
case DependencyObject dep: CollectText(dep, sb); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- UC-S02 내비게이션: 섹션 스크롤 ----
|
||||
private void SettingsNav_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (SettingsNavList.SelectedIndex < 0 || SettingsNavList.SelectedIndex >= _sections.Count) return;
|
||||
_sections[SettingsNavList.SelectedIndex].BringIntoView();
|
||||
}
|
||||
|
||||
// ---- §A 테마 라이브 적용(UC-T03/T04) ----
|
||||
private void ThemeModeBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (ThemeModeBox?.SelectedValue == null) return;
|
||||
var theme = ThemeModeBox.SelectedValue as string ?? "dark";
|
||||
Config.Theme = theme;
|
||||
App.ApplyTheme(theme, Config.CustomThemePath);
|
||||
App.ApplyAccent(Config.AccentColor);
|
||||
RefreshTitleBarChrome();
|
||||
(Owner as MainWindow)?.UpdateTitleBarTheme();
|
||||
}
|
||||
|
||||
private void Accent_Checked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (AccentColorBox == null) return;
|
||||
if (ReferenceEquals(sender, AccentDefault)) AccentColorBox.Text = "";
|
||||
else if (ReferenceEquals(sender, AccentBlue)) AccentColorBox.Text = "#2563EB";
|
||||
else if (ReferenceEquals(sender, AccentGreen)) AccentColorBox.Text = "#15803D";
|
||||
else if (ReferenceEquals(sender, AccentPink)) AccentColorBox.Text = "#DB2777";
|
||||
}
|
||||
|
||||
private void BrowseThemeFile(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new OpenFileDialog { Filter = "테마 파일|*.xaml" };
|
||||
if (dlg.ShowDialog() == true)
|
||||
{
|
||||
CustomThemeBox.Text = dlg.FileName;
|
||||
Config.CustomThemePath = dlg.FileName;
|
||||
App.ApplyTheme(Config.Theme, dlg.FileName); // 즉시 미리보기
|
||||
RefreshTitleBarChrome();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- §B/§C 라이브 미리보기 ----
|
||||
private void RefreshFontPreview()
|
||||
{
|
||||
var size = double.TryParse(FontSizeBox?.SelectedValue as string, out var s) ? s : 13;
|
||||
FontPreviewText.FontSize = size * Math.Clamp(UiFontScaleSlider.Value, 1.0, 2.0);
|
||||
}
|
||||
|
||||
private void UiScaleSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
{
|
||||
if (UiScaleLabel != null) UiScaleLabel.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||
(Owner as MainWindow)?.ApplyUiScale(e.NewValue); // UC-L25 라이브 미리보기
|
||||
}
|
||||
|
||||
private void UiFontScaleSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) => RefreshFontPreview();
|
||||
|
||||
// ---- DEPLOYMENT_PLAN §6 v1 업데이트 확인 ----
|
||||
internal async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var repo = UpdateRepoBox.Text?.Trim() ?? "";
|
||||
if (repo.Length == 0 || !repo.Contains('/'))
|
||||
{
|
||||
UpdateResultText.Text = "저장소를 owner/repo 형식으로 입력하세요 (예: user/videodownloader)";
|
||||
return;
|
||||
}
|
||||
|
||||
var btn = sender as Button;
|
||||
if (btn != null) btn.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
var current = CurrentVersion();
|
||||
var rid = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "win-arm64" : "win-x64";
|
||||
var result = await new VideoDownloader.Core.Update.UpdateChecker().CheckAsync(repo, current, rid);
|
||||
if (result.IsNewer)
|
||||
{
|
||||
UpdateResultText.Text = $"새 버전 {result.LatestVersion} (현재 {current})";
|
||||
var open = MessageBox.Show(
|
||||
$"새 버전 {result.LatestVersion} 이(가) 있습니다 (현재 {current}).\n릴리스 페이지를 열까요?",
|
||||
"업데이트", MessageBoxButton.YesNo, MessageBoxImage.Information);
|
||||
if (open == MessageBoxResult.Yes)
|
||||
{
|
||||
var url = result.Asset?.DownloadUrl ?? result.ReleaseUrl;
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateResultText.Text = $"최신 버전입니다 (현재 {current}, 최신 {result.LatestVersion})";
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
UpdateResultText.Text = $"확인 실패: {ex.Message}";
|
||||
}
|
||||
finally { if (btn != null) btn.IsEnabled = true; }
|
||||
}
|
||||
|
||||
private static string CurrentVersion()
|
||||
{
|
||||
var asm = System.Reflection.Assembly.GetExecutingAssembly();
|
||||
var info = asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
|
||||
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>().FirstOrDefault()?.InformationalVersion;
|
||||
// 소스 링크 해시(+abc...) 는 버전 비교에서 제외
|
||||
if (!string.IsNullOrEmpty(info)) return info.Split('+')[0];
|
||||
return asm.GetName().Version?.ToString() ?? "0.0.0";
|
||||
}
|
||||
|
||||
private void SearchEngineBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
|
|
@ -117,17 +348,149 @@ public partial class SettingsWindow : Window
|
|||
finally { if (btn != null) btn.IsEnabled = true; }
|
||||
}
|
||||
|
||||
private void Ok_Click(object sender, RoutedEventArgs e)
|
||||
// ---- UC-S04/S05/S06 기본값·내보내기·가져오기 ----
|
||||
private void ResetDefaults_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MessageBox.Show("모든 설정을 기본값으로 되돌릴까요? 저장 폴더·토큰도 초기화됩니다.",
|
||||
"기본값 복원", MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK) return;
|
||||
Config.ResetToDefaults();
|
||||
LoadValues();
|
||||
ValidationHint.Text = "기본값으로 되돌렸습니다 — 확인을 누르면 저장됩니다";
|
||||
}
|
||||
|
||||
private void ExportSettings_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Save(); // 화면 값 → Config 반영 후 내보냄
|
||||
var dlg = new SaveFileDialog { Filter = "설정 JSON|*.json", FileName = "videodownloader-settings.json" };
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
File.WriteAllText(dlg.FileName, System.Text.Json.JsonSerializer.Serialize(Config, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private void ImportSettings_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new OpenFileDialog { Filter = "설정 JSON|*.json" };
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
var imported = System.Text.Json.JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(dlg.FileName));
|
||||
if (imported == null) throw new InvalidOperationException("빈 설정 파일");
|
||||
ImportInto(imported);
|
||||
LoadValues();
|
||||
ValidationHint.Text = "설정을 가져왔습니다 — 확인을 누르면 저장됩니다";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ValidationHint.Text = $"가져오기 실패: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void ImportInto(AppConfig src)
|
||||
{
|
||||
foreach (var prop in typeof(AppConfig).GetProperties().Where(p => p.CanRead && p.CanWrite))
|
||||
{
|
||||
if (prop.Name == nameof(AppConfig.RemoteAccessToken)) continue; // 토큰은 현재 기기 값 유지
|
||||
try { prop.SetValue(Config, prop.GetValue(src)); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private void RestartNow_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Save();
|
||||
MainWindow.RestartApp();
|
||||
}
|
||||
|
||||
/// <summary>UC-S09 취소 시 변경 폐기 확인.</summary>
|
||||
public bool ConfirmDiscardChanges()
|
||||
{
|
||||
if (!IsDirty()) return true;
|
||||
return MessageBox.Show(this, "저장하지 않은 변경 사항이 있습니다.\n폐기하고 닫을까요?",
|
||||
"변경 사항", MessageBoxButton.OKCancel, MessageBoxImage.Warning) == MessageBoxResult.OK;
|
||||
}
|
||||
|
||||
private bool IsDirty() => !System.Text.Json.JsonSerializer.Serialize(Config).Equals(
|
||||
System.Text.Json.JsonSerializer.Serialize(_original));
|
||||
|
||||
private static AppConfig CloneConfig(AppConfig c)
|
||||
{
|
||||
var clone = new AppConfig();
|
||||
foreach (var prop in typeof(AppConfig).GetProperties().Where(p => p.CanRead && p.CanWrite))
|
||||
try { prop.SetValue(clone, prop.GetValue(c)); } catch { }
|
||||
return clone;
|
||||
}
|
||||
|
||||
private void Cancel_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ConfirmDiscardChanges()) Close();
|
||||
}
|
||||
|
||||
/// <summary>화면 값 → Config 반영(검증 포함). 오류는 ValidationHint 에 표시하고 false 반환.</summary>
|
||||
private bool Save()
|
||||
{
|
||||
ValidationHint.Text = "";
|
||||
if (!int.TryParse(ConcurrencyBox.Text, out var c) || c < 1) return Invalid("세그먼트 동시 수는 1 이상의 숫자");
|
||||
if (!int.TryParse(MaxConcurrentBox.Text, out var mc) || mc < 1) return Invalid("최대 동시 항목은 1 이상의 숫자");
|
||||
if (!int.TryParse(RetriesBox.Text, out var retries) || retries < 1) return Invalid("재시도 횟수는 1 이상의 숫자");
|
||||
if (!long.TryParse(MinSizeBox.Text, out var m) || m < 0) return Invalid("최소 크기는 0 이상의 숫자");
|
||||
if (!int.TryParse(MaxSpeedBox.Text, out var speed) || speed < 0) return Invalid("속도 상한은 0 이상의 숫자");
|
||||
if (!double.TryParse(ToastDurationBox.Text, out var toastSec) || toastSec < 0.5) return Invalid("토스트 시간은 0.5초 이상");
|
||||
if (!string.IsNullOrWhiteSpace(ScheduleBox.Text) && !Services.DownloadScheduler.TryParse(ScheduleBox.Text.Trim(), out _, out _))
|
||||
return Invalid("예약 시간대는 HH:mm-HH:mm 형식(예: 02:00-08:00)");
|
||||
var accent = AccentColorBox.Text?.Trim();
|
||||
if (!string.IsNullOrEmpty(accent) &&
|
||||
!(accent.Length == 7 && accent.StartsWith('#') && accent[1..].All(ch => Uri.IsHexDigit(ch))))
|
||||
return Invalid("강조색은 #RRGGBB 형식");
|
||||
|
||||
Config.OutputFolder = FolderBox.Text?.Trim() ?? "";
|
||||
Config.HomeUrl = string.IsNullOrWhiteSpace(HomeBox.Text) ? "https://www.google.com" : HomeBox.Text.Trim();
|
||||
if (int.TryParse(ConcurrencyBox.Text, out var c)) Config.Concurrency = Math.Max(1, c);
|
||||
if (long.TryParse(MinSizeBox.Text, out var m) && m >= 0) Config.MinSizeMB = m;
|
||||
Config.Concurrency = Math.Max(1, c);
|
||||
Config.MaxConcurrentDownloads = Math.Max(1, mc);
|
||||
Config.Retries = Math.Max(1, retries);
|
||||
Config.MinSizeMB = Math.Max(0, m);
|
||||
Config.MaxDownloadSpeedKbps = speed;
|
||||
Config.FfmpegPath = string.IsNullOrWhiteSpace(FfmpegBox.Text) ? null : FfmpegBox.Text.Trim();
|
||||
|
||||
Config.Theme = ThemeLightBox.IsChecked == true ? "light" : "dark";
|
||||
if (int.TryParse(MaxConcurrentBox.Text, out var mc)) Config.MaxConcurrentDownloads = Math.Max(1, mc);
|
||||
ThemeChanged = !Config.Theme.Equals(_origTheme, StringComparison.OrdinalIgnoreCase);
|
||||
Config.Theme = ThemeModeBox.SelectedValue as string ?? "dark";
|
||||
Config.AccentColor = string.IsNullOrEmpty(accent) ? null : accent;
|
||||
Config.CustomThemePath = string.IsNullOrWhiteSpace(CustomThemeBox.Text) ? null : CustomThemeBox.Text.Trim();
|
||||
Config.UiDensity = UiDensityBox.SelectedValue as string ?? "normal";
|
||||
Config.UiScale = Math.Clamp(UiScaleSlider.Value, 1.0, 2.0);
|
||||
Config.UiFontScale = Math.Clamp(UiFontScaleSlider.Value, 1.0, 2.0);
|
||||
Config.UiFontFamily = UiFontFamilyBox.Text?.Trim() ?? "";
|
||||
Config.UiFontKoreanFallback = string.IsNullOrWhiteSpace(UiFontKoreanFallbackBox.Text) ? "Malgun Gothic" : UiFontKoreanFallbackBox.Text.Trim();
|
||||
Config.UiFontSize = double.TryParse(FontSizeBox.SelectedValue as string, out var fs) ? fs : 13;
|
||||
|
||||
Config.Language = LanguageBox.SelectedValue as string ?? "ko";
|
||||
Config.AutoStart = AutoStartBox.IsChecked == true;
|
||||
Config.MinimizeToTray = MinimizeToTrayBox.IsChecked == true;
|
||||
Config.SearchEngine = SearchEngineBox.SelectedItem as string ?? "Google";
|
||||
Config.UpdateRepo = UpdateRepoBox.Text?.Trim() ?? "";
|
||||
Config.UserAgent = string.IsNullOrWhiteSpace(UserAgentBox.Text)
|
||||
? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
: UserAgentBox.Text.Trim();
|
||||
var proxyChanged = Config.ProxyServer != (ProxyBox.Text?.Trim() ?? "");
|
||||
Config.ProxyServer = ProxyBox.Text?.Trim() ?? "";
|
||||
|
||||
Config.AutoClearCompleted = AutoClearCompletedBox.IsChecked == true;
|
||||
Config.OpenFolderOnComplete = OpenFolderOnCompleteBox.IsChecked == true;
|
||||
Config.SubtitleLanguage = (SubtitleLangBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "";
|
||||
Config.DownloadScheduleWindow = string.IsNullOrWhiteSpace(ScheduleBox.Text) ? null : ScheduleBox.Text.Trim();
|
||||
|
||||
Config.NotifySound = NotifySoundBox.IsChecked == true;
|
||||
Config.DoNotDisturb = DoNotDisturbBox.IsChecked == true;
|
||||
Config.PauseOnPowerSaver = PauseOnPowerSaverBox.IsChecked == true;
|
||||
Config.ToastDurationMs = (int)(toastSec * 1000);
|
||||
|
||||
Config.ReduceMotion = ReduceMotionBox.IsChecked == true;
|
||||
Config.ShowCaptions = ShowCaptionsBox.IsChecked == true;
|
||||
Config.FocusSelectsAddress = FocusSelectsAddressBox.IsChecked == true;
|
||||
Config.PasteAndGo = PasteAndGoBox.IsChecked == true;
|
||||
|
||||
Config.TabWheelSwitch = TabWheelSwitchBox.IsChecked == true;
|
||||
Config.TabStripPosition = TabStripPositionBox.SelectedValue as string ?? "top";
|
||||
Config.ConfirmCloseMultipleTabs = ConfirmCloseMultipleTabsBox.IsChecked == true;
|
||||
Config.BookmarkBarVisible = BookmarkBarVisibleBox.IsChecked == true;
|
||||
Config.RestoreTabsSuspended = RestoreTabsSuspendedBox.IsChecked == true;
|
||||
Config.TabTitleMultiline = TabTitleMultilineBox.IsChecked == true;
|
||||
|
||||
var fmts = new List<string>();
|
||||
if (FmtHls.IsChecked == true) fmts.Add("hls");
|
||||
|
|
@ -148,18 +511,63 @@ public partial class SettingsWindow : Window
|
|||
Config.AutoInstallDeno = AutoInstallDenoBox.IsChecked == true;
|
||||
Config.DefaultSocialQuality = (DefaultQualityBox.SelectedValue as string) ?? "best";
|
||||
Config.YoutubePlayerClients = string.IsNullOrWhiteSpace(YoutubeClientsBox.Text) ? null : YoutubeClientsBox.Text.Trim();
|
||||
Config.SearchEngine = SearchEngineBox.SelectedItem as string ?? "Google";
|
||||
|
||||
// 즉시 반영(재시작 없음 — §A/§B/§G)
|
||||
App.ApplyFontSettings();
|
||||
App.ApplyReduceMotion(Config.ReduceMotion);
|
||||
App.ApplyAccent(Config.AccentColor);
|
||||
|
||||
// UC-S15 자동 실행 등록(HKCU Run)
|
||||
ApplyAutoStart(Config.AutoStart);
|
||||
|
||||
// UC-S03 재시작 배너 — 프록시가 바뀌었으면 알림
|
||||
if (proxyChanged) _needsRestart = true;
|
||||
RestartBanner.Visibility = _needsRestart ? Visibility.Visible : Visibility.Collapsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool Invalid(string message)
|
||||
{
|
||||
ValidationHint.Text = message;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Windows 시작 자동 실행(HKCU\...\Run) 등록/해제.</summary>
|
||||
private static void ApplyAutoStart(bool enable)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run");
|
||||
var name = "VideoDownloader";
|
||||
if (enable)
|
||||
{
|
||||
var exe = Environment.ProcessPath;
|
||||
if (!string.IsNullOrEmpty(exe)) key.SetValue(name, $"\"{exe}\"");
|
||||
}
|
||||
else key.DeleteValue(name, throwOnMissingValue: false);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void Ok_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!Save()) return;
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
// 보더리스: Win11 어두운 테두리 + 둥근 모서리
|
||||
// 보더리스: Win11 어두운 테두리 + 둥근 모서리. 테마 라이브 전환 시 재적용(RefreshTitleBarChrome).
|
||||
protected override void OnSourceInitialized(EventArgs e)
|
||||
{
|
||||
base.OnSourceInitialized(e);
|
||||
RefreshTitleBarChrome();
|
||||
}
|
||||
|
||||
internal void RefreshTitleBarChrome()
|
||||
{
|
||||
try
|
||||
{
|
||||
var hwnd = new WindowInteropHelper(this).Handle;
|
||||
if (hwnd == IntPtr.Zero) return;
|
||||
int dark = App.IsLightTheme ? 0 : 1; // DWMWA_USE_IMMERSIVE_DARK_MODE (라이트 테마면 밝게)
|
||||
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
|
||||
int round = 2; // DWMWCP_ROUND
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ===== 다크 팔레트 (단일 보라 액센트 - 의도적 통일) ===== -->
|
||||
<!-- ===== 다크 팔레트 (단일 보라 액센트 - 의도적 통일) =====
|
||||
UC-T14/T15/T18/A20: WCAG 대비 결함 수정 — TextMuted/BorderBrush/AccentBrush 밝게 조정. -->
|
||||
<SolidColorBrush x:Key="WindowBg" Color="#0B0B10"/>
|
||||
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#11111B" Offset="0"/>
|
||||
|
|
@ -12,24 +13,38 @@
|
|||
<SolidColorBrush x:Key="GlassBg" Color="#1A1A24"/>
|
||||
<SolidColorBrush x:Key="CardBg" Color="#1C1C26"/>
|
||||
<SolidColorBrush x:Key="CardBgHover" Color="#242430"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#343442"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#6C6C8A"/>
|
||||
<SolidColorBrush x:Key="BorderSubtle" Color="#20202B"/>
|
||||
<SolidColorBrush x:Key="TextPrimary" Color="#F2F2F6"/>
|
||||
<SolidColorBrush x:Key="TextSecondary" Color="#A9B0C5"/>
|
||||
<SolidColorBrush x:Key="TextMuted" Color="#7C8296"/>
|
||||
<SolidColorBrush x:Key="TextMuted" Color="#8B91A6"/>
|
||||
<SolidColorBrush x:Key="TextDisabled" Color="#5A5F72"/>
|
||||
<SolidColorBrush x:Key="TrackBrush" Color="#262631"/>
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399"/>
|
||||
<SolidColorBrush x:Key="ErrorBrush" Color="#F87171"/>
|
||||
<SolidColorBrush x:Key="PendingBrush" Color="#FBBF24"/>
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="#FB923C"/>
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="#60A5FA"/>
|
||||
<SolidColorBrush x:Key="SelectionBrush" Color="#4A3F9E"/>
|
||||
<SolidColorBrush x:Key="FocusAdornerBrush" Color="#B4A7FF"/>
|
||||
<SolidColorBrush x:Key="LinkBrush" Color="#93B4FF"/>
|
||||
<SolidColorBrush x:Key="VisitedLinkBrush" Color="#C4A9E8"/>
|
||||
<SolidColorBrush x:Key="AccentTextBrush" Color="#A99DFF"/>
|
||||
|
||||
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#6366F1" Offset="0"/>
|
||||
<GradientStop Color="#A855F7" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#7C6CFF"/>
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#8B7CFF"/>
|
||||
|
||||
<LinearGradientBrush x:Key="AccentFadeBrush" StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#556366F1" Offset="0"/>
|
||||
<GradientStop Color="#006366F1" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
|
||||
<!-- UC-T09 강조색 프리셋 스와치 (설정창 색 선택기가 참조) -->
|
||||
<SolidColorBrush x:Key="Accent.Blue" Color="#60A5FA"/>
|
||||
<SolidColorBrush x:Key="Accent.Green" Color="#34D399"/>
|
||||
<SolidColorBrush x:Key="Accent.Pink" Color="#F472B6"/>
|
||||
<SolidColorBrush x:Key="Accent.Orange" Color="#FB923C"/>
|
||||
</ResourceDictionary>
|
||||
|
|
|
|||
49
VideoDownloader.App/Themes/HighContrast.xaml
Normal file
49
VideoDownloader.App/Themes/HighContrast.xaml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ===== 고대비 팔레트 (UC-T05/A04) — Windows 고대비 테마 관례: 검정 배경 + 원색.
|
||||
Dark/Light 와 동일 키를 전부 정의해 Shared.xaml 스타일이 그대로 동작한다. ===== -->
|
||||
<SolidColorBrush x:Key="WindowBg" Color="#000000"/>
|
||||
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#000000" Offset="0"/>
|
||||
<GradientStop Color="#000000" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
<SolidColorBrush x:Key="PanelBg" Color="#000000"/>
|
||||
<SolidColorBrush x:Key="GlassBg" Color="#1A1A1A"/>
|
||||
<SolidColorBrush x:Key="CardBg" Color="#000000"/>
|
||||
<SolidColorBrush x:Key="CardBgHover" Color="#2D2D2D"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="BorderSubtle" Color="#767676"/>
|
||||
<SolidColorBrush x:Key="TextPrimary" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="TextSecondary" Color="#E8E8E8"/>
|
||||
<SolidColorBrush x:Key="TextMuted" Color="#D0D0D0"/>
|
||||
<SolidColorBrush x:Key="TextDisabled" Color="#8A8A8A"/>
|
||||
<SolidColorBrush x:Key="TrackBrush" Color="#5A5A5A"/>
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#00FF66"/>
|
||||
<SolidColorBrush x:Key="ErrorBrush" Color="#FF5A5A"/>
|
||||
<SolidColorBrush x:Key="PendingBrush" Color="#FFFF00"/>
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="#FFA500"/>
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="#00C8FF"/>
|
||||
<SolidColorBrush x:Key="SelectionBrush" Color="#FFFF00"/>
|
||||
<SolidColorBrush x:Key="FocusAdornerBrush" Color="#FFFF00"/>
|
||||
<SolidColorBrush x:Key="LinkBrush" Color="#00C8FF"/>
|
||||
<SolidColorBrush x:Key="VisitedLinkBrush" Color="#C77BFF"/>
|
||||
<SolidColorBrush x:Key="AccentTextBrush" Color="#FFFF00"/>
|
||||
|
||||
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#FFFF00" Offset="0"/>
|
||||
<GradientStop Color="#FFFF00" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#FFFF00"/>
|
||||
|
||||
<LinearGradientBrush x:Key="AccentFadeBrush" StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#66FFFF00" Offset="0"/>
|
||||
<GradientStop Color="#00FFFF00" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
|
||||
<!-- UC-T09 강조색 프리셋 (고대비에서도 동일 키 제공) -->
|
||||
<SolidColorBrush x:Key="Accent.Blue" Color="#00C8FF"/>
|
||||
<SolidColorBrush x:Key="Accent.Green" Color="#00FF66"/>
|
||||
<SolidColorBrush x:Key="Accent.Pink" Color="#FF6EC7"/>
|
||||
<SolidColorBrush x:Key="Accent.Orange" Color="#FFA500"/>
|
||||
</ResourceDictionary>
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ===== 라이트 팔레트 (동일 키 - Shared.xaml 의 스타일이 그대로 동작) ===== -->
|
||||
<!-- ===== 라이트 팔레트 (동일 키 - Shared.xaml 의 스타일이 그대로 동작) =====
|
||||
UC-T16/T17/A19: TextMuted 어둡게(#63687A), TrackBrush 명확히(#808798) 조정. -->
|
||||
<SolidColorBrush x:Key="WindowBg" Color="#F6F7F9"/>
|
||||
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#FFFFFF" Offset="0"/>
|
||||
|
|
@ -12,15 +13,23 @@
|
|||
<SolidColorBrush x:Key="GlassBg" Color="#E9EBF0"/>
|
||||
<SolidColorBrush x:Key="CardBg" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="CardBgHover" Color="#EEF0F4"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#D5D9E1"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#AEB4C4"/>
|
||||
<SolidColorBrush x:Key="BorderSubtle" Color="#E3E6EC"/>
|
||||
<SolidColorBrush x:Key="TextPrimary" Color="#1B1C24"/>
|
||||
<SolidColorBrush x:Key="TextSecondary" Color="#4A4E5E"/>
|
||||
<SolidColorBrush x:Key="TextMuted" Color="#767B8C"/>
|
||||
<SolidColorBrush x:Key="TrackBrush" Color="#DBDFE6"/>
|
||||
<SolidColorBrush x:Key="TextMuted" Color="#63687A"/>
|
||||
<SolidColorBrush x:Key="TextDisabled" Color="#9DA2B1"/>
|
||||
<SolidColorBrush x:Key="TrackBrush" Color="#808798"/>
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#15A34A"/>
|
||||
<SolidColorBrush x:Key="ErrorBrush" Color="#DC2626"/>
|
||||
<SolidColorBrush x:Key="PendingBrush" Color="#C77708"/>
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="#B45309"/>
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="#2563EB"/>
|
||||
<SolidColorBrush x:Key="SelectionBrush" Color="#C7CFFB"/>
|
||||
<SolidColorBrush x:Key="FocusAdornerBrush" Color="#5B5FE3"/>
|
||||
<SolidColorBrush x:Key="LinkBrush" Color="#2C5BD1"/>
|
||||
<SolidColorBrush x:Key="VisitedLinkBrush" Color="#7C3AAB"/>
|
||||
<SolidColorBrush x:Key="AccentTextBrush" Color="#5B5FE3"/>
|
||||
|
||||
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#6366F1" Offset="0"/>
|
||||
|
|
@ -32,4 +41,10 @@
|
|||
<GradientStop Color="#306366F1" Offset="0"/>
|
||||
<GradientStop Color="#006366F1" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
|
||||
<!-- UC-T09 강조색 프리셋 스와치 (다크와 동일 키 — 배경에 따라 살짝 어둡게) -->
|
||||
<SolidColorBrush x:Key="Accent.Blue" Color="#2563EB"/>
|
||||
<SolidColorBrush x:Key="Accent.Green" Color="#15803D"/>
|
||||
<SolidColorBrush x:Key="Accent.Pink" Color="#DB2777"/>
|
||||
<SolidColorBrush x:Key="Accent.Orange" Color="#C2410C"/>
|
||||
</ResourceDictionary>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,144 @@
|
|||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sys="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!-- 폰트 (테마 무관) — 한국어 폴백 포함(맑은 고딕) -->
|
||||
<FontFamily x:Key="UiFont">Segoe UI Variable Text, Segoe UI, Malgun Gothic</FontFamily>
|
||||
<!-- 폰트 (테마 무관) — 한국어 가독성 프리셋 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>
|
||||
|
||||
<!-- 암시 스타일: 모든 텍스트가 UI 폰트로 통일(Foreground 는 상속 존중) -->
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
</Style>
|
||||
<Style TargetType="ToolTip">
|
||||
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
|
||||
<Setter Property="Background" Value="{StaticResource CardBg}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<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="{StaticResource UiFont}"/>
|
||||
<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="{StaticResource AccentGradient}" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<Border x:Name="bd" Background="{DynamicResource AccentGradient}" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
|
|
@ -39,7 +146,10 @@
|
|||
<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"/></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>
|
||||
|
|
@ -48,28 +158,33 @@
|
|||
|
||||
<!-- ===== Ghost 버튼 ===== -->
|
||||
<Style x:Key="GhostButton" TargetType="Button">
|
||||
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
|
||||
<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="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<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="{StaticResource CardBgHover}"/>
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
|
||||
<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"/></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>
|
||||
|
|
@ -78,28 +193,28 @@
|
|||
|
||||
<!-- ===== 아이콘 전용 버튼 (Fluent) ===== -->
|
||||
<Style x:Key="IconButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
|
||||
<Setter Property="FontFamily" Value="{StaticResource IconFont}"/>
|
||||
<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="{StaticResource TextSecondary}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Fluent 아이콘 TextBlock ===== -->
|
||||
<Style x:Key="FluentIcon" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{StaticResource IconFont}"/>
|
||||
<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="{StaticResource IconFont}"/>
|
||||
<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="{StaticResource TextSecondary}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
|
||||
<Setter Property="Cursor" Value="Arrow"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
|
|
@ -109,11 +224,11 @@
|
|||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
|
||||
<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="{StaticResource BorderBrush}"/>
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource BorderBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
|
|
@ -148,12 +263,14 @@
|
|||
|
||||
<!-- ===== 투명 행 버튼 ===== -->
|
||||
<Style x:Key="FlatButton" TargetType="Button">
|
||||
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
|
||||
<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>
|
||||
|
|
@ -162,7 +279,7 @@
|
|||
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/></Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/></Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
|
|
@ -179,8 +296,9 @@
|
|||
</Style>
|
||||
<Style x:Key="SuggestItem" TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="12,7"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
|
||||
<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">
|
||||
|
|
@ -188,8 +306,8 @@
|
|||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/></Trigger>
|
||||
<Trigger Property="IsSelected" Value="True"><Setter TargetName="bd" Property="Background" Value="{StaticResource GlassBg}"/></Trigger>
|
||||
<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>
|
||||
|
|
@ -198,15 +316,16 @@
|
|||
|
||||
<!-- ===== TextBox ===== -->
|
||||
<Style x:Key="DarkTextBox" TargetType="TextBox">
|
||||
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
|
||||
<Setter Property="Background" Value="{StaticResource GlassBg}"/>
|
||||
<Setter Property="CaretBrush" Value="{StaticResource TextPrimary}"/>
|
||||
<Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<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">
|
||||
|
|
@ -215,31 +334,27 @@
|
|||
Focusable="False" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/></Trigger>
|
||||
<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 ===== -->
|
||||
<!-- ===== 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="{StaticResource TrackBrush}"/>
|
||||
<Border x:Name="PART_Indicator" CornerRadius="4" HorizontalAlignment="Left">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
|
||||
<GradientStop Color="#6366F1" Offset="0"/>
|
||||
<GradientStop Color="#A855F7" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
<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>
|
||||
|
|
@ -249,19 +364,14 @@
|
|||
<!-- ===== 부정확(길이 미상) 진행률 바 ===== -->
|
||||
<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="{StaticResource TrackBrush}"/>
|
||||
<Border x:Name="shimmer" Width="48" CornerRadius="4" HorizontalAlignment="Left" RenderTransformOrigin="0,0">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
|
||||
<GradientStop Color="#556366F1" Offset="0"/>
|
||||
<GradientStop Color="#CCA855F7" Offset="0.5"/>
|
||||
<GradientStop Color="#556366F1" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
<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>
|
||||
|
|
@ -271,7 +381,7 @@
|
|||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetName="shimmer"
|
||||
Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.X)"
|
||||
From="-50" To="470" Duration="0:0:1.3" RepeatBehavior="Forever"/>
|
||||
From="-50" To="470" Duration="{DynamicResource Duration.Normal}" RepeatBehavior="Forever"/>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</EventTrigger>
|
||||
|
|
@ -283,46 +393,82 @@
|
|||
|
||||
<!-- ===== CheckBox ===== -->
|
||||
<Style x:Key="DarkCheckBox" TargetType="CheckBox">
|
||||
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
|
||||
<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="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1.5">
|
||||
<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="{StaticResource AccentGradient}"/>
|
||||
<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="{StaticResource AccentBrush}"/></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 (어두운 테마, 카드 화질 선택용) ===== -->
|
||||
<!-- ===== 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="{StaticResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
|
||||
<Setter Property="Background" Value="{StaticResource GlassBg}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<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="{StaticResource TextPrimary}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="Padding" Value="10,5"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
|
|
@ -333,10 +479,10 @@
|
|||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="ibd" Property="Background" Value="{StaticResource AccentFadeBrush}"/>
|
||||
<Setter TargetName="ibd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="ibd" Property="Background" Value="{StaticResource AccentFadeBrush}"/>
|
||||
<Setter TargetName="ibd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
|
|
@ -373,14 +519,16 @@
|
|||
TextBlock.Foreground="{TemplateBinding Foreground}"
|
||||
TextBlock.FontSize="{TemplateBinding FontSize}"/>
|
||||
<TextBlock x:Name="arrow" Grid.Column="1" Text="" Style="{StaticResource FluentIcon}"
|
||||
FontSize="9" Foreground="{StaticResource TextMuted}"
|
||||
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="{StaticResource CardBg}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="4"
|
||||
MinWidth="{TemplateBinding ActualWidth}" MaxHeight="340">
|
||||
<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>
|
||||
|
|
@ -389,7 +537,7 @@
|
|||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsDropDownOpen" Value="True">
|
||||
<Setter TargetName="MainBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
<Setter TargetName="MainBorder" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5"/>
|
||||
|
|
@ -400,7 +548,7 @@
|
|||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 얇은 스크롤바 ===== -->
|
||||
<!-- ===== 얇은 스크롤바 (UC-T22 썸 색 토큰) ===== -->
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Width" Value="8"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
|
|
@ -415,7 +563,7 @@
|
|||
<Thumb>
|
||||
<Thumb.Template>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border CornerRadius="4" Background="{StaticResource BorderBrush}"/>
|
||||
<Border CornerRadius="4" Background="{DynamicResource ScrollBarThumbBrush}"/>
|
||||
</ControlTemplate>
|
||||
</Thumb.Template>
|
||||
</Thumb>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,19 @@ public class DownloadItemViewModel : INotifyPropertyChanged
|
|||
public CancellationTokenSource? Cts { get; set; }
|
||||
public bool Started { get; set; }
|
||||
|
||||
/// <summary>§E 일시정지 상태(재개 대기) — 임시 파일 보존·"일시정지" 상태 표시 판단에 사용.</summary>
|
||||
public bool PausedForResume { get; set; }
|
||||
|
||||
/// <summary>UC-D30 사이트별 묶음 — 출처 페이지 호스트(없으면 미디어 URL 호스트).</summary>
|
||||
public string SiteName
|
||||
{
|
||||
get
|
||||
{
|
||||
var source = PageUrl ?? Url ?? "";
|
||||
return Uri.TryCreate(source, UriKind.Absolute, out var u) ? u.Host : "직접 링크";
|
||||
}
|
||||
}
|
||||
|
||||
public string Status { get => _status; set => SetField(ref _status, value); }
|
||||
public int Progress { get => _progress; set => SetField(ref _progress, value); }
|
||||
// #9 직링크 길이 미상 시 진행률 바를 부정확(애니메이션) 모드로
|
||||
|
|
@ -50,6 +63,71 @@ public class DownloadItemViewModel : INotifyPropertyChanged
|
|||
public string? OutputPath { get => _outputPath; set => SetField(ref _outputPath, value); }
|
||||
public string? Error { get => _error; set => SetField(ref _error, value); }
|
||||
|
||||
// ---- §E 다운로드 관리자 확장 (docs/UI_USECASES.md) ----
|
||||
|
||||
/// <summary>UC-D05 현재 속도 표시("2.4 MB/s" 등). DownloadManager 진행 콜백에서 갱신.</summary>
|
||||
public string SpeedText { get => _speedText; set => SetField(ref _speedText, value); }
|
||||
private string _speedText = "";
|
||||
|
||||
/// <summary>UC-D04 남은 시간 추정("3분 남음" 등). 속도·진행률로 계산.</summary>
|
||||
public string RemainingText { get => _remainingText; set => SetField(ref _remainingText, value); }
|
||||
private string _remainingText = "";
|
||||
|
||||
/// <summary>UC-D19 파일 총 크기 표시("142.5 MB" 등). 길이 미상이면 "".</summary>
|
||||
public string TotalSizeText { get => _totalSizeText; set => SetField(ref _totalSizeText, value); }
|
||||
private string _totalSizeText = "";
|
||||
|
||||
/// <summary>UC-D09 재시도 횟수.</summary>
|
||||
public int RetryCount { get => _retryCount; set => SetField(ref _retryCount, value); }
|
||||
private int _retryCount;
|
||||
|
||||
/// <summary>UC-D10 세그먼트별 진행 맵 — 각 세그먼트 진행률(0~1) 목록을 순서대로 유지.</summary>
|
||||
public System.Collections.ObjectModel.ObservableCollection<double> SegmentMap { get; } = new();
|
||||
|
||||
/// <summary>UC-D24 대기열 우선순위(클수록 먼저). 기본 0.</summary>
|
||||
public int Priority { get => _priority; set => SetField(ref _priority, value); }
|
||||
private int _priority;
|
||||
|
||||
/// <summary>UC-D26 충돌 회피 후 최종 저장 파일명(OutputPath 확정 시 갱신).</summary>
|
||||
public string? FinalFileName { get => _finalFileName; set => SetField(ref _finalFileName, value); }
|
||||
private string? _finalFileName;
|
||||
|
||||
/// <summary>수신 바이트 총량(속도·ETA 계산 원천). 진행 콜백에서 누적.</summary>
|
||||
public long ReceivedBytes { get => _receivedBytes; set => SetField(ref _receivedBytes, value); }
|
||||
private long _receivedBytes;
|
||||
|
||||
/// <summary>총 바이트(길이 미상 -1). TotalSizeText 와 ETA 계산에 사용.</summary>
|
||||
public long TotalBytes { get => _totalBytes; set => SetField(ref _totalBytes, value); }
|
||||
private long _totalBytes = -1;
|
||||
|
||||
/// <summary>UC-D08 완료 시각(자동 치움 지연 판정). 미완료 null.</summary>
|
||||
public DateTime? CompletedAt { get => _completedAt; set => SetField(ref _completedAt, value); }
|
||||
private DateTime? _completedAt;
|
||||
|
||||
/// <summary>UC-D05 부속: 진행률·수신량으로 속도/ETA 텍스트를 갱신한다. speedBps ≤ 0 이면 속도만 지운다.</summary>
|
||||
public void UpdateSpeedEta(long receivedBytes, long totalBytes, double speedBps)
|
||||
{
|
||||
ReceivedBytes = receivedBytes;
|
||||
if (totalBytes > 0) TotalBytes = totalBytes;
|
||||
SpeedText = speedBps <= 0
|
||||
? ""
|
||||
: speedBps < 1024 ? $"{speedBps:F0} B/s"
|
||||
: speedBps < 1024 * 1024 ? $"{speedBps / 1024:F1} KB/s"
|
||||
: $"{speedBps / 1024 / 1024:F1} MB/s";
|
||||
if (TotalBytes > 0 && receivedBytes >= 0)
|
||||
TotalSizeText = $"{TotalBytes / 1048576.0:F1} MB";
|
||||
RemainingText = speedBps > 0 && TotalBytes > receivedBytes && receivedBytes > 0
|
||||
? FormatEta(TimeSpan.FromSeconds((TotalBytes - receivedBytes) / speedBps))
|
||||
: "";
|
||||
}
|
||||
|
||||
/// <summary>ETA 사람 표기(1시간 미만 분 단위, 이상 시 시간 단위).</summary>
|
||||
public static string FormatEta(TimeSpan eta) => eta.TotalHours >= 1
|
||||
? $"{(int)eta.TotalHours}시간 {eta.Minutes}분 남음"
|
||||
: eta.TotalMinutes >= 1
|
||||
? $"{(int)Math.Ceiling(eta.TotalMinutes)}분 남음"
|
||||
: $"{(int)Math.Ceiling(eta.TotalSeconds)}초 남음";
|
||||
|
||||
public DownloadItemViewModel(string title) { _title = title; }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
namespace VideoDownloader.Browser;
|
||||
|
||||
public record Bookmark(string Title, string Url);
|
||||
/// <summary>즐겨찾기 항목. Folder 는 UC-M16 폴더 정리용(기존 JSON 과 호환되는 선택 파라미터).</summary>
|
||||
public record Bookmark(string Title, string Url, string? Folder = null);
|
||||
|
||||
public record HistoryEntry(string Title, string Url, DateTime VisitedAt);
|
||||
|
|
|
|||
|
|
@ -60,4 +60,13 @@ public sealed class NotesStore
|
|||
try { File.WriteAllText(_path, JsonSerializer.Serialize(_items)); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>UC-K19 명시적 저장(Ctrl+S) — 현재 목록을 파일에 다시 기록한다.</summary>
|
||||
public void Save()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
try { File.WriteAllText(_path, JsonSerializer.Serialize(_items)); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,4 +60,27 @@ public sealed class TabArchive
|
|||
try { File.WriteAllText(_path, JsonSerializer.Serialize(_items)); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>UC-B15 아카이브 전체 비우기.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_items.Clear();
|
||||
try { File.WriteAllText(_path, JsonSerializer.Serialize(_items)); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>UC-B16 아카이브 항목 복구 — 꺼내 반환하고 목록에서 제거한다. 없으면 null.</summary>
|
||||
public ArchivedTab? Restore(string url)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var hit = _items.FirstOrDefault(t => t.Url.Equals(url, StringComparison.OrdinalIgnoreCase));
|
||||
if (hit == null) return null;
|
||||
_items.Remove(hit);
|
||||
try { File.WriteAllText(_path, JsonSerializer.Serialize(_items)); } catch { }
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,18 @@ public sealed class TabItem : INotifyPropertyChanged
|
|||
private string? _groupColor;
|
||||
public string? GroupColor { get => _groupColor; set => Set(ref _groupColor, value); }
|
||||
|
||||
/// <summary>UC-B01 탭 잠금 — 실수로 닫는 것 방지(닫기 무시, 컨텍스트 메뉴로 해제).</summary>
|
||||
private bool _isLocked;
|
||||
public bool IsLocked { get => _isLocked; set => Set(ref _isLocked, value); }
|
||||
|
||||
/// <summary>UC-B02 사용자 지정 탭 색(#RRGGBB) — 탭 좌측 표시줄에 표시.</summary>
|
||||
private string? _customColor;
|
||||
public string? CustomColor { get => _customColor; set => Set(ref _customColor, value); }
|
||||
|
||||
/// <summary>UC-B03 그룹 접음 — 같은 그룹 탭을 스트립에서 접어 표시.</summary>
|
||||
private bool _isGroupCollapsed;
|
||||
public bool IsGroupCollapsed { get => _isGroupCollapsed; set => Set(ref _isGroupCollapsed, value); }
|
||||
|
||||
/// <summary>마지막 활성 시각(탭 절전 판정용)</summary>
|
||||
public DateTime LastActivated { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ public sealed class TabManager
|
|||
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;
|
||||
|
|
@ -32,6 +35,13 @@ public sealed class TabManager
|
|||
|
||||
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);
|
||||
|
|
@ -51,9 +61,11 @@ public sealed class TabManager
|
|||
ActiveChanged?.Invoke(tab);
|
||||
}
|
||||
|
||||
/// <summary>탭 닫기. 활성 탭이면 오른쪽 이웃(없으면 왼쪽) 활성화. 마지막 탭이면 새 빈 탭으로 대체.</summary>
|
||||
/// <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;
|
||||
|
||||
|
|
@ -181,13 +193,38 @@ public static class TabManagerGroupExtensions
|
|||
if (lastIdx >= 0) m.Move(tab, lastIdx + 1);
|
||||
}
|
||||
|
||||
/// <summary>그룹 전체 닫기. 반환: 닫은 탭 수.</summary>
|
||||
/// <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)).ToList();
|
||||
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>
|
||||
|
|
|
|||
25
VideoDownloader.Browser/Tabs/TabPreview.cs
Normal file
25
VideoDownloader.Browser/Tabs/TabPreview.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -77,4 +77,177 @@ public class AppConfig
|
|||
/// <summary>M0 원격 접근(모바일 연동) 서버 켜기. 토큰은 자동 생성/저장.</summary>
|
||||
public bool RemoteAccessEnabled { get; set; } = false;
|
||||
public string? RemoteAccessToken { get; set; }
|
||||
|
||||
/// <summary>업데이트 확인 대상 GitHub 저장소("owner/repo") — DEPLOYMENT_PLAN §6 v1. 비면 확인 안 함.</summary>
|
||||
public string UpdateRepo { get; set; } = "";
|
||||
|
||||
// ==================== UI 복잡 버전 (docs/UI_USECASES.md) ====================
|
||||
|
||||
// ---- §A 테마 ----
|
||||
/// <summary>사용자 지정 강조색(#RRGGBB). null/빈이면 테마 기본 보라.</summary>
|
||||
public string? AccentColor { get; set; }
|
||||
|
||||
/// <summary>사용자 테마 파일(.xaml 리소스사전) 경로. 설정에 있으면 우선 적용.</summary>
|
||||
public string? CustomThemePath { get; set; }
|
||||
|
||||
// ---- §B 타이포그래피 ----
|
||||
/// <summary>UI 기본 폰트(빈 문자열이면 기본 스택 Segoe UI+맑은고딕).</summary>
|
||||
public string UiFontFamily { get; set; } = "";
|
||||
|
||||
/// <summary>UI 기본 폰트 크기(pt 단위가 아닌 WPF Device Independent px).</summary>
|
||||
public double UiFontSize { get; set; } = 13;
|
||||
|
||||
/// <summary>한국어 폴백 폰트(기본 '맑은 고딕').</summary>
|
||||
public string UiFontKoreanFallback { get; set; } = "Malgun Gothic";
|
||||
|
||||
// ---- §C 레이아웃·밀도·창 상태 ----
|
||||
/// <summary>정보 밀도: compact / normal / comfortable.</summary>
|
||||
public string UiDensity { get; set; } = "normal";
|
||||
|
||||
/// <summary>T3-13 컴팩트 모드(크롬 숨김) 시작 시 복원.</summary>
|
||||
public bool CompactMode { get; set; } = false;
|
||||
|
||||
/// <summary>세로 탭을 기본 레이아웃으로 시작.</summary>
|
||||
public bool VerticalTabsDefault { get; set; } = false;
|
||||
|
||||
/// <summary>분할 화면(스플릿) 패널 비율(0~1). 재시작 후 재현.</summary>
|
||||
public double SplitViewRatio { get; set; } = 0.5;
|
||||
|
||||
/// <summary>창 위치·크기 "Left,Top,Width,Height". 화면 밤새 좌표는 무시됨.</summary>
|
||||
public string? WindowBounds { get; set; }
|
||||
|
||||
/// <summary>종료 시 최대화 상태였는지.</summary>
|
||||
public bool WindowMaximized { get; set; } = false;
|
||||
|
||||
/// <summary>사용 중인 상태바는 자동으로 숨김.</summary>
|
||||
public bool StatusBarAutoHide { get; set; } = false;
|
||||
|
||||
/// <summary>툴바에 남길 버튼 키 목록(빈 목록=전부 표시). 예: back,forward,bookmarks,history,downloads</summary>
|
||||
public List<string> ToolbarButtons { get; set; } = new();
|
||||
|
||||
/// <summary>북마크바 표시 여부(세션 간 유지).</summary>
|
||||
public bool BookmarkBarVisible { get; set; } = false;
|
||||
|
||||
/// <summary>탭 스트립 위치: top / bottom / left.</summary>
|
||||
public string TabStripPosition { get; set; } = "top";
|
||||
|
||||
/// <summary>우측 패널 최소 폭(px).</summary>
|
||||
public double RightPanelMinWidth { get; set; } = 280;
|
||||
|
||||
/// <summary>움직임 민감 사용자용 애니메이션 축소.</summary>
|
||||
public bool ReduceMotion { get; set; } = false;
|
||||
|
||||
/// <summary>컴팩트 모드 엣지 호버 반응 지연(ms).</summary>
|
||||
public int EdgeRevealDelayMs { get; set; } = 200;
|
||||
|
||||
/// <summary>UI 전체 배율(레이아웃 루트 ScaleTransform, 0.5~2.0).</summary>
|
||||
public double UiScale { get; set; } = 1.0;
|
||||
|
||||
/// <summary>종료 시 전체화면이었는지.</summary>
|
||||
public bool FullScreen { get; set; } = false;
|
||||
|
||||
// ---- §D 탭 ----
|
||||
/// <summary>여러 탭을 한 번에 닫을 때 확인 받기.</summary>
|
||||
public bool ConfirmCloseMultipleTabs { get; set; } = true;
|
||||
|
||||
/// <summary>탭 스트립에서 휠로 탭 전환.</summary>
|
||||
public bool TabWheelSwitch { get; set; } = true;
|
||||
|
||||
/// <summary>탭 최대 개수(0=제한 없음).</summary>
|
||||
public int MaxTabs { get; set; } = 0;
|
||||
|
||||
/// <summary>세션 복원 시 탭을 잠자기 상태로만 복원(빠른 시작).</summary>
|
||||
public bool RestoreTabsSuspended { get; set; } = false;
|
||||
|
||||
/// <summary>작업공간 표시 이름(인덱스 순).</summary>
|
||||
public List<string> WorkspaceNames { get; set; } = new() { "작업", "개인", "학습" };
|
||||
|
||||
/// <summary>탭 제목 두 줄 표시.</summary>
|
||||
public bool TabTitleMultiline { get; set; } = false;
|
||||
|
||||
// ---- §E 다운로드 ----
|
||||
/// <summary>다운로드 목록 정렬: newest / largest / progress / title.</summary>
|
||||
public string DownloadSortOrder { get; set; } = "newest";
|
||||
|
||||
/// <summary>완료 항목을 n초 후 목록에서 자동 제거(0=자동 치움 안 함).</summary>
|
||||
public bool AutoClearCompleted { get; set; } = false;
|
||||
|
||||
/// <summary>다운로드 완료 알림 소리.</summary>
|
||||
public bool NotifySound { get; set; } = true;
|
||||
|
||||
/// <summary>다운로드 완료 시 저장 폴더 자동 열기.</summary>
|
||||
public bool OpenFolderOnComplete { get; set; } = false;
|
||||
|
||||
/// <summary>자막 우선 언어(ko/en/…, 빈 문자열=자막 안 받음).</summary>
|
||||
public string SubtitleLanguage { get; set; } = "";
|
||||
|
||||
/// <summary>UC-D21 다운로드 예약 창("HH:mm-HH:mm", 빈 값=항상 허용).</summary>
|
||||
public string? DownloadScheduleWindow { get; set; }
|
||||
|
||||
/// <summary>다운로드 속도 상한(kbps, 0=제한 없음).</summary>
|
||||
public int MaxDownloadSpeedKbps { get; set; } = 0;
|
||||
|
||||
// ---- §F 설정창 ----
|
||||
/// <summary>설정창 위치·크기 "Left,Top,Width,Height".</summary>
|
||||
public string? SettingsWindowBounds { get; set; }
|
||||
|
||||
/// <summary>UI 언어: ko / en.</summary>
|
||||
public string Language { get; set; } = "ko";
|
||||
|
||||
/// <summary>닫기 버튼으로 트레이로 최소화.</summary>
|
||||
public bool MinimizeToTray { get; set; } = false;
|
||||
|
||||
/// <summary>Windows 시작 시 자동 실행.</summary>
|
||||
public bool AutoStart { get; set; } = false;
|
||||
|
||||
/// <summary>재시도 횟수(UI 노출용 — Retries 와 동일 값을 설정창에서 편집).</summary>
|
||||
public int RetriesUi { get => Retries; set => Retries = value; }
|
||||
|
||||
/// <summary>HTTP 프록시 서버(http://host:port, 빈 문자열=직접 연결).</summary>
|
||||
public string ProxyServer { get; set; } = "";
|
||||
|
||||
// ---- §G 접근성 ----
|
||||
/// <summary>저시력용 폰트 확대 배율(1.0~2.0). UiFontSize 와 곱해 적용.</summary>
|
||||
public double UiFontScale { get; set; } = 1.0;
|
||||
|
||||
/// <summary>영상 자막 표시(플레이어에 자막 렌더 시도).</summary>
|
||||
public bool ShowCaptions { get; set; } = true;
|
||||
|
||||
// ---- §H 인터랙션 ----
|
||||
/// <summary>명령 키 → 조합 문자열(예: "newtab": "Ctrl+T"). 없으면 기본값.</summary>
|
||||
public Dictionary<string, string> Shortcuts { get; set; } = new();
|
||||
|
||||
/// <summary>주소창에 붙여넣으면 즉시 이동.</summary>
|
||||
public bool PasteAndGo { get; set; } = true;
|
||||
|
||||
/// <summary>주소창 포커스 시 전체 선택.</summary>
|
||||
public bool FocusSelectsAddress { get; set; } = true;
|
||||
|
||||
/// <summary>새 탭 기본 줌 배율(0.1~5.0).</summary>
|
||||
public double DefaultZoomFactor { get; set; } = 1.0;
|
||||
|
||||
// ---- §I 라이브러리·재생목록 ----
|
||||
/// <summary>재생목록 반복 모드: none / one / all.</summary>
|
||||
public string PlaylistLoopMode { get; set; } = "none";
|
||||
|
||||
// ---- §J 상태·알림 ----
|
||||
/// <summary>토스트 표시 시간(ms).</summary>
|
||||
public int ToastDurationMs { get; set; } = 4000;
|
||||
|
||||
/// <summary>절전 모드(전원 절약) 진입 시 다운로드 일시정지.</summary>
|
||||
public bool PauseOnPowerSaver { get; set; } = true;
|
||||
|
||||
/// <summary>방해 금지(알림 음소거·토스트 억제).</summary>
|
||||
public bool DoNotDisturb { get; set; } = false;
|
||||
|
||||
/// <summary>UC-S04 모든 설정을 새 인스턴스 기본값으로 되돌린다(원격 접속 토큰은 유지 — 기기 페어링 유지).</summary>
|
||||
public void ResetToDefaults()
|
||||
{
|
||||
var fresh = new AppConfig();
|
||||
foreach (var prop in typeof(AppConfig).GetProperties().Where(p => p.CanRead && p.CanWrite))
|
||||
{
|
||||
if (prop.Name == nameof(RemoteAccessToken)) continue;
|
||||
try { prop.SetValue(this, prop.GetValue(fresh)); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ public class DirectDownloader
|
|||
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));
|
||||
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, detail,
|
||||
ReceivedBytes: written, TotalBytes: total));
|
||||
}
|
||||
await fs.FlushAsync(ct);
|
||||
return Finalize(part, outputBase, ext);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
namespace VideoDownloader.Core.Models;
|
||||
|
||||
/// <summary>다운로드 진행 상태. Stage: downloading / merging / remuxing / done</summary>
|
||||
public record DownloadProgress(int Completed, int Total, string Stage, double OverridePercent = -1, string? Detail = null)
|
||||
public record DownloadProgress(int Completed, int Total, string Stage, double OverridePercent = -1, string? Detail = null,
|
||||
long ReceivedBytes = -1, long TotalBytes = -1)
|
||||
{
|
||||
// HLS: Completed/Total 로 계산 / 직링크: OverridePercent 사용
|
||||
public double Percent => OverridePercent >= 0 ? OverridePercent : (Total > 0 ? Completed * 100.0 / Total : 0);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ public class YtDlpDownloader
|
|||
void ReportStatus(string msg) => progress?.Report(new DownloadProgress(0, 0, "downloading", -1, msg));
|
||||
await DenoRunner.EnsureInstalledAsync(_config, ReportStatus, ct);
|
||||
|
||||
var args = BuildArgs(pageUrl, outputBase, fmt, cookiesPath, ffmpegDir, _config.YoutubePlayerClients);
|
||||
var args = BuildArgs(pageUrl, outputBase, fmt, cookiesPath, ffmpegDir, _config.YoutubePlayerClients, _config.SubtitleLanguage);
|
||||
|
||||
// yt-dlp 실행 + 진행률 파싱. 갱신 후 재시도 시 동일 콜백을 재사용하므로 로컬 함수로 캡슐화.
|
||||
// (remuxing 은 실행마다 리셋 — 갱신 후 재시도에선 병합 단계가 처음부터 다시 시작될 수 있음.)
|
||||
|
|
@ -125,7 +125,7 @@ public class YtDlpDownloader
|
|||
}
|
||||
|
||||
private static List<string> BuildArgs(Uri pageUrl, string outputBase, FormatOption fmt,
|
||||
string? cookiesPath, string? ffmpegDir, string? playerClients)
|
||||
string? cookiesPath, string? ffmpegDir, string? playerClients, string? subtitleLang = null)
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
|
|
@ -152,6 +152,9 @@ public class YtDlpDownloader
|
|||
// YouTube n 챌린지(플레이어 서명 스로틀 파라미터 난독화) 해석 활성화. 없으면 포맷이 통째로 드랍되어
|
||||
// "Only images are available" 로 MP4 다운로드 실패하거나, 살아남아도 수백 KiB/s 로 심하게 스로틀됨.
|
||||
AddRemoteComponentsArgs(args);
|
||||
// UC-D20 자막 함께 받기 — 지정 언어가 있으면 다운로드+임베드
|
||||
if (!string.IsNullOrWhiteSpace(subtitleLang))
|
||||
args.AddRange(new[] { "--write-subs", "--sub-langs", subtitleLang, "--embed-subs" });
|
||||
args.AddRange(new[] { "-o", outputBase + ".%(ext)s" });
|
||||
args.Add(pageUrl.ToString());
|
||||
return args;
|
||||
|
|
|
|||
84
VideoDownloader.Core/Update/UpdateChecker.cs
Normal file
84
VideoDownloader.Core/Update/UpdateChecker.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace VideoDownloader.Core.Update;
|
||||
|
||||
/// <summary>GitHub 릴리스의 설치 자산 하나.</summary>
|
||||
public sealed record UpdateAsset(string Name, string DownloadUrl);
|
||||
|
||||
/// <summary>업데이트 확인 결과. LatestVersion 은 태그의 v 접두를 뗀 정규형.</summary>
|
||||
public sealed record UpdateCheckResult(bool IsNewer, string CurrentVersion, string LatestVersion, string ReleaseUrl, UpdateAsset? Asset);
|
||||
|
||||
/// <summary>
|
||||
/// DEPLOYMENT_PLAN §6 자동 업데이트 v1 — GitHub Releases latest 태그 폴링(무의존·전 OS 공통).
|
||||
/// 설치를 자동 적용하지 않고 릴리스/자산 URL 만 돌려준다(설치는 사람 관문).
|
||||
/// </summary>
|
||||
public sealed class UpdateChecker
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public UpdateChecker(HttpMessageHandler? handler = null)
|
||||
{
|
||||
_http = handler == null ? new HttpClient() : new HttpClient(handler);
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("VideoDownloader-UpdateCheck"); // GitHub API 는 UA 필수
|
||||
_http.Timeout = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
/// <summary>"v1.2.3"/"1.2.3.0" 을 관대하게 비교. 어느 쪽이든 파싱 불가면 false(갱신 아님).</summary>
|
||||
public static bool IsNewer(string current, string latest) =>
|
||||
TryParse(current, out var cur) && TryParse(latest, out var lat) && lat > cur;
|
||||
|
||||
private static bool TryParse(string s, out Version version)
|
||||
{
|
||||
s = s.Trim().TrimStart('v', 'V');
|
||||
if (!s.Contains('.')) s += ".0"; // Version.TryParse 는 최소 2파트 요구
|
||||
return Version.TryParse(s, out version!);
|
||||
}
|
||||
|
||||
/// <summary>RID(win-x64 등)에 맞는 설치 자산 선택. "x64" 판정에서 arm64 를 먼저 배제한다.</summary>
|
||||
public static UpdateAsset? SelectAsset(IEnumerable<UpdateAsset> assets, string rid)
|
||||
{
|
||||
var parts = rid.Split('-');
|
||||
var os = parts[0];
|
||||
var arch = parts[^1];
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
var name = asset.Name.ToLowerInvariant();
|
||||
var isArm = name.Contains("arm64");
|
||||
if (arch == "arm64" ? !isArm : isArm || !name.Contains(arch)) continue;
|
||||
var osMatch = os switch
|
||||
{
|
||||
"win" => name.EndsWith(".exe") || name.EndsWith(".msi"),
|
||||
"linux" => name.EndsWith(".tar.gz") || name.EndsWith(".appimage") || name.EndsWith(".deb"),
|
||||
"osx" => name.EndsWith(".dmg"),
|
||||
_ => false,
|
||||
};
|
||||
if (osMatch) return asset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>repo("owner/repo")의 latest 릴리스를 조회한다. API 실패는 HttpRequestException 으로 드러난다.</summary>
|
||||
public async Task<UpdateCheckResult> CheckAsync(string repo, string currentVersion, string rid, CancellationToken ct = default)
|
||||
{
|
||||
using var res = await _http.GetAsync($"https://api.github.com/repos/{repo}/releases/latest", ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
|
||||
var root = doc.RootElement;
|
||||
|
||||
var tag = root.GetProperty("tag_name").GetString() ?? "";
|
||||
var releaseUrl = root.TryGetProperty("html_url", out var h) ? h.GetString() ?? "" : "";
|
||||
var assets = new List<UpdateAsset>();
|
||||
if (root.TryGetProperty("assets", out var arr) && arr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var a in arr.EnumerateArray())
|
||||
{
|
||||
assets.Add(new UpdateAsset(
|
||||
a.GetProperty("name").GetString() ?? "",
|
||||
a.GetProperty("browser_download_url").GetString() ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
return new UpdateCheckResult(
|
||||
IsNewer(currentVersion, tag), currentVersion, tag.TrimStart('v', 'V'), releaseUrl, SelectAsset(assets, rid));
|
||||
}
|
||||
}
|
||||
58
VideoDownloader.Server/MdnsAdvertiser.cs
Normal file
58
VideoDownloader.Server/MdnsAdvertiser.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
using Makaretu.Dns;
|
||||
|
||||
namespace VideoDownloader.Server;
|
||||
|
||||
/// <summary>
|
||||
/// mDNS 서비스 광고(<c>_videodl._tcp</c>) — MOBILE_PLAN §3 아키텍처.
|
||||
/// QR 페어링이 주 경로이고 광고는 발견 보조이므로, 멀티캐스트가 막힌 네트워크에서의
|
||||
/// 실패는 서버 기동을 막지 않는다(ServerHost 가 MdnsError 로 드러냄).
|
||||
/// </summary>
|
||||
public sealed class MdnsAdvertiser : IDisposable
|
||||
{
|
||||
public const string ServiceType = "_videodl._tcp";
|
||||
|
||||
private readonly ServiceProfile _profile;
|
||||
private ServiceDiscovery? _sd;
|
||||
|
||||
public MdnsAdvertiser(string deviceName, int port) => _profile = BuildProfile(deviceName, (ushort)port);
|
||||
|
||||
/// <summary>
|
||||
/// 광고 프로필 구성(순수 로직). 점은 DNS 라벨 구분자와 충돌하므로 대시로 정리.
|
||||
/// Makaretu WireWriter 가 TXT/라벨에 ASCII 만 허용 → 기기명은 퍼센트 인코딩으로 무손실 보존,
|
||||
/// 인스턴스 라벨은 ASCII 로 접는다(클라이언트는 TXT device 를 디코드해 표시).
|
||||
/// </summary>
|
||||
public static ServiceProfile BuildProfile(string deviceName, ushort port)
|
||||
{
|
||||
var display = string.IsNullOrWhiteSpace(deviceName) ? "VideoDownloader" : deviceName.Trim();
|
||||
var profile = new ServiceProfile(ToAsciiLabel(display), ServiceType, port);
|
||||
profile.AddProperty("device", Uri.EscapeDataString(display));
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static string ToAsciiLabel(string s)
|
||||
{
|
||||
var label = new string(s.Replace('.', '-').Where(c => c >= ' ' && c < (char)0x7f).ToArray()).Trim();
|
||||
return label.Length == 0 ? "VideoDownloader" : label;
|
||||
}
|
||||
|
||||
/// <summary>광고 시작. 멀티캐스트 소켓 실패는 그대로 던진다(호출측이 보조 기능으로 취급).</summary>
|
||||
public void Start()
|
||||
{
|
||||
_sd ??= new ServiceDiscovery();
|
||||
_sd.Advertise(_profile);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_sd == null) return;
|
||||
try
|
||||
{
|
||||
_sd.Unadvertise(); // goodbye 패킷은 규격상 best-effort — 네트워크가 이미 죽었으면 보낼 수 없다
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Net.Sockets.SocketException or ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
_sd.Dispose();
|
||||
_sd = null;
|
||||
}
|
||||
}
|
||||
17
VideoDownloader.Server/QueueHub.cs
Normal file
17
VideoDownloader.Server/QueueHub.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace VideoDownloader.Server;
|
||||
|
||||
/// <summary>
|
||||
/// 실시간 큐 허브(MOBILE_PLAN §3 <c>/hubs/queue</c>).
|
||||
/// 클라이언트는 접속 직후 GetQueue 로 스냅샷을 받고, 이후 서버발 queueUpdated 푸시로 갱신한다
|
||||
/// (2초 폴링의 실시간 대체 — 폴링 클라이언트와 공존).
|
||||
/// </summary>
|
||||
public sealed class QueueHub : Hub
|
||||
{
|
||||
private readonly IQueueAdapter _queue;
|
||||
|
||||
public QueueHub(IQueueAdapter queue) => _queue = queue;
|
||||
|
||||
public Task<IReadOnlyList<QueueItemDto>> GetQueue() => _queue.GetItemsAsync();
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ 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;
|
||||
|
|
@ -18,17 +19,27 @@ 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://<lan-ip>:<port>/#t=<token></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)
|
||||
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)
|
||||
|
|
@ -40,19 +51,25 @@ public sealed class ServerHost : IAsyncDisposable
|
|||
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("/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;
|
||||
|
|
@ -146,6 +163,12 @@ public sealed class ServerHost : IAsyncDisposable
|
|||
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"));
|
||||
|
||||
|
|
@ -157,11 +180,46 @@ public sealed class ServerHost : IAsyncDisposable
|
|||
.Select(m2 => int.Parse(m2.Groups[1].Value)).FirstOrDefault();
|
||||
actualPort = m;
|
||||
}
|
||||
return new ServerHost(app, actualPort, token, options);
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ public sealed class ServerOptions
|
|||
public string DeviceName { get; init; } = Environment.MachineName;
|
||||
/// <summary>썸네일/해상도 생성기(기본: DesktopProcessRunner 기반 ffmpeg). 테스트 주입 가능.</summary>
|
||||
public Thumbnailer? Thumbnailer { get; init; }
|
||||
/// <summary>mDNS 광고(_videodl._tcp) 켜기 — MOBILE_PLAN §3. 실패해도 서버는 뜬다(MdnsError 로 노출).</summary>
|
||||
public bool EnableMdns { get; init; }
|
||||
/// <summary>ffmpeg 경로 오버라이드(기본: 설정→PATH 탐색). 호스트 ffmpeg 없는 환경 테스트용.</summary>
|
||||
public string? FfmpegPath { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Makaretu.Dns.Multicast" Version="0.27.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
|
||||
|
|
|
|||
388
VideoDownloader.Tests/Browser/UiUsecaseHardeningTests.cs
Normal file
388
VideoDownloader.Tests/Browser/UiUsecaseHardeningTests.cs
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
using VideoDownloader.App;
|
||||
using VideoDownloader.App.Browser.Features;
|
||||
using VideoDownloader.App.Services;
|
||||
using VideoDownloader.App.ViewModels;
|
||||
using VideoDownloader.Browser;
|
||||
using VideoDownloader.Browser.Tabs;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// UI 유즈케이스 GREEN 구현 보강 테스트 — docs/UI_USECASES.md 계약(존재 확인)이 놓친
|
||||
/// "실제 동작" 을 검증한다. RED 스펙 234건이 요구하지 않은 각진 부분:
|
||||
/// 탭 잠금/상한, 아카이브 복원·비움, 다운로드 예약 창 계산, 단축키 충돌 검출,
|
||||
/// 카드 VM 속도/ETA 계산, 페이지 다운로드 전체 일시정지, 북마크 HTML 파싱,
|
||||
/// 테마 사전 키 동일성(런타임 MissingKey 방지).
|
||||
/// </summary>
|
||||
public class UiUsecaseHardeningTests
|
||||
{
|
||||
// ---------- §D 탭: 잠금·상한·그룹 ----------
|
||||
|
||||
[Fact]
|
||||
public void 잠긴_탭은_CloseTab_거부하고_해제후_닫힌다()
|
||||
{
|
||||
var tabs = new TabManager(() => new TabItem());
|
||||
var t = tabs.NewTab("https://a");
|
||||
t.IsLocked = true;
|
||||
|
||||
Assert.False(tabs.CloseTab(t)); // UC-B01: 잠금이 닫기 거부
|
||||
Assert.Single(tabs.Tabs);
|
||||
|
||||
t.IsLocked = false;
|
||||
Assert.True(tabs.CloseTab(t));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxTabs_도달시_새_탭_대신_마지막_탭_활성화()
|
||||
{
|
||||
var tabs = new TabManager(() => new TabItem()) { MaxTabs = 2 };
|
||||
var t1 = tabs.NewTab("https://1");
|
||||
var t2 = tabs.NewTab("https://2");
|
||||
var t3 = tabs.NewTab("https://3"); // 상한 도달 → 새 탭 추가 없음
|
||||
|
||||
Assert.Equal(2, tabs.Tabs.Count);
|
||||
Assert.Same(t2, t3); // 마지막 탭 반환(활성화)
|
||||
Assert.Same(t2, tabs.ActiveTab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenameGroup_같은_그룹_전체_이름_변경()
|
||||
{
|
||||
var tabs = new TabManager(() => new TabItem());
|
||||
var a = tabs.NewTab("https://a");
|
||||
var b = tabs.NewTab("https://b");
|
||||
tabs.SetGroup(a, "G1", "#fff");
|
||||
tabs.SetGroup(b, "G1", "#fff");
|
||||
|
||||
Assert.True(tabs.RenameGroup("G1", "작업"));
|
||||
Assert.Equal("작업", a.GroupName);
|
||||
Assert.Equal("작업", b.GroupName);
|
||||
Assert.False(tabs.RenameGroup("없는그룹", "x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleGroupCollapsed_그룹_전체_접힘_토글()
|
||||
{
|
||||
var tabs = new TabManager(() => new TabItem());
|
||||
var a = tabs.NewTab("https://a");
|
||||
var b = tabs.NewTab("https://b");
|
||||
tabs.SetGroup(a, "G1", "#fff");
|
||||
tabs.SetGroup(b, "G1", "#fff");
|
||||
b.IsLocked = true; // CloseGroup 대상 아님(접기는 잠금과 무관)
|
||||
|
||||
Assert.Equal(2, tabs.ToggleGroupCollapsed("G1"));
|
||||
Assert.True(a.IsGroupCollapsed);
|
||||
Assert.True(b.IsGroupCollapsed);
|
||||
}
|
||||
|
||||
// ---------- §D 탭: 아카이브 ----------
|
||||
|
||||
[Fact]
|
||||
public void TabArchive_Clear_후_Search_빈값_Restore_null()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"vd_arch_{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var arch = new TabArchive(path);
|
||||
arch.Add("제목", "https://example.com");
|
||||
arch.Clear();
|
||||
Assert.Empty(arch.Search(""));
|
||||
Assert.Null(arch.Restore("https://example.com"));
|
||||
}
|
||||
finally { try { File.Delete(path); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TabArchive_Restore_꺼내고_파일에도_반영()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"vd_arch_{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var arch = new TabArchive(path);
|
||||
arch.Add("제목A", "https://a.example.com");
|
||||
|
||||
var got = arch.Restore("https://a.example.com");
|
||||
Assert.NotNull(got);
|
||||
Assert.Equal("제목A", got!.Title);
|
||||
|
||||
// 꺼낸 항목은 목록에서 제거(다시 복구 불가)
|
||||
Assert.Null(arch.Restore("https://a.example.com"));
|
||||
Assert.Empty(arch.Search(""));
|
||||
|
||||
// 새 인스턴스(재시작)로도 제거 상태 유지
|
||||
Assert.Empty(new TabArchive(path).Search(""));
|
||||
}
|
||||
finally { try { File.Delete(path); } catch { } }
|
||||
}
|
||||
|
||||
// ---------- §E 다운로드: 예약·VM 계산·페이지 일시정지 ----------
|
||||
|
||||
[Theory]
|
||||
[InlineData("02:00-08:00", "2026-01-01T03:00:00", true)]
|
||||
[InlineData("02:00-08:00", "2026-01-01T12:00:00", false)]
|
||||
[InlineData("22:00-06:00", "2026-01-01T23:30:00", true)] // 자정 가로지름
|
||||
[InlineData("22:00-06:00", "2026-01-01T03:00:00", true)]
|
||||
[InlineData("22:00-06:00", "2026-01-01T12:00:00", false)]
|
||||
public void DownloadScheduler_시간대_판정(string window, string nowIso, bool allowed)
|
||||
{
|
||||
var sched = new DownloadScheduler(window);
|
||||
Assert.True(sched.Enabled);
|
||||
Assert.Equal(allowed, sched.CanRunNow(DateTime.Parse(nowIso)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadScheduler_빈창_항상허용_형식오류_게이트열림()
|
||||
{
|
||||
Assert.True(new DownloadScheduler(null).CanRunNow(DateTime.Now));
|
||||
Assert.False(new DownloadScheduler(null).Enabled);
|
||||
// 형식 오류 창은 닫힌 게이트로 막지 않는다(파싱 실패 = 항상 허용)
|
||||
Assert.True(new DownloadScheduler("bad-format").CanRunNow(DateTime.Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadScheduler_TryParse_형식검증()
|
||||
{
|
||||
Assert.True(DownloadScheduler.TryParse("09:00-18:00", out _, out _));
|
||||
Assert.False(DownloadScheduler.TryParse("9-18", out _, out _));
|
||||
Assert.False(DownloadScheduler.TryParse("25:00-26:00", out _, out _));
|
||||
Assert.False(DownloadScheduler.TryParse("", out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadScheduler_다음창까지_남은시간_양수()
|
||||
{
|
||||
var sched = new DownloadScheduler("02:00-08:00");
|
||||
var now = new DateTime(2026, 1, 1, 12, 0, 0);
|
||||
var remain = sched.TimeUntilNextWindow(now);
|
||||
Assert.True(remain > TimeSpan.Zero);
|
||||
// 다음날 02:00 이어야 한다(14시간)
|
||||
Assert.Equal(TimeSpan.FromHours(14), remain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadItemViewModel_속도와_ETA_계산()
|
||||
{
|
||||
var vm = new DownloadItemViewModel("테스트");
|
||||
vm.UpdateSpeedEta(receivedBytes: 50 * 1024 * 1024, totalBytes: 100 * 1024 * 1024, speedBps: 10 * 1024 * 1024);
|
||||
Assert.Equal("10.0 MB/s", vm.SpeedText);
|
||||
Assert.Equal("100.0 MB", vm.TotalSizeText);
|
||||
Assert.Equal("5초 남음", vm.RemainingText);
|
||||
Assert.Equal(50 * 1024 * 1024L, vm.ReceivedBytes);
|
||||
|
||||
vm.UpdateSpeedEta(0, -1, 0); // 정지 → 표시 지움
|
||||
Assert.Equal("", vm.SpeedText);
|
||||
Assert.Equal("", vm.RemainingText);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.5, "1초 남음")]
|
||||
[InlineData(90, "2분 남음")]
|
||||
[InlineData(3700, "1시간 1분 남음")]
|
||||
public void DownloadItemViewModel_FormatEta_단위(double seconds, string expected)
|
||||
=> Assert.Equal(expected, DownloadItemViewModel.FormatEta(TimeSpan.FromSeconds(seconds)));
|
||||
|
||||
[Fact]
|
||||
public void DownloadItemViewModel_재시도_횟수와_우선순위_완료시각()
|
||||
{
|
||||
var vm = new DownloadItemViewModel("테스트");
|
||||
Assert.Equal(0, vm.RetryCount);
|
||||
Assert.Equal(0, vm.Priority);
|
||||
vm.RetryCount = 3;
|
||||
vm.Priority = 5;
|
||||
vm.FinalFileName = "video.mp4";
|
||||
vm.CompletedAt = DateTime.UtcNow;
|
||||
Assert.Equal(3, vm.RetryCount);
|
||||
Assert.Equal(5, vm.Priority);
|
||||
Assert.Equal("video.mp4", vm.FinalFileName);
|
||||
Assert.NotNull(vm.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadItemViewModel_SegmentMap_완료_세그먼트_채움()
|
||||
{
|
||||
var vm = new DownloadItemViewModel("HLS");
|
||||
Assert.Empty(vm.SegmentMap);
|
||||
vm.SegmentMap.Add(1.0);
|
||||
vm.SegmentMap.Add(0.0);
|
||||
vm.SegmentMap.Add(0.5);
|
||||
Assert.Equal(new[] { 1.0, 0.0, 0.5 }, vm.SegmentMap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageDownloadTracker_PauseAll_ResumeAll_진행중만_제어()
|
||||
{
|
||||
var tracker = new PageDownloadTracker();
|
||||
var runningOp = new FakeOp { State = "InProgress" };
|
||||
var doneOp = new FakeOp { State = "Completed" };
|
||||
tracker.Add(runningOp, "a.bin");
|
||||
tracker.Add(doneOp, "b.bin");
|
||||
|
||||
tracker.PauseAll();
|
||||
Assert.Equal(1, runningOp.PauseCount);
|
||||
Assert.Equal(0, doneOp.PauseCount);
|
||||
|
||||
tracker.ResumeAll();
|
||||
Assert.Equal(1, runningOp.ResumeCount);
|
||||
Assert.Equal(0, doneOp.ResumeCount); // 완료 항목은 재개 대상 아님
|
||||
}
|
||||
|
||||
private sealed class FakeOp : IDownloadOperation
|
||||
{
|
||||
public string Uri => "https://example.com/f";
|
||||
public string FileName => "f";
|
||||
public long BytesReceived => 10;
|
||||
public long TotalBytesToReceive => 100;
|
||||
public string State { get; set; } = "InProgress";
|
||||
public int PauseCount, ResumeCount, CancelCount;
|
||||
#pragma warning disable CS0067 // 인터페이스 계약상 선언만 필요(테스트에서 발화 안 함)
|
||||
public event Action? Changed;
|
||||
#pragma warning restore CS0067
|
||||
public void Pause() => PauseCount++;
|
||||
public void Resume() => ResumeCount++;
|
||||
public void Cancel() => CancelCount++;
|
||||
}
|
||||
|
||||
// ---------- §H 단축키 ----------
|
||||
|
||||
[Fact]
|
||||
public void ShortcutManager_기본조합과_사용자오버라이드()
|
||||
{
|
||||
var sm = new ShortcutManager();
|
||||
Assert.Equal("Ctrl+T", sm.GestureFor("newtab"));
|
||||
|
||||
var custom = new ShortcutManager(new Dictionary<string, string> { ["newtab"] = "Ctrl+Shift+Y" });
|
||||
Assert.Equal("Ctrl+Shift+Y", custom.GestureFor("newtab"));
|
||||
Assert.Equal("Ctrl+W", custom.GestureFor("closetab"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortcutManager_충돌검출()
|
||||
{
|
||||
var sm = new ShortcutManager(new Dictionary<string, string> { ["find"] = "Ctrl+T" }); // newtab 과 충돌
|
||||
var conflicts = sm.FindConflicts();
|
||||
Assert.Contains(conflicts, g => g.Count == 2 &&
|
||||
g.Any(b => b.Command == "newtab") && g.Any(b => b.Command == "find"));
|
||||
|
||||
Assert.Empty(new ShortcutManager().FindConflicts()); // 기본 표에는 충돌 없음
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Ctrl+Shift+P", System.Windows.Input.ModifierKeys.Control | System.Windows.Input.ModifierKeys.Shift, System.Windows.Input.Key.P)]
|
||||
[InlineData("F12", System.Windows.Input.ModifierKeys.None, System.Windows.Input.Key.F12)]
|
||||
public void ShortcutManager_Parse_조합문자열(string gesture, System.Windows.Input.ModifierKeys mods, System.Windows.Input.Key key)
|
||||
{
|
||||
var parsed = ShortcutManager.Parse(gesture);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(mods, parsed!.Value.Mods);
|
||||
Assert.Equal(key, parsed.Value.Key);
|
||||
Assert.Null(ShortcutManager.Parse("Not+A+Key"));
|
||||
Assert.Null(ShortcutManager.Parse(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortcutManager_Normalize_순서무관_동일취급()
|
||||
=> Assert.Equal(ShortcutManager.Normalize("Ctrl+Shift+P"), ShortcutManager.Normalize("shift+p+ctrl"));
|
||||
|
||||
// ---------- §I 즐겨찾기 ----------
|
||||
|
||||
[Fact]
|
||||
public void 북마크_Netscape_HTML_파싱_및_역호환()
|
||||
{
|
||||
var html = """
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://a.example.com/x?a=1&b=2">사이트 <A></A>
|
||||
<DT><A HREF="https://b.example.com">Plain</A>
|
||||
</DL><p>
|
||||
""";
|
||||
var parsed = BookmarkManagerWindow.ParseNetscapeBookmarks(html).ToList();
|
||||
Assert.Equal(2, parsed.Count);
|
||||
Assert.Equal("https://a.example.com/x?a=1&b=2", parsed[0].Url);
|
||||
Assert.Equal("사이트 <A>", parsed[0].Title);
|
||||
Assert.Equal("Plain", parsed[1].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bookmark_레코드_Folder_기존_JSON_역직렬화_호환()
|
||||
{
|
||||
// 기존 저장 파일(2필드)이 Folder 기본값(null)으로 읽히는지
|
||||
var old = System.Text.Json.JsonSerializer.Deserialize<Bookmark>("""{"Title":"T","Url":"https://x"}""");
|
||||
Assert.NotNull(old);
|
||||
Assert.Null(old!.Folder);
|
||||
var @new = new Bookmark("T", "https://x", "작업");
|
||||
Assert.Equal("작업", @new.Folder);
|
||||
}
|
||||
|
||||
// ---------- §A 테마 ----------
|
||||
|
||||
[Theory]
|
||||
[InlineData("dark")]
|
||||
[InlineData("light")]
|
||||
[InlineData("highcontrast")]
|
||||
public void ThemeResolver_고정값_매핑(string setting)
|
||||
{
|
||||
var (theme, custom) = ThemeResolver.Resolve(setting);
|
||||
Assert.Equal(setting, theme);
|
||||
Assert.Null(custom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThemeResolver_system_은_dark_또는_light_로_확정()
|
||||
{
|
||||
var (theme, _) = ThemeResolver.Resolve("system");
|
||||
Assert.Contains(theme, new[] { "dark", "light" }); // 레지스트리 결과에 따라
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThemeResolver_커스텀_경로_우선()
|
||||
{
|
||||
var file = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var (theme, custom) = ThemeResolver.Resolve("whatever", file);
|
||||
Assert.Equal("custom", theme);
|
||||
Assert.Equal(file, custom);
|
||||
// 없는 파일이면 다크 폴백
|
||||
Assert.Equal("dark", ThemeResolver.Resolve("whatever", @"Z:\nope.xaml").Theme);
|
||||
}
|
||||
finally { try { File.Delete(file); } catch { } }
|
||||
}
|
||||
|
||||
/// <summary>Dark/Light/HighContrast 세 테마가 동일한 키 집합을 정의하는지 —
|
||||
/// 한 테마에만 있는 키는 전환 시 MissingKey 런타임 예외로 이어진다.</summary>
|
||||
[Fact]
|
||||
public void 세_테마_사전의_키_집합이_동일하다()
|
||||
{
|
||||
var keysOf = (string xaml) =>
|
||||
System.Text.RegularExpressions.Regex.Matches(xaml, "x:Key=\"([^\"]+)\"")
|
||||
.Select(m => m.Groups[1].Value).ToHashSet();
|
||||
var dark = keysOf(SourceTree.ThemeDarkXaml);
|
||||
var light = keysOf(SourceTree.ThemeLightXaml);
|
||||
var hc = keysOf(File.ReadAllText(SourceTree.AppPath("Themes/HighContrast.xaml")));
|
||||
Assert.True(dark.SetEquals(light), $"Dark/Light 키 불일치: dark-only={string.Join(",", dark.Except(light))} light-only={string.Join(",", light.Except(dark))}");
|
||||
Assert.True(dark.SetEquals(hc), $"Dark/HighContrast 키 불일치: dark-only={string.Join(",", dark.Except(hc))} hc-only={string.Join(",", hc.Except(dark))}");
|
||||
}
|
||||
|
||||
// ---------- §F 설정 ----------
|
||||
|
||||
[Fact]
|
||||
public void ResetToDefaults_원격토큰은_유지_나머지는_초기화()
|
||||
{
|
||||
var c = new AppConfig
|
||||
{
|
||||
RemoteAccessToken = "keep-me",
|
||||
Concurrency = 1,
|
||||
Theme = "light",
|
||||
ToastDurationMs = 100,
|
||||
DownloadSortOrder = "title",
|
||||
};
|
||||
c.ResetToDefaults();
|
||||
Assert.Equal("keep-me", c.RemoteAccessToken); // 기기 페어링 유지
|
||||
Assert.Equal(new AppConfig().Concurrency, c.Concurrency);
|
||||
Assert.Equal("dark", c.Theme);
|
||||
Assert.Equal(4000, c.ToastDurationMs);
|
||||
Assert.Equal("newest", c.DownloadSortOrder);
|
||||
}
|
||||
}
|
||||
47
VideoDownloader.Tests/E2E/SettingsUpdateUiE2ETests.cs
Normal file
47
VideoDownloader.Tests/E2E/SettingsUpdateUiE2ETests.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
142
VideoDownloader.Tests/E2E/UiUsecaseAccessibilityRedTests.cs
Normal file
142
VideoDownloader.Tests/E2E/UiUsecaseAccessibilityRedTests.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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 (클릭 대상 식별 기준)");
|
||||
}
|
||||
}
|
||||
194
VideoDownloader.Tests/E2E/UiUsecaseDownloadsRedTests.cs
Normal file
194
VideoDownloader.Tests/E2E/UiUsecaseDownloadsRedTests.cs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
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) 요소가 필요하다");
|
||||
}
|
||||
131
VideoDownloader.Tests/E2E/UiUsecaseInteractionRedTests.cs
Normal file
131
VideoDownloader.Tests/E2E/UiUsecaseInteractionRedTests.cs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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", "페이지 링크 우클릭 '링크 다운로드' 메뉴가 필요하다");
|
||||
}
|
||||
168
VideoDownloader.Tests/E2E/UiUsecaseLayoutRedTests.cs
Normal file
168
VideoDownloader.Tests/E2E/UiUsecaseLayoutRedTests.cs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
using VideoDownloader.App;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// UI 복잡 버전 RED 스펙 — §C 레이아웃·밀도·창 상태 유즈케이스(docs/UI_USECASES.md §C, UC-L01~L26).
|
||||
/// RED 단계: 전부 실패하는 것이 정상이다.
|
||||
/// </summary>
|
||||
public class UiUsecaseLayoutRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void UC_L01_UI_밀도_설정이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "UiDensity"),
|
||||
"UC-L01", "컴팩트/보통/여유 밀도를 고르는 AppConfig.UiDensity 가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L02_설정창에서_밀도를_바꿀_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("밀도"),
|
||||
"UC-L02", "설정창에 '밀도' 항목이 노출되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L03_작은_간격_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "Spacing.S"),
|
||||
"UC-L03", "간격 토큰 Spacing.S 가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L04_중간_간격_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "Spacing.M"),
|
||||
"UC-L04", "간격 토큰 Spacing.M 이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L05_큰_간격_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "Spacing.L"),
|
||||
"UC-L05", "간격 토큰 Spacing.L 이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L06_카드_모서리_둥글기_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "CornerRadius.Card"),
|
||||
"UC-L06", "카드 둥근 모서리 토큰 CornerRadius.Card 가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L07_컴팩트_모드_상태가_저장된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "CompactMode"),
|
||||
"UC-L07", "AppConfig.CompactMode 로 재시작 후에도 컴팩트 상태가 유지되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L08_툴바에_컴팩트_토글_버튼이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("Name=\"CompactModeBtn\""),
|
||||
"UC-L08", "툴바의 CompactModeBtn 로 컴팩트 모드를 빠르게 켜고 끌 수 있어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L09_세로_탭_기본_여부를_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "VerticalTabsDefault"),
|
||||
"UC-L09", "AppConfig.VerticalTabsDefault 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L10_분할_화면_비율이_저장된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "SplitViewRatio"),
|
||||
"UC-L10", "분할 화면 비율(AppConfig.SplitViewRatio)이 저장·복원되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L11_창_위치_크기가_저장된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "WindowBounds"),
|
||||
"UC-L11", "AppConfig.WindowBounds 로 창 위치·크기가 복원되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L12_최대화_상태가_저장된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "WindowMaximized"),
|
||||
"UC-L12", "AppConfig.WindowMaximized 로 최대화 여부가 복원되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L13_루트_요소가_레이아웃_반올림을_켠다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("UseLayoutRounding"),
|
||||
"UC-L13", "MainWindow 가 UseLayoutRounding 으로 고DPI 흐림을 방지해야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L14_공용_스타일이_픽셀_정렬을_켠다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SharedXaml.Contains("SnapsToDevicePixels"),
|
||||
"UC-L14", "Shared.xaml 스타일이 SnapsToDevicePixels 로 선명도를 보장해야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L15_상태바_자동_숨김을_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "StatusBarAutoHide"),
|
||||
"UC-L15", "AppConfig.StatusBarAutoHide 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L16_툴바_버튼_노출을_직접_고른다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "ToolbarButtons"),
|
||||
"UC-L16", "AppConfig.ToolbarButtons 로 버튼 표시 여부를 지정할 수 있어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L17_북마크바_표시_여부가_저장된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "BookmarkBarVisible"),
|
||||
"UC-L17", "AppConfig.BookmarkBarVisible 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L18_탭_스트립_위치를_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "TabStripPosition"),
|
||||
"UC-L18", "AppConfig.TabStripPosition 로 탭 스트립 위치를 고를 수 있어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L19_패널_폭을_더블클릭으로_초기화한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(MainWindow), "ResetPanelWidth"),
|
||||
"UC-L19", "MainWindow.ResetPanelWidth 로 패널 폭을 기본값으로 되돌릴 수 있어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L20_패널_최소_폭을_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "RightPanelMinWidth"),
|
||||
"UC-L20", "AppConfig.RightPanelMinWidth 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L21_애니메이션_축소_설정이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "ReduceMotion"),
|
||||
"UC-L21", "AppConfig.ReduceMotion 로 애니메이션을 줄일 수 있어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L22_빠른_애니메이션_지속시간_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "Duration.Fast"),
|
||||
"UC-L22", "지속 시간 토큰 Duration.Fast 가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L23_일반_애니메이션_지속시간_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "Duration.Normal"),
|
||||
"UC-L23", "지속 시간 토큰 Duration.Normal 이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L24_컴팩트_엣지_호버_반응_속도를_설정한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "EdgeRevealDelayMs"),
|
||||
"UC-L24", "AppConfig.EdgeRevealDelayMs 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L25_UI_전체_배율을_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "UiScale"),
|
||||
"UC-L25", "AppConfig.UiScale 로 UI 전체 배율을 조절할 수 있어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_L26_전체화면_여부가_저장된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "FullScreen"),
|
||||
"UC-L26", "AppConfig.FullScreen 속성이 필요하다");
|
||||
}
|
||||
143
VideoDownloader.Tests/E2E/UiUsecaseLibraryRedTests.cs
Normal file
143
VideoDownloader.Tests/E2E/UiUsecaseLibraryRedTests.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// UI 복잡 버전 RED 스펙 — §I 라이브러리·재생목록·즐겨찾기·기록·노트 유즈케이스(docs/UI_USECASES.md §I, UC-M01~M22).
|
||||
/// 데스크톱 앱에는 아직 라이브러리 뷰가 없으므로 전부 RED 다.
|
||||
/// </summary>
|
||||
public class UiUsecaseLibraryRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void UC_M01_라이브러리_뷰가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("LibraryPopup") || SourceTree.MainWindowXaml.Contains("LibraryBtn"),
|
||||
"UC-M01", "받은 파일을 보는 라이브러리 뷰(LibraryPopup/LibraryBtn)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M02_라이브러리를_그리드_리스트로_전환한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("LibraryViewToggle"),
|
||||
"UC-M02", "그리드/리스트 전환(LibraryViewToggle)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M03_라이브러리를_검색할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("LibrarySearchBox"),
|
||||
"UC-M03", "라이브러리 검색 상자(LibrarySearchBox)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M04_라이브러리를_정렬할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("LibrarySortMenu"),
|
||||
"UC-M04", "라이브러리 정렬 메뉴(LibrarySortMenu)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M05_항목에_추가일이_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("AddedDateText"),
|
||||
"UC-M05", "라이브러리 항목의 추가일 표시(AddedDateText)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M06_항목에_썸네일이_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("LibraryThumbnail"),
|
||||
"UC-M06", "라이브러리 썸네일(LibraryThumbnail)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M07_항목을_더블클릭해_재생한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("LibraryPlay_Click"),
|
||||
"UC-M07", "라이브러리 재생(LibraryPlay_Click) 처리기가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M08_항목을_삭제할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("LibraryDelete_Click"),
|
||||
"UC-M08", "라이브러리 삭제(LibraryDelete_Click) 처리기가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M09_항목_폴더를_열_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("LibraryOpenFolder_Click"),
|
||||
"UC-M09", "라이브러리 폴더 열기(LibraryOpenFolder_Click)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M10_라이브러리_빈_상태_안내가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("LibraryEmptyHint"),
|
||||
"UC-M10", "라이브러리 빈 상태 안내(LibraryEmptyHint)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M11_재생목록_셔플이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("PlaylistShuffle"),
|
||||
"UC-M11", "재생목록 셔플(PlaylistShuffle) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M12_반복_모드가_저장된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "PlaylistLoopMode"),
|
||||
"UC-M12", "AppConfig.PlaylistLoopMode 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M13_재생목록을_파일로_저장_불러온다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("PlaylistExport"),
|
||||
"UC-M13", "재생목록 내보내기/불러오기(PlaylistExport)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M14_재생목록_순서를_바꾼다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("PlaylistMoveUp"),
|
||||
"UC-M14", "재생목록 순서 변경(PlaylistMoveUp) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M15_재생목록을_검색한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("PlaylistSearchBox"),
|
||||
"UC-M15", "재생목록 검색 상자(PlaylistSearchBox)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M16_즐겨찾기를_폴더로_정리한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("BookmarkFolder"),
|
||||
"UC-M16", "즐겨찾기 폴더(BookmarkFolder) 기능이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M17_즐겨찾기를_편집한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("EditBookmark"),
|
||||
"UC-M17", "즐겨찾기 편집(EditBookmark) 기능이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M18_즐겨찾기를_검색한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("BookmarkSearchBox"),
|
||||
"UC-M18", "즐겨찾기 검색 상자(BookmarkSearchBox)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M19_즐겨찾기를_HTML로_내보낸다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("ExportBookmarks"),
|
||||
"UC-M19", "즐겨찾기 내보내기(ExportBookmarks) 기능이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M20_방문_기록을_날짜별로_묶는다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("GroupHistoryByDate"),
|
||||
"UC-M20", "방문 기록 날짜 묶음(GroupHistoryByDate) 기능이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M21_방문_기록을_검색한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("HistorySearchBox"),
|
||||
"UC-M21", "방문 기록 검색 상자(HistorySearchBox)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_M22_노트를_검색한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("NotesSearchBox"),
|
||||
"UC-M22", "노트 검색 상자(NotesSearchBox)가 필요하다");
|
||||
}
|
||||
249
VideoDownloader.Tests/E2E/UiUsecaseLiveE2ETests.cs
Normal file
249
VideoDownloader.Tests/E2E/UiUsecaseLiveE2ETests.cs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using VideoDownloader.App;
|
||||
using VideoDownloader.App.Browser.Features;
|
||||
using VideoDownloader.App.Core;
|
||||
using VideoDownloader.App.Services;
|
||||
using VideoDownloader.App.ViewModels;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
using WpfApp = VideoDownloader.App.App;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// E2E: "UI 복잡 버전"(docs/UI_USECASES.md §A~§J) GREEN 구현의 실창 실증 —
|
||||
/// MainWindowSmokeE2ETests 와 동일한 StaDispatcher/WpfTestApp 패턴으로 실제 윈도우를 띄워
|
||||
/// ① 신규 유즈케이스 컨트롤이 실 트리에 존재하는지 ② 테마가 재시작 없이 라이브 전환되는지(UC-T03/T04)
|
||||
/// ③ 다운로드 일시정지/재개 상태 전이(UC-D06/D07) ④ 설정창 검색 필터·테마 즉시 적용(UC-S01)
|
||||
/// ⑤ 토스트 방해 금지 억제·알림센터 히스토리(UC-N14/N15)를 검증한다.
|
||||
/// </summary>
|
||||
[Collection("E2E")]
|
||||
public class UiUsecaseLiveE2ETests
|
||||
{
|
||||
private static string TempDataDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"vd_uc_e2e_{Guid.NewGuid():N}");
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MainWindow_유즈케이스_컨트롤_실존과_라이브_테마_전환_E2E()
|
||||
{
|
||||
var dataDir = TempDataDir();
|
||||
try
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
var ok = await sta.InvokeAsync(async () =>
|
||||
{
|
||||
var window = new MainWindow();
|
||||
window.Show();
|
||||
var first = await Wait.Until(
|
||||
() => Task.FromResult(window.Tabs.ActiveTab),
|
||||
t => t?.Core != null, 30000);
|
||||
|
||||
// ── §C/§D/§E/§I/§J 신규 컨트롤이 실 트리에 존재(최상위 x:Name 필드) ──
|
||||
var mustExist = new FrameworkElement?[]
|
||||
{
|
||||
// 툴바
|
||||
window.CompactModeBtn, window.LibraryBtn, window.PairingBtn,
|
||||
window.DownloadsBtnBadge, window.UpdateBadge,
|
||||
// 탭 스트립
|
||||
window.WorkspaceIndicator, window.WorkspaceSwitcher, window.NewTabMoreBtn,
|
||||
window.ArchiveBtn, window.TabInsertIndicator,
|
||||
// 다운로드 패널
|
||||
window.DownloadFilterTabs, window.DownloadSearchBox, window.QueueSummaryText,
|
||||
window.GlobalSpeedText, window.SpeedSparkline,
|
||||
// 상태바·오버레이
|
||||
window.StatusRight, window.ZoomIndicator, window.ServerStatusIcon,
|
||||
window.NotificationCenter, window.ShortcutHelpBtn, window.AggregateProgress,
|
||||
window.ToastHost, window.ErrorBanner, window.ErrorDetailExpander,
|
||||
window.ClipboardHint, window.Onboarding,
|
||||
// 팝업 본문
|
||||
window.ArchivePopup, window.ArchiveSearchBox,
|
||||
window.LibraryPopup, window.LibrarySearchBox, window.LibraryViewToggle, window.LibrarySortMenu,
|
||||
window.BookmarkSearchBox, window.HistorySearchBox, window.NotesSearchBox,
|
||||
window.PlaylistSearchBox, window.PlaylistShuffle,
|
||||
window.ShortcutHelpPopup, window.NotificationCenterPopup, window.TabVolumePopup,
|
||||
};
|
||||
var missing = mustExist.Count(e => e == null);
|
||||
|
||||
// ── §J 초기 상태 계약: 온보딩 표시(세션 없음)·오류배너·클립보드힌트 숨김 ──
|
||||
var onboardingShown = window.Onboarding.Visibility == Visibility.Visible;
|
||||
var errorHidden = window.ErrorBanner.Visibility == Visibility.Collapsed;
|
||||
var clipboardHidden = window.ClipboardHint.Visibility == Visibility.Collapsed;
|
||||
|
||||
// ── §A 라이브 테마 전환(UC-T03/T04) — 창을 다시 만들지 않고 즉시 ──
|
||||
window.ApplyTheme("light");
|
||||
var lightApplied = WpfApp.CurrentTheme == "light" && WpfApp.IsLightTheme;
|
||||
var aliveInLight = window.IsLoaded;
|
||||
window.ApplyTheme("dark");
|
||||
var darkApplied = WpfApp.CurrentTheme == "dark" && !WpfApp.IsLightTheme;
|
||||
var aliveInDark = window.IsLoaded;
|
||||
var lightRestore = WpfApp.CurrentTheme == "dark"; // 원복 확인
|
||||
|
||||
window.Close();
|
||||
await Task.Delay(800); // WebView2 파일 핸들 해제 여유(smoke 테스트 패턴)
|
||||
var fails = new List<string>();
|
||||
if (missing != 0) fails.Add($"컨트롤 누락 {missing}개");
|
||||
if (!onboardingShown) fails.Add("온보딩 미표시");
|
||||
if (!errorHidden) fails.Add("오류배너 초기 미숨김");
|
||||
if (!clipboardHidden) fails.Add("클립보드힌트 초기 미숨김");
|
||||
if (!lightApplied) fails.Add("라이트 테마 미적용");
|
||||
if (!aliveInLight) fails.Add("라이트 전환 후 창 사망");
|
||||
if (!darkApplied) fails.Add("다크 원복 미적용");
|
||||
if (!aliveInDark) fails.Add("다크 전환 후 창 사망");
|
||||
return string.Join(" | ", fails);
|
||||
});
|
||||
Assert.True(string.IsNullOrEmpty(ok), $"MainWindow 유즈케이스 E2E 실패: {ok}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
|
||||
try { Directory.Delete(dataDir, recursive: true); } catch { } // WebView2 파일 점유 시 남김(smoke 테스트 패턴)
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadManager_전체_일시정지_및_재개_상태전이_E2E()
|
||||
{
|
||||
var dataDir = TempDataDir();
|
||||
var outDir = Path.Combine(dataDir, "out");
|
||||
try
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
var ok = sta.Invoke(() =>
|
||||
{
|
||||
var config = new AppConfig { OutputFolder = outDir, MaxConcurrentDownloads = 2 };
|
||||
var manager = new DownloadManager(
|
||||
config,
|
||||
_ => Task.FromResult(new List<Microsoft.Web.WebView2.Core.CoreWebView2Cookie>()),
|
||||
_ => { });
|
||||
|
||||
var running = new DownloadItemViewModel("진행 중 항목")
|
||||
{
|
||||
Status = "다운로드 중",
|
||||
Started = true,
|
||||
MediaUrl = new Uri("https://127.0.0.1:9/video.mp4"), // 비라우팅 — 재개가 네트워크를 치지 않게
|
||||
OutputBase = Path.Combine(outDir, "video"),
|
||||
};
|
||||
var queued = new DownloadItemViewModel("대기 항목")
|
||||
{
|
||||
Status = "대기",
|
||||
MediaUrl = new Uri("https://example.com/video2.mp4"),
|
||||
OutputBase = Path.Combine(outDir, "video2"),
|
||||
};
|
||||
manager.Items.Add(running);
|
||||
manager.Items.Add(queued);
|
||||
|
||||
// UC-D06: 전체 일시정지 — 진행 중만 "일시정지" 전이, 대기 항목은 그대로
|
||||
manager.PauseAll();
|
||||
var pausedStatus = running.Status == "일시정지" && running.PausedForResume;
|
||||
var queuedUntouched = queued.Status == "대기";
|
||||
|
||||
// UC-D07: 전체 재개 — 일시정지 항목만 재투입(PausedForResume 해제 + Started 재설정)
|
||||
manager.ResumeAll();
|
||||
var resumed = !running.PausedForResume && running.Started; // 재시작 큐 진입
|
||||
manager.Cancel(running); // 실측 목적 달성 — 원격 접속 시도 중단
|
||||
var queuedStillQueued = queued.Started == false && queued.PausedForResume == false;
|
||||
|
||||
var fails = new List<string>();
|
||||
if (!pausedStatus) fails.Add($"PauseAll 후 상태={running.Status}/PausedForResume={running.PausedForResume}");
|
||||
if (!queuedUntouched) fails.Add($"대기 항목이 건드려짐(상태={queued.Status})");
|
||||
if (!resumed) fails.Add($"ResumeAll 후 재투입 안 됨(Started={running.Started}/PausedForResume={running.PausedForResume})");
|
||||
if (!queuedStillQueued) fails.Add("대기 항목이 재개 대상이 됨");
|
||||
return string.Join(" | ", fails);
|
||||
});
|
||||
Assert.True(string.IsNullOrEmpty(ok), $"일시정지/재개 E2E 실패: {ok}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
|
||||
try { Directory.Delete(dataDir, recursive: true); } catch { } // WebView2 파일 점유 시 남김(smoke 테스트 패턴)
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsWindow_검색_필터와_테마_즉시적용_E2E()
|
||||
{
|
||||
var dataDir = TempDataDir();
|
||||
try
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
var ok = sta.Invoke(() =>
|
||||
{
|
||||
var config = new AppConfig(); // 기본 테마 dark
|
||||
var win = new SettingsWindow(config);
|
||||
|
||||
// 생성 시점 로드: 테마 dark 반영 + 검색창 비어 전 섹션 표시
|
||||
var initialTheme = WpfApp.CurrentTheme == "dark";
|
||||
var allVisible = win.SectionFont.Visibility == Visibility.Visible
|
||||
&& win.SectionDownload.Visibility == Visibility.Visible;
|
||||
|
||||
// UC-T01/T02: 콤보박스로 라이트 선택 → 앱 테마가 즉시 전환(재시작 없음)
|
||||
win.ThemeModeBox.SelectedValue = "light";
|
||||
var lightLive = WpfApp.CurrentTheme == "light" && WpfApp.IsLightTheme;
|
||||
win.ThemeModeBox.SelectedValue = "dark";
|
||||
var backToDark = WpfApp.CurrentTheme == "dark";
|
||||
|
||||
// UC-S01: 설정 검색 — 해당 섹션만 노출
|
||||
win.SettingsSearchBox.Text = "글꼴";
|
||||
var fontOnly = win.SectionFont.Visibility == Visibility.Visible
|
||||
&& win.SectionDownload.Visibility == Visibility.Collapsed
|
||||
&& win.SectionNotify.Visibility == Visibility.Collapsed;
|
||||
win.SettingsSearchBox.Text = "알림";
|
||||
var downloadOnly = win.SectionNotify.Visibility == Visibility.Visible
|
||||
&& win.SectionDownload.Visibility == Visibility.Collapsed
|
||||
&& win.SectionAccessibility.Visibility == Visibility.Collapsed;
|
||||
win.SettingsSearchBox.Text = "";
|
||||
var restored = win.SectionFont.Visibility == Visibility.Visible
|
||||
&& win.SectionDownload.Visibility == Visibility.Visible;
|
||||
|
||||
win.Close();
|
||||
var fails = new List<string>();
|
||||
if (!initialTheme) fails.Add($"초기 테마 dark 아님(현재 {WpfApp.CurrentTheme})");
|
||||
if (!allVisible) fails.Add("초기 전 섹션 표시 아님");
|
||||
if (!lightLive) fails.Add($"라이트 즉시 적용 실패(현재 {WpfApp.CurrentTheme}/{WpfApp.IsLightTheme})");
|
||||
if (!backToDark) fails.Add($"다크 원복 실패(현재 {WpfApp.CurrentTheme})");
|
||||
if (!fontOnly) fails.Add("'글꼴' 검색 시 글꼴 섹션만 표시 아님");
|
||||
if (!downloadOnly) fails.Add("'알림' 검색 시 알림 섹션만 표시 아님");
|
||||
if (!restored) fails.Add("빈 검색어로 복원 안 됨");
|
||||
return string.Join(" | ", fails);
|
||||
});
|
||||
Assert.True(string.IsNullOrEmpty(ok), $"설정창 E2E 실패: {ok}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
|
||||
try { Directory.Delete(dataDir, recursive: true); } catch { } // WebView2 파일 점유 시 남김(smoke 테스트 패턴)
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToastNotifier_방해금지_억제와_알림센터_히스토리_E2E()
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
var ok = sta.Invoke(() =>
|
||||
{
|
||||
var host = new StackPanel();
|
||||
var toast = new ToastNotifier(host) { Duration = TimeSpan.FromMilliseconds(500) };
|
||||
|
||||
// 일반 표시: 토스트 1장 + 히스토리 1건(UC-N02)
|
||||
toast.Show("완료: 첫 번째", "success");
|
||||
var shown = host.Children.Count == 1 && toast.History.Count == 1;
|
||||
|
||||
// UC-N15 방해 금지: 표시는 억제되고 히스토리에만 기록(UC-N14 알림 센터 원천)
|
||||
toast.DoNotDisturb = true;
|
||||
toast.Show("실패: 두 번째", "error");
|
||||
var suppressed = host.Children.Count == 1 && toast.History.Count == 2;
|
||||
|
||||
return shown && suppressed;
|
||||
});
|
||||
Assert.True(ok, "토스트 방해 금지/히스토리 계약 위반");
|
||||
}
|
||||
}
|
||||
132
VideoDownloader.Tests/E2E/UiUsecaseSettingsRedTests.cs
Normal file
132
VideoDownloader.Tests/E2E/UiUsecaseSettingsRedTests.cs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
using VideoDownloader.App;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// UI 복잡 버전 RED 스펙 — §F 설정 창 유즈케이스(docs/UI_USECASES.md §F, UC-S01~S20).
|
||||
/// RED 단계: 전부 실패하는 것이 정상이다.
|
||||
/// </summary>
|
||||
public class UiUsecaseSettingsRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void UC_S01_설정을_검색으로_찾을_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("SettingsSearchBox"),
|
||||
"UC-S01", "설정 검색 상자(SettingsSearchBox)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S02_카테고리_내비게이션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("SettingsNavList"),
|
||||
"UC-S02", "좌측 카테고리 내비게이션(SettingsNavList)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S03_재시작_필요_변경을_배너로_알린다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("RestartBanner"),
|
||||
"UC-S03", "재시작 필요 알림 배너(RestartBanner)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S04_기본값으로_한번에_되돌릴_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("ResetDefaultsButton"),
|
||||
"UC-S04", "기본값 복원 버튼(ResetDefaultsButton)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S05_설정을_내보낼_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("ExportSettingsButton"),
|
||||
"UC-S05", "설정 내보내기 버튼(ExportSettingsButton)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S06_설정을_가져올_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("ImportSettingsButton"),
|
||||
"UC-S06", "설정 가져오기 버튼(ImportSettingsButton)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S07_고급_항목을_접을_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("AdvancedSection"),
|
||||
"UC-S07", "고급 섹션 접힘(AdvancedSection)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S08_Ctrl_S로_저장할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowCs.Contains("Key.S"),
|
||||
"UC-S08", "설정창 코드비하인드가 Ctrl+S(Key.S) 저장을 처리해야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S09_취소_시_폐기_확인을_받는다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(SettingsWindow), "ConfirmDiscardChanges"),
|
||||
"UC-S09", "SettingsWindow.ConfirmDiscardChanges() 확인 절차가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S10_설정창_크기_위치가_기억된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "SettingsWindowBounds"),
|
||||
"UC-S10", "AppConfig.SettingsWindowBounds 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S11_재시도_횟수를_UI에서_바꾼다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Name=\"RetriesBox\""),
|
||||
"UC-S11", "재시도 횟수 입력(RetriesBox)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S12_UserAgent를_UI에서_바꾼다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Name=\"UserAgentBox\""),
|
||||
"UC-S12", "User-Agent 입력(UserAgentBox)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S13_프록시를_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Name=\"ProxyBox\""),
|
||||
"UC-S13", "프록시 설정(ProxyBox)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S14_UI_언어를_고를_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Name=\"LanguageBox\""),
|
||||
"UC-S14", "언어 선택(LanguageBox)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S15_자동_실행을_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Name=\"AutoStartBox\""),
|
||||
"UC-S15", "시작 시 자동 실행(AutoStartBox)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S16_트레이로_최소화할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("MinimizeToTrayBox"),
|
||||
"UC-S16", "트레이 최소화 옵션(MinimizeToTrayBox)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S17_알림_설정_섹션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("알림"),
|
||||
"UC-S17", "설정창에 '알림' 섹션이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S18_실험적_기능_섹션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("실험적"),
|
||||
"UC-S18", "설정창에 '실험적' 기능 섹션이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S19_실시간_검증_힌트가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("ValidationHint"),
|
||||
"UC-S19", "입력 검증 힌트(ValidationHint) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_S20_버전_정보가_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Name=\"VersionText\""),
|
||||
"UC-S20", "앱 버전 표시(VersionText)가 필요하다");
|
||||
}
|
||||
131
VideoDownloader.Tests/E2E/UiUsecaseStatusRedTests.cs
Normal file
131
VideoDownloader.Tests/E2E/UiUsecaseStatusRedTests.cs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// UI 복잡 버전 RED 스펙 — §J 상태 표시·알림·피드백 유즈케이스(docs/UI_USECASES.md §J, UC-N01~N20).
|
||||
/// RED 단계: 전부 실패하는 것이 정상이다.
|
||||
/// </summary>
|
||||
public class UiUsecaseStatusRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void UC_N01_상태바에_전체_다운로드_진행률이_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("AggregateProgress"),
|
||||
"UC-N01", "상태바 통합 진행률(AggregateProgress) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N02_토스트_호스트가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("ToastHost"),
|
||||
"UC-N02", "알림 토스트 컨테이너(ToastHost)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N03_토스트_표시_시간을_설정한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "ToastDurationMs"),
|
||||
"UC-N03", "AppConfig.ToastDurationMs 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N04_치명_오류_배너가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("ErrorBanner"),
|
||||
"UC-N04", "상단 고정 오류 배너(ErrorBanner)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N05_오류_상세를_펼쳐_볼_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("ErrorDetailExpander"),
|
||||
"UC-N05", "오류 상세 펼침(ErrorDetailExpander) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N06_클립보드_URL_감지_제안이_뜬다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("ClipboardHint"),
|
||||
"UC-N06", "클립보드 URL 감지 배지(ClipboardHint)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N07_원격_서버_상태가_상시_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("ServerStatusIcon"),
|
||||
"UC-N07", "서버 상태 아이콘(ServerStatusIcon)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N08_툴바에서_QR_페어링을_바로_연다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("PairingBtn"),
|
||||
"UC-N08", "툴바 페어링 버튼(PairingBtn)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N09_업데이트_가능_배지가_뜬다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("UpdateBadge"),
|
||||
"UC-N09", "새 버전 알림 배지(UpdateBadge)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N10_차단_도메인별_통계가_보인다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("ShieldDomainStats"),
|
||||
"UC-N10", "도메인별 차단 통계(ShieldDomainStats)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N11_네트워크_끊김이_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("NetworkChange"),
|
||||
"UC-N11", "NetworkChange 오프라인 감지 처리가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N12_절전_모드_진입_시_일시정지_옵션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "PauseOnPowerSaver"),
|
||||
"UC-N12", "AppConfig.PauseOnPowerSaver 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N13_상태바_텍스트를_클릭해_복사한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("CopyStatus_Click"),
|
||||
"UC-N13", "상태 복사(CopyStatus_Click) 처리기가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N14_알림_센터가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("NotificationCenter"),
|
||||
"UC-N14", "지나간 알림 목록(NotificationCenter)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N15_방해_금지_모드를_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "DoNotDisturb"),
|
||||
"UC-N15", "AppConfig.DoNotDisturb 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N16_온보딩_안내가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("Onboarding") || SourceTree.MainWindowCs.Contains("Onboarding"),
|
||||
"UC-N16", "첫 실행 온보딩(Onboarding) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N17_빈_상태_공용_스타일이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "EmptyStateStyle"),
|
||||
"UC-N17", "빈 상태 공용 스타일(EmptyStateStyle) 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N18_크래시_리포트_대화상자가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.FindType("CrashReportDialog") is not null,
|
||||
"UC-N18", "충돌 리포트 대화상자(CrashReportDialog)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N19_다운로드_버튼에_진행_개수_배지가_뜬다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("DownloadsBtnBadge"),
|
||||
"UC-N19", "툴바 다운로드 버튼 배지(DownloadsBtnBadge)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_N20_상태바_우측_보조_정보_영역이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("StatusRight"),
|
||||
"UC-N20", "상태바 우측 정보 영역(StatusRight)이 필요하다");
|
||||
}
|
||||
163
VideoDownloader.Tests/E2E/UiUsecaseTabsRedTests.cs
Normal file
163
VideoDownloader.Tests/E2E/UiUsecaseTabsRedTests.cs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
using VideoDownloader.App;
|
||||
using VideoDownloader.Browser;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// UI 복잡 버전 RED 스펙 — §D 탭 고급 관리 유즈케이스(docs/UI_USECASES.md §D, UC-B01~B24).
|
||||
/// RED 단계: 전부 실패하는 것이 정상이다.
|
||||
/// </summary>
|
||||
public class UiUsecaseTabsRedTests
|
||||
{
|
||||
private static readonly Type TabItemType =
|
||||
SourceTree.FindType("TabItem") ?? throw new InvalidOperationException("Browser TabItem 형식을 찾을 수 없습니다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B01_탭을_잠글_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(TabItemType, "IsLocked"),
|
||||
"UC-B01", "탭 잠금(IsLocked)으로 실수로 닫는 것을 막을 수 있어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B02_탭마다_사용자_지정_색을_달_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(TabItemType, "CustomColor"),
|
||||
"UC-B02", "탭 사용자 샆(CustomColor) 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B03_탭_그룹을_접을_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(TabItemType, "IsGroupCollapsed"),
|
||||
"UC-B03", "그룹 접음(IsGroupCollapsed) 상태가 탭 항목에 있어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B04_탭_그룹_이름을_변경할_수_있다()
|
||||
{
|
||||
var ext = SourceTree.FindType("TabManagerGroupExtensions");
|
||||
SourceTree.Usecase(
|
||||
ext is not null && SourceTree.MethodExists(ext, "RenameGroup"),
|
||||
"UC-B04", "그룹 이름 변경(RenameGroup) API 가 필요하다");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UC_B05_탭_호버_미리보기_컴포넌트가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.FindType("TabPreview") is not null,
|
||||
"UC-B05", "탭 호버 미리보기를 담당하는 TabPreview 컴포넌트가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B06_잠자기_탭이_시각적으로_구분된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("IsSuspended"),
|
||||
"UC-B06", "탭 템플릿이 잠자기 상태(IsSuspended)를 시각적으로 표시해야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B07_탭_번호_배지가_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("TabIndexBadge"),
|
||||
"UC-B07", "탭 번호 배지(TabIndexBadge) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B08_탭_드래그_삽입_표시기가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("TabInsertIndicator"),
|
||||
"UC-B08", "드래그 중 삽입 위치 표시기(TabInsertIndicator)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B09_다중_탭_닫기_확인_옵션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "ConfirmCloseMultipleTabs"),
|
||||
"UC-B09", "AppConfig.ConfirmCloseMultipleTabs 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B10_휠로_탭을_전환하는_옵션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "TabWheelSwitch"),
|
||||
"UC-B10", "AppConfig.TabWheelSwitch 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B11_탭_최대_개수를_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "MaxTabs"),
|
||||
"UC-B11", "AppConfig.MaxTabs 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B12_세션_복원을_잠자기_상태로_할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "RestoreTabsSuspended"),
|
||||
"UC-B12", "AppConfig.RestoreTabsSuspended 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B13_닫은_탭_아카이브_전용_팝업이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("Name=\"ArchivePopup\""),
|
||||
"UC-B13", "아카이브를 보여주는 ArchivePopup 이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B14_아카이브를_검색할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("ArchiveSearchBox"),
|
||||
"UC-B14", "아카이브 검색 상자(ArchiveSearchBox)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B15_아카이브를_한번에_비울_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(TabArchive), "Clear"),
|
||||
"UC-B15", "TabArchive.Clear() 메서드가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B16_아카이브_항목을_복원할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(TabArchive), "Restore"),
|
||||
"UC-B16", "TabArchive.Restore() 메서드가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B17_탭_그룹_색_팔레트가_테마로_관리된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "GroupPalette"),
|
||||
"UC-B17", "그룹 색 팔레트(GroupPalette)가 공용 리소스로 정의되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B18_현재_작업공간_이름이_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("WorkspaceIndicator"),
|
||||
"UC-B18", "현재 작업공간 표시(WorkspaceIndicator) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B19_작업공간_이름을_직접_지을_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "WorkspaceNames"),
|
||||
"UC-B19", "AppConfig.WorkspaceNames 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B20_작업공간_전환_UI가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("WorkspaceSwitcher"),
|
||||
"UC-B20", "작업공간 전환기(WorkspaceSwitcher) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B21_새_탭_버튼에_보조_메뉴가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("NewTabMore_Click"),
|
||||
"UC-B21", "새 탭 버튼의 보조 메뉴(최근 페이지·즐겨찾기) 핸들러가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B22_탭_제목을_두_줄로_볼_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "TabTitleMultiline"),
|
||||
"UC-B22", "AppConfig.TabTitleMultiline 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B23_탭에서_볼륨_슬라이더를_쓸_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("TabVolumePopup"),
|
||||
"UC-B23", "탭 볼륨 조절 팝업(TabVolumePopup)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_B24_탭_그룹을_즐겨찾기로_저장할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(MainWindow), "BookmarkGroup"),
|
||||
"UC-B24", "그룹 전체를 즐겨찾기로 저장하는 MainWindow.BookmarkGroup() 이 필요하다");
|
||||
}
|
||||
213
VideoDownloader.Tests/E2E/UiUsecaseThemeRedTests.cs
Normal file
213
VideoDownloader.Tests/E2E/UiUsecaseThemeRedTests.cs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
using VideoDownloader.App;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// UI 복잡 버전 RED 스펙 — §A 테마·색상·대비 유즈케이스(docs/UI_USECASES.md §A, UC-T01~T30).
|
||||
/// RED 단계: 전부 실패하는 것이 정상이다. 계약은 해당 문서의 "계약" 열을 따른다.
|
||||
/// </summary>
|
||||
public class UiUsecaseThemeRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void UC_T01_시스템_테마를_따라가는_옵션이_설정에_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Tag=\"system\""),
|
||||
"UC-T01", "OS 테마(다크/라이트)를 자동으로 따라가는 'system' 옵션이 설정창에 노출된다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T02_테마를_콤보박스로_다중_옵션_중_고른다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Name=\"ThemeModeBox\""),
|
||||
"UC-T02", "테마 선택이 체크박스(라이트만)가 아니라 다크/라이트/시스템/고대비 콤보박스여야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T03_테마_전환에_재시작이_필요하다는_문구가_없어야_한다()
|
||||
=> SourceTree.Usecase(
|
||||
!SourceTree.SettingsWindowXaml.Contains("재시작"),
|
||||
"UC-T03", "테마는 즉시 적용되어야 하며 설정창에 '재시작' 안내가 남아 있지 않아야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T04_MainWindow에_라이브_테마_적용_API가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(MainWindow), "ApplyTheme"),
|
||||
"UC-T04", "테마 전환 후 열려 있는 MainWindow 자체가 리소스를 다시 적용하는 API 가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T05_고대비_테마_사전_파일이_존재한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.AppFileExists("Themes/HighContrast.xaml"),
|
||||
"UC-T05", "고대비 사용자를 위한 Themes/HighContrast.xaml 리소스 사전이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T06_설정에서_고대비_테마를_직접_고를_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Tag=\"highcontrast\""),
|
||||
"UC-T06", "설정창 테마 옵션에 'highcontrast' 값이 제공되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T07_사용자_지정_강조색이_설정에_저장된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "AccentColor"),
|
||||
"UC-T07", "AppConfig 에 강조색(AccentColor) 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T08_강조색을_즉시_적용하는_API가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(VideoDownloader.App.App), "ApplyAccent"),
|
||||
"UC-T08", "App.ApplyAccent 로 재시작 없이 강조색이 전체 컨트롤에 반영되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T09_강조색_프리셋_스와치가_테마에_준비된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "Accent.Blue"),
|
||||
"UC-T09", "다크 테마에 Accent.Blue 등 강조색 프리셋 키가 정의되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T10_진행바_스타일에_하드코딩된_강조색이_없어야_한다()
|
||||
=> SourceTree.Usecase(
|
||||
!SourceTree.SharedXaml.Contains("Color=\"#6366F1\""),
|
||||
"UC-T10", "AccentProgressBar 템플릿이 하드코딩 색 대신 테마 강조색 리소스를 참조해야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T11_대기_상태색_PendingBrush가_실제_UI에서_쓰인다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("PendingBrush"),
|
||||
"UC-T11", "테마에 정의만 된 PendingBrush 가 대기 상태 표시에 실제로 사용되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T12_사용자_테마_파일_경로를_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "CustomThemePath"),
|
||||
"UC-T12", "AppConfig.CustomThemePath 로 사용자 테마 파일을 불러올 수 있어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T13_시스템_테마_판별_컴포넌트가_존재한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.FindType("ThemeResolver") is not null,
|
||||
"UC-T13", "OS 테마 값을 판별하는 ThemeResolver 컴포넌트가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T14_다크_TextMuted가_CardBg에서_AA대비를_만족한다()
|
||||
{
|
||||
var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "TextMuted");
|
||||
var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBg");
|
||||
Assert.False(fg is null || bg is null, "UC-T14 — 테마 브러시 파싱 실패(테마 파일 구조 변경?)");
|
||||
var ratio = SourceTree.ContrastRatio(fg!, bg!);
|
||||
Assert.True(ratio >= 4.5, $"UC-T14 RED — 다크 TextMuted/CardBg 대비 {ratio:F2}:1 < 4.5:1 (WCAG AA)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UC_T15_다크_TextMuted가_카드_호버_배경에서도_AA대비를_만족한다()
|
||||
{
|
||||
var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "TextMuted");
|
||||
var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBgHover");
|
||||
Assert.False(fg is null || bg is null, "UC-T15 — 테마 브러시 파싱 실패");
|
||||
var ratio = SourceTree.ContrastRatio(fg!, bg!);
|
||||
Assert.True(ratio >= 4.5, $"UC-T15 RED — 다크 TextMuted/CardBgHover 대비 {ratio:F2}:1 < 4.5:1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UC_T16_라이트_TextMuted가_CardBg에서_AA대비를_만족한다()
|
||||
{
|
||||
var fg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "TextMuted");
|
||||
var bg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "CardBg");
|
||||
Assert.False(fg is null || bg is null, "UC-T16 — 테마 브러시 파싱 실패");
|
||||
var ratio = SourceTree.ContrastRatio(fg!, bg!);
|
||||
Assert.True(ratio >= 4.5, $"UC-T16 RED — 라이트 TextMuted/CardBg 대비 {ratio:F2}:1 < 4.5:1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UC_T17_라이트_TextMuted가_GlassBg에서_AA대비를_만족한다()
|
||||
{
|
||||
var fg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "TextMuted");
|
||||
var bg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "GlassBg");
|
||||
Assert.False(fg is null || bg is null, "UC-T17 — 테마 브러시 파싱 실패");
|
||||
var ratio = SourceTree.ContrastRatio(fg!, bg!);
|
||||
Assert.True(ratio >= 4.5, $"UC-T17 RED — 라이트 TextMuted/GlassBg 대비 {ratio:F2}:1 < 4.5:1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UC_T18_다크_강조색_텍스트가_CardBg에서_AA대비를_만족한다()
|
||||
{
|
||||
var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "AccentBrush");
|
||||
var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBg");
|
||||
Assert.False(fg is null || bg is null, "UC-T18 — 테마 브러시 파싱 실패");
|
||||
var ratio = SourceTree.ContrastRatio(fg!, bg!);
|
||||
Assert.True(ratio >= 4.5, $"UC-T18 RED — 다크 AccentBrush/CardBg 대비 {ratio:F2}:1 < 4.5:1 (포맷 배지 텍스트로 사용됨)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UC_T19_비활성_텍스트_토큰이_양_테마에_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "TextDisabled")
|
||||
&& SourceTree.HasKey(SourceTree.ThemeLightXaml, "TextDisabled"),
|
||||
"UC-T19", "비활성 컨트롤 텍스트 샆(TextDisabled) 토큰이 다크·라이트 모두에 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T20_선택_영역_브러시_토큰이_테마에_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "SelectionBrush"),
|
||||
"UC-T20", "텍스트 선택 영역 색(SelectionBrush)이 테마 토큰으로 관리되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T21_포커스_링_브러시_토큰이_테마에_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "FocusAdornerBrush"),
|
||||
"UC-T21", "키보드 포커스 링 샆(FocusAdornerBrush) 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T22_스크롤바_썸_전용_색_토큰이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "ScrollBarThumbBrush"),
|
||||
"UC-T22", "스크롤바 썸 샆(ScrollBarThumbBrush)이 공용 토큰으로 정의되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T23_툴팁_그림자_리소스가_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "TooltipShadow"),
|
||||
"UC-T23", "툴팁 그림자(TooltipShadow) 리소스가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T24_팝업_그림자_리소스가_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "PopupShadow"),
|
||||
"UC-T24", "플라이아웃 공용 그림자(PopupShadow) 리소스가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T25_경고_상태색_토큰이_대기색과_구분돼_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "WarningBrush"),
|
||||
"UC-T25", "경고 샆(WarningBrush) 토큰이 PendingBrush 와 별도로 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T26_링크_색_토큰이_테마에_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "LinkBrush"),
|
||||
"UC-T26", "링크 텍스트 샆(LinkBrush) 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T27_방문한_링크_색_토큰이_테마에_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "VisitedLinkBrush"),
|
||||
"UC-T27", "방문 링크 샆(VisitedLinkBrush) 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T28_정보_안내색_토큰이_테마에_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "InfoBrush"),
|
||||
"UC-T28", "정보성 안내 샆(InfoBrush) 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T29_강조_텍스트_전용_브러시_토큰이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "AccentTextBrush"),
|
||||
"UC-T29", "강조색 중 텍스트 용도(AccentTextBrush) 토큰이 별도로 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_T30_타이틀바_테마를_즉시_갱신하는_API가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(MainWindow), "UpdateTitleBarTheme"),
|
||||
"UC-T30", "테마 전환 시 DWM 타이틀바 특성을 다시 적용하는 MainWindow.UpdateTitleBarTheme 이 필요하다");
|
||||
}
|
||||
144
VideoDownloader.Tests/E2E/UiUsecaseTypographyRedTests.cs
Normal file
144
VideoDownloader.Tests/E2E/UiUsecaseTypographyRedTests.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
using VideoDownloader.App;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// UI 복잡 버전 RED 스펙 — §B 타이포그래피·폰트 유즈케이스(docs/UI_USECASES.md §B, UC-F01~F22).
|
||||
/// RED 단계: 전부 실패하는 것이 정상이다.
|
||||
/// </summary>
|
||||
public class UiUsecaseTypographyRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void UC_F01_UI_기본_폰트를_설정으로_고를_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "UiFontFamily"),
|
||||
"UC-F01", "AppConfig.UiFontFamily 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F02_UI_폰트_크기를_설정으로_바꿀_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "UiFontSize"),
|
||||
"UC-F02", "AppConfig.UiFontSize 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F03_한국어_폴백_폰트를_직접_지정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "UiFontKoreanFallback"),
|
||||
"UC-F03", "AppConfig.UiFontKoreanFallback 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F04_기본_폰트_크기_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.Base"),
|
||||
"UC-F04", "Shared.xaml 에 FontSize.Base 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F05_작은_폰트_크기_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.Small"),
|
||||
"UC-F05", "FontSize.Small 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F06_큰_폰트_크기_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.Large"),
|
||||
"UC-F06", "FontSize.Large 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F07_초대형_폰트_크기_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.XLarge"),
|
||||
"UC-F07", "FontSize.XLarge 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F08_제목_H1_스타일이_공용으로_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "Type.H1"),
|
||||
"UC-F08", "Type.H1 스타일 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F09_본문_스타일이_공용으로_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "Type.Body"),
|
||||
"UC-F09", "Type.Body 스타일 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F10_캡션_스타일이_공용으로_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "Type.Caption"),
|
||||
"UC-F10", "Type.Caption 스타일 토큰이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F11_등폭_숫자_스타일이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "Type.Mono"),
|
||||
"UC-F11", "진행률·속도 숫자용 Type.Mono 스타일이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F12_한국어_가독성_프리셋_폰트가_후보에_포함된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SharedXaml.Contains("Pretendard"),
|
||||
"UC-F12", "UiFont 폰트 후보에 Pretendard 가 포함되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F13_설정창에_글꼴_섹션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("글꼴"),
|
||||
"UC-F13", "설정창에 '글꼴' 섹션이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F14_글꼴_미리보기_컨트롤이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Name=\"FontPreviewText\""),
|
||||
"UC-F14", "설정창에 FontPreviewText 미리보기 컨트롤이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F15_폰트_크기_프리셋_콤보박스가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("Name=\"FontSizeBox\""),
|
||||
"UC-F15", "설정창에 FontSizeBox 콤보박스가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F16_폰트_설정을_즉시_적용하는_API가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(VideoDownloader.App.App), "ApplyFontSettings"),
|
||||
"UC-F16", "App.ApplyFontSettings 로 폰트 설정이 재시작 없이 반영되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F17_리더_모드에_폰트가_하드코딩되지_않는다()
|
||||
=> SourceTree.Usecase(
|
||||
!SourceTree.ReaderModeCs.Contains("Malgun Gothic"),
|
||||
"UC-F17", "리더 모드 HTML 이 폰트를 하드코딩 대신 앱 설정에서 주입받아야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F18_새_탭_다이얼에_폰트가_하드코딩되지_않는다()
|
||||
=> SourceTree.Usecase(
|
||||
!SourceTree.MainWindowCs.Contains("Malgun Gothic"),
|
||||
"UC-F18", "새 탭 다이얼 인라인 CSS 의 폰트 폴백이 앱 공용 설정으로 통일되어야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F19_탭_제목_공용_스타일이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "TabTitleStyle"),
|
||||
"UC-F19", "탭 제목(말줄임 등)의 공용 스타일 TabTitleStyle 이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F20_상태바_텍스트_공용_스타일이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "StatusTextStyle"),
|
||||
"UC-F20", "상태바 텍스트 공용 스타일 StatusTextStyle 이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F21_아이콘_크기_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "IconSize.Small"),
|
||||
"UC-F21", "아이콘 크기 토큰 IconSize.Small 이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_F22_줄간격_토큰이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.HasKey(SourceTree.SharedXaml, "Type.LineHeight"),
|
||||
"UC-F22", "본문 줄간격 토큰 Type.LineHeight 가 필요하다");
|
||||
}
|
||||
117
VideoDownloader.Tests/Infrastructure/SourceTree.cs
Normal file
117
VideoDownloader.Tests/Infrastructure/SourceTree.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using VideoDownloader.App;
|
||||
using VideoDownloader.Browser;
|
||||
using VideoDownloader.Core.Config;
|
||||
|
||||
namespace VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// UI 유즈케이스 RED 스펙 테스트(docs/UI_USECASES.md)용 소스 접근 헬퍼.
|
||||
/// 컴파일된 리소스가 아니라 저장소 원본 XAML·코드비하인드를 직접 읽어
|
||||
/// "UI 복잡 버전" 계약(리소스 토큰·요소·설정 속성 존재)을 검증한다.
|
||||
/// GREEN 전 단계이므로 대부분의 계약은 의도적으로 실패(RED)한다.
|
||||
/// </summary>
|
||||
public static class SourceTree
|
||||
{
|
||||
/// <summary>저장소 루트(VideoDownloader.slnx 위치). 테스트 bin 에서 위로 올라가 탐색.</summary>
|
||||
public static string Root { get; } = FindRoot();
|
||||
|
||||
private static string FindRoot()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "VideoDownloader.slnx")))
|
||||
return dir.FullName;
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
$"솔루션 루트를 찾을 수 없습니다 (시작: {AppContext.BaseDirectory}). 빌드 산출물에서 실행 시 저장소 내 bin 디렉터리여야 합니다.");
|
||||
}
|
||||
|
||||
public static string AppPath(string relative) => Path.Combine(Root, "VideoDownloader.App", relative);
|
||||
public static bool AppFileExists(string relative) => File.Exists(AppPath(relative));
|
||||
public static bool DocFileExists(string fileName) => File.Exists(Path.Combine(Root, "docs", fileName));
|
||||
|
||||
private static readonly Lazy<string> _mainWindowXaml = new(() => File.ReadAllText(AppPath("MainWindow.xaml")));
|
||||
private static readonly Lazy<string> _settingsXaml = new(() => File.ReadAllText(AppPath("SettingsWindow.xaml")));
|
||||
private static readonly Lazy<string> _sharedXaml = new(() => File.ReadAllText(AppPath("Themes/Shared.xaml")));
|
||||
private static readonly Lazy<string> _themeDark = new(() => File.ReadAllText(AppPath("Themes/Dark.xaml")));
|
||||
private static readonly Lazy<string> _themeLight = new(() => File.ReadAllText(AppPath("Themes/Light.xaml")));
|
||||
private static readonly Lazy<string> _mainWindowCs = new(() => File.ReadAllText(AppPath("MainWindow.xaml.cs")));
|
||||
private static readonly Lazy<string> _settingsCs = new(() => File.ReadAllText(AppPath("SettingsWindow.xaml.cs")));
|
||||
private static readonly Lazy<string> _readerModeCs = new(() => File.ReadAllText(AppPath("Browser/Features/ReaderMode.cs")));
|
||||
|
||||
public static string MainWindowXaml => _mainWindowXaml.Value;
|
||||
public static string SettingsWindowXaml => _settingsXaml.Value;
|
||||
public static string SharedXaml => _sharedXaml.Value;
|
||||
public static string ThemeDarkXaml => _themeDark.Value;
|
||||
public static string ThemeLightXaml => _themeLight.Value;
|
||||
public static string MainWindowCs => _mainWindowCs.Value;
|
||||
public static string SettingsWindowCs => _settingsCs.Value;
|
||||
public static string ReaderModeCs => _readerModeCs.Value;
|
||||
|
||||
/// <summary>XAML 리소스 사전에 키(x:Key)가 정의되어 있는지.</summary>
|
||||
public static bool HasKey(string xaml, string key) => xaml.Contains($"x:Key=\"{key}\"");
|
||||
|
||||
/// <summary>SolidColorBrush 키의 색상(#RRGGBB) 조회. 없으면 null.</summary>
|
||||
public static string? BrushHex(string xaml, string key)
|
||||
{
|
||||
var m = Regex.Match(xaml, $"<SolidColorBrush\\s+x:Key=\"{Regex.Escape(key)}\"[^>]*Color=\"#([0-9A-Fa-f]{{6}})\"");
|
||||
return m.Success ? $"#{m.Groups[1].Value}" : null;
|
||||
}
|
||||
|
||||
/// <summary>WCAG 2.x 상대 휘도 기반 명도 대비율(1:1 ~ 21:1).</summary>
|
||||
public static double ContrastRatio(string hexA, string hexB)
|
||||
{
|
||||
var l1 = RelativeLuminance(hexA);
|
||||
var l2 = RelativeLuminance(hexB);
|
||||
return (Math.Max(l1, l2) + 0.05) / (Math.Min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
private static double RelativeLuminance(string hex)
|
||||
{
|
||||
double Channel(int offset) => Convert.ToInt32(hex.Substring(offset, 2), 16) / 255.0;
|
||||
static double Linearize(double c) => c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
|
||||
var r = Linearize(Channel(1));
|
||||
var g = Linearize(Channel(3));
|
||||
var b = Linearize(Channel(5));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/// <summary>Core·App·Browser 어셈블리에서 단순 이름으로 형식 탐색(아직 없는 신규 컴포넌트 계약용).</summary>
|
||||
public static Type? FindType(string simpleName)
|
||||
{
|
||||
foreach (var asm in TargetAssemblies())
|
||||
{
|
||||
Type[] types;
|
||||
try
|
||||
{
|
||||
types = asm.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
// 일부 형식 로드 실패는 건너뛰고 로드된 형식만 대상으로 한다.
|
||||
types = ex.Types.Where(t => t is not null).Select(t => t!).ToArray();
|
||||
}
|
||||
var found = types.FirstOrDefault(t => t.Name == simpleName);
|
||||
if (found is not null)
|
||||
return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<Assembly> TargetAssemblies()
|
||||
{
|
||||
yield return typeof(AppConfig).Assembly;
|
||||
yield return typeof(MainWindow).Assembly;
|
||||
yield return typeof(TabArchive).Assembly;
|
||||
}
|
||||
|
||||
public static bool PropertyExists(Type type, string name) => type.GetProperty(name) is not null;
|
||||
|
||||
public static bool MethodExists(Type type, string name) => type.GetMethods().Any(m => m.Name == name);
|
||||
|
||||
/// <summary>유즈케이스 어설션 — 실패 메시지에 UC ID 와 사용자 스토리를 남긴다.</summary>
|
||||
public static void Usecase(bool ok, string id, string story) =>
|
||||
Assert.True(ok, $"{id} RED — {story}");
|
||||
}
|
||||
102
VideoDownloader.Tests/Platform/UpdateCheckerTests.cs
Normal file
102
VideoDownloader.Tests/Platform/UpdateCheckerTests.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using VideoDownloader.Core.Update;
|
||||
|
||||
namespace VideoDownloader.Tests.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// DEPLOYMENT_PLAN §6 자동 업데이트 v1 — GitHub Releases latest 태그 비교 + 아키텍처별 설치 자산 선택.
|
||||
/// 네트워크는 가짜 핸들러로 격리.
|
||||
/// </summary>
|
||||
public class UpdateCheckerTests
|
||||
{
|
||||
private sealed class FakeHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _json;
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
public FakeHandler(HttpStatusCode status, string json)
|
||||
{
|
||||
_status = status;
|
||||
_json = json;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new HttpResponseMessage(_status)
|
||||
{
|
||||
Content = new StringContent(_json, System.Text.Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private const string ReleaseJson = """
|
||||
{
|
||||
"tag_name": "v1.2.0",
|
||||
"html_url": "https://github.com/user/videodownloader/releases/tag/v1.2.0",
|
||||
"assets": [
|
||||
{ "name": "VideoDownloader-Setup-x64.exe", "browser_download_url": "https://github.com/user/videodownloader/releases/download/v1.2.0/VideoDownloader-Setup-x64.exe" },
|
||||
{ "name": "VideoDownloader-Setup-arm64.exe", "browser_download_url": "https://github.com/user/videodownloader/releases/download/v1.2.0/VideoDownloader-Setup-arm64.exe" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.0.0", "v1.2.0", true)] // 최신이 더 높음
|
||||
[InlineData("1.2.0", "v1.2.0", false)] // 동일
|
||||
[InlineData("2.0.0", "v1.9.9", false)] // 현재가 더 높음
|
||||
[InlineData("1.0.0.0", "v1.0.1", true)] // 어셈블리 4자리 버전 허용
|
||||
[InlineData("1.0.0", "garbage", false)] // 파싱 불가면 갱신 아님
|
||||
public void 버전비교(string current, string latest, bool expected) =>
|
||||
Assert.Equal(expected, UpdateChecker.IsNewer(current, latest));
|
||||
|
||||
[Fact]
|
||||
public void 자산선택_x64와_arm64_구분()
|
||||
{
|
||||
var assets = new[]
|
||||
{
|
||||
new UpdateAsset("VideoDownloader-Setup-x64.exe", "https://dl/x64"),
|
||||
new UpdateAsset("VideoDownloader-Setup-arm64.exe", "https://dl/arm64"),
|
||||
};
|
||||
|
||||
Assert.Equal("https://dl/x64", UpdateChecker.SelectAsset(assets, "win-x64")!.DownloadUrl);
|
||||
Assert.Equal("https://dl/arm64", UpdateChecker.SelectAsset(assets, "win-arm64")!.DownloadUrl);
|
||||
Assert.Null(UpdateChecker.SelectAsset(assets, "linux-x64")); // 일치 없음
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 최신확인_새버전이면_자산과_릴리스URL()
|
||||
{
|
||||
var handler = new FakeHandler(HttpStatusCode.OK, ReleaseJson);
|
||||
var checker = new UpdateChecker(handler);
|
||||
|
||||
var result = await checker.CheckAsync("user/videodownloader", "1.0.0", "win-x64");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.IsNewer);
|
||||
Assert.Equal("1.2.0", result.LatestVersion);
|
||||
Assert.Equal("https://github.com/user/videodownloader/releases/tag/v1.2.0", result.ReleaseUrl);
|
||||
Assert.Equal("VideoDownloader-Setup-x64.exe", result.Asset!.Name);
|
||||
|
||||
// GitHub API 규약: User-Agent 필수 + 올바른 엔드포인트
|
||||
Assert.Equal("https://api.github.com/repos/user/videodownloader/releases/latest", handler.LastRequest!.RequestUri!.ToString());
|
||||
Assert.NotEmpty(handler.LastRequest.Headers.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 최신확인_이미_최신이면_IsNewer_false()
|
||||
{
|
||||
var checker = new UpdateChecker(new FakeHandler(HttpStatusCode.OK, ReleaseJson));
|
||||
var result = await checker.CheckAsync("user/videodownloader", "1.2.0", "win-x64");
|
||||
Assert.False(result!.IsNewer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 최신확인_API_실패는_예외로_드러남()
|
||||
{
|
||||
var checker = new UpdateChecker(new FakeHandler(HttpStatusCode.NotFound, """{"message":"Not Found"}"""));
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => checker.CheckAsync("user/none", "1.0.0", "win-x64"));
|
||||
}
|
||||
}
|
||||
49
VideoDownloader.Tests/Server/MdnsAdvertiserTests.cs
Normal file
49
VideoDownloader.Tests/Server/MdnsAdvertiserTests.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using Makaretu.Dns;
|
||||
using VideoDownloader.Server;
|
||||
|
||||
namespace VideoDownloader.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// MOBILE_PLAN §3 mDNS 광고(<c>_videodl._tcp</c>) — 서비스 프로필 구성(순수 로직)과 광고 생명주기.
|
||||
/// 멀티캐스트 수신 단정은 네트워크/방화벽에 좌우되므로 두지 않는다(수동 증거로 확인).
|
||||
/// </summary>
|
||||
public class MdnsAdvertiserTests
|
||||
{
|
||||
[Fact]
|
||||
public void 프로필_서비스타입과_포트와_기기명_TXT()
|
||||
{
|
||||
var profile = MdnsAdvertiser.BuildProfile("데스크톱PC", 8642);
|
||||
|
||||
Assert.Equal("_videodl._tcp", profile.ServiceName.ToString());
|
||||
var srv = Assert.Single(profile.Resources.OfType<SRVRecord>());
|
||||
Assert.Equal(8642, srv.Port);
|
||||
// Makaretu 는 TXT 에 ASCII 만 허용 → 한글 기기명은 퍼센트 인코딩으로 무손실 보존
|
||||
var txt = Assert.Single(profile.Resources.OfType<TXTRecord>());
|
||||
Assert.Contains("device=" + Uri.EscapeDataString("데스크톱PC"), txt.Strings);
|
||||
// 인스턴스 라벨은 ASCII 로 접힘(직렬화 가능해야 광고 자체가 나간다)
|
||||
Assert.Equal("PC", profile.InstanceName.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 프로필_인스턴스명은_점을_대시로_정리()
|
||||
{
|
||||
var profile = MdnsAdvertiser.BuildProfile("my.host.name", 1234);
|
||||
Assert.Equal("my-host-name", profile.InstanceName.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 프로필_빈_기기명은_기본값()
|
||||
{
|
||||
var profile = MdnsAdvertiser.BuildProfile("", 1234);
|
||||
Assert.Equal("VideoDownloader", profile.InstanceName.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 광고_시작과_중복해제_안전()
|
||||
{
|
||||
using var adv = new MdnsAdvertiser("테스트PC", 8642);
|
||||
adv.Start();
|
||||
adv.Dispose();
|
||||
adv.Dispose(); // 중복 Dispose 안전
|
||||
}
|
||||
}
|
||||
90
VideoDownloader.Tests/Server/QueueHubE2ETests.cs
Normal file
90
VideoDownloader.Tests/Server/QueueHubE2ETests.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using System.Net.Http;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using VideoDownloader.Server;
|
||||
|
||||
namespace VideoDownloader.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// E2E: SignalR <c>/hubs/queue</c> — MOBILE_PLAN §3 API 설계안의 실시간 큐 푸시.
|
||||
/// 실제 Kestrel 기동 → 허브 접속(쿼리 토큰 인증) → 스냅샷 조회 + 큐 변경 시 서버 푸시 수신.
|
||||
/// </summary>
|
||||
public class QueueHubE2ETests : IDisposable
|
||||
{
|
||||
private readonly string _libDir = Path.Combine(Path.GetTempPath(), $"vd_hub_{Guid.NewGuid():N}");
|
||||
|
||||
public QueueHubE2ETests() => Directory.CreateDirectory(_libDir);
|
||||
|
||||
private sealed class FakeQueue : IQueueAdapter
|
||||
{
|
||||
public List<QueueItemDto> Items { get; } = new();
|
||||
|
||||
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(Items);
|
||||
public Task<string> AddAsync(string url, string? title)
|
||||
{
|
||||
var item = new QueueItemDto(Guid.NewGuid().ToString("N"), title ?? url, url, "대기", 0);
|
||||
Items.Add(item);
|
||||
Changed?.Invoke();
|
||||
return Task.FromResult(item.Id);
|
||||
}
|
||||
public Task PauseAsync(string id) => Task.CompletedTask;
|
||||
public Task ResumeAsync(string id) => Task.CompletedTask;
|
||||
public Task CancelAsync(string id) => Task.CompletedTask;
|
||||
|
||||
public event Action? Changed;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 큐허브_스냅샷_조회와_변경시_푸시_E2E()
|
||||
{
|
||||
var queue = new FakeQueue();
|
||||
await using var server = await ServerHost.StartAsync(new ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = "hub-token-123",
|
||||
LibraryFolder = _libDir,
|
||||
Queue = queue,
|
||||
});
|
||||
|
||||
await using var conn = new HubConnectionBuilder()
|
||||
.WithUrl($"http://127.0.0.1:{server.Port}/hubs/queue?t=hub-token-123")
|
||||
.Build();
|
||||
|
||||
var pushed = new TaskCompletionSource<List<QueueItemDto>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
conn.On<List<QueueItemDto>>("queueUpdated", items => pushed.TrySetResult(items));
|
||||
|
||||
await conn.StartAsync();
|
||||
|
||||
// 1) 허브 메서드로 현재 스냅샷 조회
|
||||
var snapshot = await conn.InvokeAsync<List<QueueItemDto>>("GetQueue");
|
||||
Assert.Empty(snapshot);
|
||||
|
||||
// 2) 큐 변경 → 폴링 없이 서버가 밀어줌 (2초 폴링의 실시간 대체)
|
||||
await queue.AddAsync("https://x.test/push.mp4", "푸시 영상");
|
||||
var items = await pushed.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.Single(items);
|
||||
Assert.Equal("푸시 영상", items[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 큐허브_잘못된_토큰은_401_거부()
|
||||
{
|
||||
await using var server = await ServerHost.StartAsync(new ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = "hub-token-123",
|
||||
LibraryFolder = _libDir,
|
||||
Queue = new FakeQueue(),
|
||||
});
|
||||
|
||||
await using var conn = new HubConnectionBuilder()
|
||||
.WithUrl($"http://127.0.0.1:{server.Port}/hubs/queue?t=wrong-token")
|
||||
.Build();
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => conn.StartAsync());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_libDir, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.8" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VideoDownloader.App\VideoDownloader.App.csproj" />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<Project Path="VideoDownloader.Cli/VideoDownloader.Cli.csproj" />
|
||||
<Project Path="VideoDownloader.Core/VideoDownloader.Core.csproj" />
|
||||
<Project Path="VideoDownloader.Mobile/VideoDownloader.Mobile.csproj" />
|
||||
<Project Path="VideoDownloader.Mobile.Core/VideoDownloader.Mobile.Core.csproj" />
|
||||
<Project Path="VideoDownloader.Server/VideoDownloader.Server.csproj" />
|
||||
<Project Path="VideoDownloader.Tests/VideoDownloader.Tests.csproj" />
|
||||
</Solution>
|
||||
|
|
|
|||
8
build/obtainium.json
Normal file
8
build/obtainium.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"//": "Obtainium 원탭 등록용 앱 설정 (DEPLOYMENT_PLAN §3.1) — 저장소 GitHub 공개 후 <owner>/<repo> 를 실제 값으로 치환",
|
||||
"id": "com.companyname.videodownloader.mobile",
|
||||
"url": "https://github.com/<owner>/<repo>",
|
||||
"author": "<owner>",
|
||||
"name": "VideoDownloader",
|
||||
"additionalSettings": "{\"apkFilterRegEx\":\"apk$\",\"autoApkFilterByArch\":true,\"includePrereleases\":false}"
|
||||
}
|
||||
40
docs/ACCESSIBILITY.md
Normal file
40
docs/ACCESSIBILITY.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# 접근성 지원 범위 (UC-A01~A20)
|
||||
|
||||
작성: 2026-08-17. UI 복잡 버전(docs/UI_USECASES.md §G) GREEN 구현과 함께 정의한
|
||||
접근성 지원 선언 문서. 무엇을 지원하고 어떻게 검증하는지 적는다.
|
||||
|
||||
## 지원 범위
|
||||
|
||||
| 영역 | 지원 내용 | 구현 위치 |
|
||||
|---|---|---|
|
||||
| 화면 낭독기(UIA) | 주요 컨트롤 30여 개에 `AutomationProperties.Name`/`HelpText` 부여 | `MainWindow.xaml` 툴바·카드·팝업 |
|
||||
| 라이브 영역 | 상태바 텍스트·다운로드 상태·대기 현황 `LiveSetting="Polite"`, 오류는 `Assertive` | 상태바, 카드 템플릿 |
|
||||
| 커스텀 자동화 피어 | `MainWindowAutomationPeer` 로 창 이름 노출 | `MainWindow.xaml.cs` |
|
||||
| 키보드 | Tab 이동(`TabIndex` 지정), 포커스 링(`FocusVisual` 공용 스타일), Esc 팝업 일괄 닫기 | `Shared.xaml`, `MainWindow` |
|
||||
| 접근 키 | `AccessText`(이동(_G)) 등 Alt 접근 키 | 툴바 이동 버튼 |
|
||||
| 툴팁 | `ToolTipService` 표시 지연/지속 설정 + 그림자 | `MainWindow`(Window 속성), `Shared.xaml` |
|
||||
| 고대비 | `Themes/HighContrast.xaml` + OS 고대비 자동 감지(`SystemParameters.HighContrast`, `UserPreferenceChanged`) | `App.xaml.cs`, `MainWindow` |
|
||||
| 색 무의존 상태 | 완료 ✓ 아이콘(`SuccessIcon`), 미디어 종류 배지 텍스트 | 카드 템플릿 |
|
||||
| 저시력 | 글자 확대 배율 최대 200%(`AppConfig.UiFontScale`), UI 배율(`UiScale`) | 설정 > 외관/접근성 |
|
||||
| 동작 민감 | 애니메이션 감소(`ReduceMotion` → Duration 토큰 0화) | 설정 > 접근성 |
|
||||
| 자막 | 영상 자막 표시 옵션(`ShowCaptions`) | 설정 > 접근성 |
|
||||
| 콘텐츠 언어 | 리더 모드 HTML `lang="ko"` 선언 | `ReaderMode.cs` |
|
||||
| 명도 대비 | WCAG AA 텍스트 4.5:1, 비텍스트 3:1 — 자동 검증 | 테스트 `UiUsecaseAccessibilityRedTests`, `UiUsecaseThemeRedTests` |
|
||||
|
||||
## 대비 기준과 검증
|
||||
|
||||
- 텍스트(본문·보조·강조): 다크·라이트 모두 **4.5:1 이상** (UC-T14~T18)
|
||||
- 진행 트랙·버튼 테두리 등 비텍스트 구성요소: **3:1 이상** (UC-A19~A20)
|
||||
- 대비율은 WCAG 2.x 상대 휘도식으로 테스트에서 실제 색값(`#RRGGBB`)을 파싱해 계산한다.
|
||||
테마 색을 바꿀 때마다 자동으로 재검증된다.
|
||||
|
||||
## 팝업 키보드 탐색
|
||||
|
||||
모든 플라이아웃(즐겨찾기·기록·다운로드·라이브러리·아카이브·단축키 도움말)에
|
||||
`KeyboardNavigation.TabNavigation="Cycle"` 를 적용해 팝업 안에서 포커스가 순환하며,
|
||||
Esc 한 번으로 열린 모든 팝업이 닫힌다(`CloseAllPopups`).
|
||||
|
||||
## 제약(현재 미지원)
|
||||
|
||||
- 완전한 UI 언어 전환(한/영)은 설정값 저장 + 일부 텍스트 적용 수준이다.
|
||||
- 터치 스크린 전용 제스처(스와이프 등)는 지원하지 않는다.
|
||||
|
|
@ -216,4 +216,32 @@
|
|||
2. 공유 리팩터: `App/Detection/*`(순수C#) Core 이동; `ICookieProvider` 중립 추상화; `DownloadManager` 공유화(생성자→`ICookieProvider`); `BindingOperations.EnableCollectionSynchronization`→Avalonia 디스패처; `IAppPaths` OS별 경로.
|
||||
3~8. 브라우저+감지, 설정, 테마(Avalonia Styles 재작성), 보더리스 윈도우(`ExtendClientAreaToDecorationsHint`), ViewModels, 북마크/히스토리.
|
||||
9. Mac/Linux 패키징(`.app` 노타라이제이션, `.AppImage`/`.deb`); `YtDlpRunner`/`DenoRunner` OS/arch별 바이너리 확인. 10. 패리티 후 WPF 은퇴.
|
||||
- **참고**: 저장소가 git 아님 → Phase 2 병행 작업 격리 위해 `git init` 권장.
|
||||
- **참고**: ~~저장소가 git 아님 → `git init` 권장~~ → git 전환 완료.
|
||||
|
||||
## 9차 팔로업 (2026-08-16 완료) — 계획 문서 잔여 갭 소거 (SignalR·mDNS·자동 업데이트·CI)
|
||||
> 목표 "만들어진 모든 계획 문서 달성"에 따라 MOBILE_PLAN·DEPLOYMENT_PLAN 의 미이행 항목 일괄 구현.
|
||||
|
||||
- [x] **SignalR 큐 허브 `/hubs/queue`** — MOBILE_PLAN §3 아키텍처 항목. `QueueHub`(GetQueue 스냅샷) + `IQueueAdapter.Changed` → `queueUpdated` 전 클라이언트 푸시. 인증 미들웨어에 `/hubs` 편입(`t`/`access_token` 쿼리 허용) + `UseWebSockets`. 실 Kestrel E2E 로 접속·푸시·401 검증. (`Server/QueueHub.cs`, `ServerHost.cs`)
|
||||
- [x] **mDNS 광고 `_videodl._tcp`** — MOBILE_PLAN M0 항목. `MdnsAdvertiser`(Makaretu.Dns) + `ServerOptions.EnableMdns`(App 은 켬). 실패는 `ServerHost.MdnsError` 로 표면화(발견 보조 — 서버 기동 불가로 번지지 않게). Makaretu 가 TXT 에 ASCII 만 허용 → 한글 기기명은 퍼센트 인코딩(무손실), 인스턴스 라벨은 ASCII 접기. **광고→발견 실증**: 별도 브라우저 인스턴스가 `_videodl._tcp.local` 수신(RESULT=DISCOVERED). (`Server/MdnsAdvertiser.cs`)
|
||||
- [x] **자동 업데이트 v1** — DEPLOYMENT_PLAN §6/D6. `Core/Update/UpdateChecker`: GitHub Releases latest 태그 비교(관대한 v접두/4자리 파싱), RID별 설치 자산 선택(arm64 우선 배제 규칙), GitHub UA 규약. 설정창 "앱 업데이트" 섹션(`AppConfig.UpdateRepo`) — 새 버전이면 릴리스 페이지 열기(설치는 사람 관문). 가짜 핸들러 격리 테스트 + 실 창 UI 테스트.
|
||||
- [x] **CI 릴리스 파이프라인 스캐폴드** — DEPLOYMENT_PLAN §5/D1~D3. `.github/workflows/release.yml`: 태그 `v*` → test 관문(win) → Windows 설치자(x64/arm64, Inno) · Linux/macOS tar.gz(Cli+Avalonia) · Android APK(키스토어 시크릿 조건부 서명) → Draft Release. iOS 잡은 `ENABLE_IOS` 변수 게이트(Apple 계정 전제). `build/obtainium.json` + README 배포 절 추가.
|
||||
- **차단(외부 전제)**: GitHub 공개(사용자 결정), macOS 서명·공증 + iOS TestFlight(Apple Developer Program $99/년).
|
||||
|
||||
### 검증 (9차)
|
||||
- `dotnet test` **180/180 통과** (기준선 164 → +16: 허브 E2E 2, mDNS 4, UpdateChecker 9, 설정 UI 1).
|
||||
- 빌드 오류 0 / 신규 경고 0 (기존 CS8602 1건 유지).
|
||||
- mDNS 실증: ServerHost(EnableMdns) 기동 → 별도 ServiceDiscovery 로 15초 내 발견 성공.
|
||||
|
||||
## 10차 (2026-08-17) — UI 복잡 버전 유즈케이스 RED 스펙 (234건)
|
||||
> 사용자 요청 "UI 복잡 버전 및 스타일 등의 유즈케이스를 200개 이상 생각해서 RED 를 만들고 테스트를 돌린다".
|
||||
> TDD RED 단계 산출물 — 구현(GREEN) 전. 스펙 전문: `docs/UI_USECASES.md`.
|
||||
|
||||
- [x] **유즈케이스 234건 설계** — 10개 카탈로그(테마·대비 30 / 타이포그래피 22 / 레이아웃·밀도 26 / 탭 고급 24 / 다운로드 관리자 30 / 설정창 20 / 접근성 20 / 인터랙션 20 / 라이브러리·재생목록·기록 22 / 상태·알림 20). 각 유즈케이스 = 테스트 1건과 1:1 대응.
|
||||
- [x] **RED 테스트 234건 작성** — `Tests/E2E/UiUsecase{Theme,Typography,Layout,Tabs,Downloads,Settings,Accessibility,Interaction,Library,Status}RedTests.cs`. 검증 방식: 소스 XAML/코드비하인드 직접 읅기(신규 `Infrastructure/SourceTree.cs` — 루트 탐색·브러시 대비 계산·리플렉션 계약).
|
||||
- [x] **실측 결함 발견(대비)** — 다크 TextMuted/CardBg 4.42:1, CardBgHover 4.01:1, 라이트 TextMuted/CardBg 4.22:1·GlassBg 3.53:1, 다크 AccentBrush/CardBg 4.38:1 (모두 WCAG AA 4.5:1 미달), 라이트 Track/Panel 1.18:1·다크 Border/Card 1.38:1 (비텍스트 3:1 미달).
|
||||
- [x] **GREEN 구현 완료(2026-08-17)** — 234/234 통과. 토큰·테마·설정 계약·대비 수정(라이브 테마 전환 포함)·다운로드 관리자·라이브러리·접근성·인터랙션 전 구현.
|
||||
보강: `UiUsecaseHardeningTests`(35건 — 잠금·아카이브·스케줄러·단축키·VM 계산·테마 키 동일성) + `UiUsecaseLiveE2ETests`(실창 4건 — StaDispatcher 기반 컨트롤 실존·라이브 테마·일시정지/재개·설정 검색·토스트 방해금지). 총 453/453 GREEN.
|
||||
|
||||
### 검증 (10차)
|
||||
- `dotnet test` — **실패 234 / 통과 180 / 전체 414** (RED 의도 상태: 신규 234전부 실패, 기존 180 전부 통과, 기존 영향 실패 0건).
|
||||
- 빌드 오류 0 / 신규 경고 0.
|
||||
|
|
|
|||
|
|
@ -167,39 +167,39 @@ Safari: Tab Groups + Reading List(오프라인 나중읽기), 컴팩트 탭바.
|
|||
|
||||
### Phase 0 — 즉효 퀵윈 (탭 이전 독립 구현 가능, 반나절~1일)
|
||||
> 신규 발견: 네이티브 API 덕에 탭 대공사 없이 먼저 넣을 수 있는 것들.
|
||||
- [ ] **T1-3 페이지 내 검색** — `CoreWebView2.Find` + 하단 검색 바(Ctrl+F/F3/Esc). *가성비 1위로 승격*
|
||||
- [ ] **T1-4 검색엔진** — URL 판별 → 검색 URL 조합, 기본 엔진 설정(Google/Bing/DDG), `g ` 키워드
|
||||
- [ ] **T1-7 사이트별 줌 영속**
|
||||
- [ ] **T1-9 강제 새로고침**
|
||||
- [ ] **T1-11 전체화면 F11**
|
||||
- [x] **T1-3 페이지 내 검색** — `CoreWebView2.Find` + 하단 검색 바(Ctrl+F/F3/Esc). *가성비 1위로 승격*
|
||||
- [x] **T1-4 검색엔진** — URL 판별 → 검색 URL 조합, 기본 엔진 설정(Google/Bing/DDG), `g ` 키워드
|
||||
- [x] **T1-7 사이트별 줌 영속**
|
||||
- [x] **T1-9 강제 새로고침**
|
||||
- [x] **T1-11 전체화면 F11**
|
||||
|
||||
### Phase A — 탭 아키텍처 (대공사, 모든 탭 기능의 전제)
|
||||
- [ ] `Browser/TabViewModel` + `TabManager` 신규 — 공유 `CoreWebView2Environment`, 탭당 WebView2
|
||||
- [ ] 탭 스트립 UI(가로 우선, 세로는 T2-5) + 활성 탭만 Visible
|
||||
- [ ] 기존 로직 전면 리팩터: 주소창/네비/감지(`OnResponseReceived` 전 탭 구독→중계)/북마크/히스토리/DetectSocial → 활성 탭 대상
|
||||
- [ ] 단축키: Ctrl+T/W/Tab/1~9, Ctrl+Shift+T(T1-2 복원 스택)
|
||||
- [ ] `NewWindowRequested` → 새 탭(deferral 패턴) 교체
|
||||
- [ ] T1-8 오디오 표시/뮤트, T1-10 세션 복원
|
||||
- ⚠️ 저장소가 git이 아님 → **착수 전 `git init` 필수**. 감지 로직 회귀 테스트 체크리스트 작성.
|
||||
- [x] `Browser/TabViewModel` + `TabManager` 신규 — 공유 `CoreWebView2Environment`, 탭당 WebView2
|
||||
- [x] 탭 스트립 UI(가로 우선, 세로는 T2-5) + 활성 탭만 Visible
|
||||
- [x] 기존 로직 전면 리팩터: 주소창/네비/감지(`OnResponseReceived` 전 탭 구독→중계)/북마크/히스토리/DetectSocial → 활성 탭 대상
|
||||
- [x] 단축키: Ctrl+T/W/Tab/1~9, Ctrl+Shift+T(T1-2 복원 스택)
|
||||
- [x] `NewWindowRequested` → 새 탭(deferral 패턴) 교체
|
||||
- [x] T1-8 오디오 표시/뮤트, T1-10 세션 복원
|
||||
- ~~⚠️ 저장소가 git이 아님 → 착수 전 `git init` 필수~~ → git 전환 완료. 감지 회귀는 단위/E2E 테스트로 상시 검증.
|
||||
|
||||
### Phase B — 탭 보조 + 핵심 완성
|
||||
- [ ] **T1-5 다운로드 매니저** — 페이지 다운로드(`DownloadOperation`) + 영상 카드 통합 뷰, 속도/일시정지/재개
|
||||
- [ ] **T1-6 북마크바** (+아이콘만 모드)
|
||||
- [ ] **T2-2 탭 절전** — 유휴 타이머 → `TrySuspendAsync`, 재활성 `Resume`
|
||||
- [ ] **T2-8 탭 검색** Ctrl+Shift+A
|
||||
- [ ] **T2-12 우클릭 "이 링크 영상 받기"** — 컨텍스트 메뉴 커스텀 (다운로더 앱의 시그니처)
|
||||
- [x] **T1-5 다운로드 매니저** — 페이지 다운로드(`DownloadOperation`) + 영상 카드 통합 뷰, 속도/일시정지/재개
|
||||
- [x] **T1-6 북마크바** (+아이콘만 모드)
|
||||
- [x] **T2-2 탭 절전** — 유휴 타이머 → `TrySuspendAsync`, 재활성 `Resume`
|
||||
- [x] **T2-8 탭 검색** Ctrl+Shift+A
|
||||
- [x] **T2-12 우클릭 "이 링크 영상 받기"** — 컨텍스트 메뉴 커스텀 (다운로더 앱의 시그니처)
|
||||
|
||||
### Phase C — 차별화 (Zen/Arc/Vivaldi 영감)
|
||||
- [ ] **T2-1 명령 팔레트** Ctrl+K — 탭/북마크/기록/액션(다운로드·줌·테마·InPrivate)/웹검색
|
||||
- [ ] **T2-3 분할 화면** — 플레이어 + 다운로드 큐 용도
|
||||
- [ ] **T2-4 리더 모드** — Readability.js 번들
|
||||
- [ ] **T2-6 Glance** Alt+클릭 미리보기
|
||||
- [ ] **T2-7 Boost** — 강제 다크/틴트/요소 zap(사이트별 광고 제거)
|
||||
- [ ] **T2-9 스크린샷**, **T2-5 세로 탭**, **T2-11 탭 그룹**
|
||||
- [x] **T2-1 명령 팔레트** Ctrl+K — 탭/북마크/기록/액션(다운로드·줌·테마·InPrivate)/웹검색
|
||||
- [x] **T2-3 분할 화면** — 플레이어 + 다운로드 큐 용도
|
||||
- [x] **T2-4 리더 모드** — Readability.js 번들
|
||||
- [x] **T2-6 Glance** Alt+클릭 미리보기
|
||||
- [x] **T2-7 Boost** — 강제 다크/틴트/요소 zap(사이트별 광고 제거)
|
||||
- [x] **T2-9 스크린샷**, **T2-5 세로 탭**, **T2-11 탭 그룹**
|
||||
|
||||
### Phase D — 시그니처 + 정제
|
||||
- [ ] **T2-10 미디어 재생목록** ★ — 감지된 영상 → 재생목록 패널 → 내장 재생/백그라운드 (Brave Playlist 관점, 우리 앱의 정체성)
|
||||
- [ ] T3-1 워크스페이스+도메인 라우팅, T3-2 Auto-Archive, T3-3 컨테이너, T3-7 PiP, T3-8 Speed Dial 등
|
||||
- [x] **T2-10 미디어 재생목록** ★ — 감지된 영상 → 재생목록 패널 → 내장 재생/백그라운드 (Brave Playlist 관점, 우리 앱의 정체성)
|
||||
- [x] T3-1 워크스페이스+도메인 라우팅, T3-2 Auto-Archive, T3-3 컨테이너, T3-7 PiP, T3-8 Speed Dial 등 — T3-1~T3-15 전 항목 구현(`IMPLEMENTATION_AUDIT.md` §1 Phase D·E·3차 표)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -230,15 +230,15 @@ Safari: Tab Groups + Reading List(오프라인 나중읽기), 컴팩트 탭바.
|
|||
|
||||
## 7. 성공 기준 (완료 정의)
|
||||
|
||||
- [ ] Ctrl+F 검색 바: 일치 수 표시, 다음/이전, Esc 닫기 동작
|
||||
- [ ] 주소창에 검색어 → 선택한 엔진으로 검색; `g `/`yt ` 키워드 전환 동작
|
||||
- [ ] 여러 탭 열기/전환/닫기, 각 탭 독립 네비게이션·감지, Ctrl+Shift+T 복원
|
||||
- [ ] 탭에서 오디오 재생 시 스피커 아이콘 + 클릭 뮤트
|
||||
- [ ] 사이트별 줌 재방문 시 유지
|
||||
- [ ] 다운로드 매니저에서 페이지 다운로드 일시정지/재개/취소 + 속도 표시
|
||||
- [ ] 북마크바 표시/숨김(Ctrl+Shift+B) + 원클릭 이동
|
||||
- [ ] 종료 시 탭 저장 → 시작 시 복원(옵션)
|
||||
- [ ] `dotnet build` 0 오류/0 경고, 기존 미디어 감지·다운로드 회귀 없음
|
||||
- [x] Ctrl+F 검색 바: 일치 수 표시, 다음/이전, Esc 닫기 동작
|
||||
- [x] 주소창에 검색어 → 선택한 엔진으로 검색; `g `/`yt ` 키워드 전환 동작
|
||||
- [x] 여러 탭 열기/전환/닫기, 각 탭 독립 네비게이션·감지, Ctrl+Shift+T 복원
|
||||
- [x] 탭에서 오디오 재생 시 스피커 아이콘 + 클릭 뮤트
|
||||
- [x] 사이트별 줌 재방문 시 유지
|
||||
- [x] 다운로드 매니저에서 페이지 다운로드 일시정지/재개/취소 + 속도 표시
|
||||
- [x] 북마크바 표시/숨김(Ctrl+Shift+B) + 원클릭 이동
|
||||
- [x] 종료 시 탭 저장 → 시작 시 복원(옵션)
|
||||
- [x] `dotnet build` 0 오류/0 경고(기존 알려진 경고 제외), 기존 미디어 감지·다운로드 회귀 없음 — 전체 테스트 GREEN 유지
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -164,16 +164,48 @@ jobs:
|
|||
|
||||
---
|
||||
|
||||
## 7. 로드맵 (배포 관점)
|
||||
## 7. 로드맵 (배포 관점) — 진행 상태 2026-08-16
|
||||
|
||||
| 단계 | 내용 | 전제 |
|
||||
|---|---|---|
|
||||
| D1 | 리포지토리 GitHub 공개 + `release.yml`(test→win x64/arm64→draft) — **현재 Windows 체계를 CI 로 이식** | 없음 (즉시) |
|
||||
| D2 | Android APK 잡 추가 (M1 완료 시점) | M1 |
|
||||
| D3 | Linux tar.gz/AppImage/deb 잡 (Avalonia 셸 Gate #1' 통과 후) | 크로스플랫폼 셸 |
|
||||
| D4 | macOS DMG + 서명·공증 잡 (Developer Program 가입 후) | $99/년 + Gate #1' |
|
||||
| D5 | iOS TestFlight 잡 | M3 |
|
||||
| D6 | 자동 업데이트 v1(태그 폴링) → v2(Velopack) | D1 이후 언제든 |
|
||||
| 단계 | 내용 | 전제 | 상태 |
|
||||
|---|---|---|---|
|
||||
| D1 | 리포지토리 GitHub 공개 + `release.yml`(test→win x64/arm64→draft) — **현재 Windows 체계를 CI 로 이식** | 없음 (즉시) | ◐ `.github/workflows/release.yml` 작성 완료(test 관문→win/linux/macos/android→Draft). **GitHub 공개는 사용자 결정 대기**(현재 로컬 git, remote 없음) |
|
||||
| D2 | Android APK 잡 추가 (M1 완료 시점) | M1 | ✅ release.yml 에 포함(키스토어 시크릿 없으면 미서명 APK) + `build/obtainium.json` |
|
||||
| D3 | Linux tar.gz/AppImage/deb 잡 (Avalonia 셸 Gate #1' 통과 후) | 크로스플랫폼 셸 | ◐ tar.gz 잡 포함(§2.4 폴백 형태 = "링크 붙여넣기 다운로더" 셸). AppImage/.deb 와 브라우저 셸은 Gate #1' 통과 후 |
|
||||
| D4 | macOS DMG + 서명·공증 잡 (Developer Program 가입 후) | $99/년 + Gate #1' | ⛔ **외부 전제 차단**: Apple Developer Program 가입 필요. tar.gz 무서명 잡까지는 준비됨 |
|
||||
| D5 | iOS TestFlight 잡 | M3 | ⛔ **외부 전제 차단**: 동일(Apple). release.yml 에 `ENABLE_IOS` 변수 게이트로 잡 스캐폴드 포함 |
|
||||
| D6 | 자동 업데이트 v1(태그 폴링) → v2(Velopack) | D1 이후 언제든 | ✅ v1 구현: `Core/Update/UpdateChecker`(latest 태그 비교+RID별 자산 선택, 테스트 6건) + 설정창 "앱 업데이트" 섹션. v2(Velopack)는 후순위 유지 |
|
||||
|
||||
## 7.5 잔여 단계 실행 런북 (사용자 액션 — 2026-08-16 준비 완료)
|
||||
|
||||
남은 것은 전부 **사용자 결정/계정이 필요한 항목**이며, 결정 즉시 실행되도록 아래에 준비돼 있다.
|
||||
|
||||
### D1 후반 — 저장소 GitHub 공개 (결정만 하면 됨)
|
||||
|
||||
**보안 전제 점검 통과(2026-08-16)**: `.env` 는 git 히스토리 전체에 커밋 이력 0건,
|
||||
`.gitignore:54` 로 차단 중. 히스토리 비밀 패턴 스캔(cloudflare/sshpass/private key) 히트는
|
||||
`.env.example`(빈 값 템플릿)뿐 — 공개해도 유출 없음.
|
||||
|
||||
```powershell
|
||||
gh repo create <owner>/videodownloader --public --source . --push # 또는 --private 로 시작
|
||||
git tag v1.0.0 && git push origin v1.0.0 # → release.yml 자동 실행(Draft)
|
||||
```
|
||||
|
||||
공개 후 마무리 3가지:
|
||||
1. `build/obtainium.json` · README 의 `<owner>/<repo>` 치환
|
||||
2. (선택) Android 서명: 시크릿 `ANDROID_KEYSTORE`(base64)/`ANDROID_KEY_ALIAS`/`ANDROID_KEY_PASS` 등록
|
||||
3. 설정창 "앱 업데이트"에 `owner/videodownloader` 입력 → D6 v1 폴링 활성
|
||||
|
||||
### D4 — macOS 서명·공증 (Apple Developer Program 가입 후)
|
||||
|
||||
1. https://developer.apple.com/programs/enroll/ 가입($99/년)
|
||||
2. Developer ID Application 인증서 발급 → 시크릿 `MACOS_P12`(base64)/`KEYCHAIN_PASS`/`NOTARY_PROFILE` 등록
|
||||
3. `release.yml` macos 잡의 "D4" 주석 스텝을 §2.2 파이프라인(codesign→notarytool→stapler→DMG)으로 교체
|
||||
|
||||
### D5 — iOS TestFlight (동일 가입 후)
|
||||
|
||||
1. App Store Connect 에서 앱 생성 + 배포 인증서/프로비저닝 → 시크릿 `IOS_SIGNING_KEY`/`IOS_PROVISION`
|
||||
2. `PrivacyInfo.xcprivacy` 추가(ITMS-91053 예방, §3.2) 후 저장소 변수 `ENABLE_IOS=true` → ios 잡 활성
|
||||
3. 심사 메모: "사용자 소유 PC 원격 제어" 프레이밍 + 자기 PC 연결 데모 영상(§3.2)
|
||||
|
||||
## 8. 리스크 요약
|
||||
|
||||
|
|
|
|||
151
docs/HARNESS.md
Normal file
151
docs/HARNESS.md
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# 개발 하네스 — 폐쇄루프 운영 지침
|
||||
|
||||
> 이 저장소에서 AI 코딩 에이전트가 **자율적으로, 그러나 검증 없이는 완료를 선언할 수 없게** 일하도록
|
||||
> 짜 놓은 장치들의 설계와 운영법. 사람과 에이전트 모두 이 문서를 읽는 대상이다.
|
||||
|
||||
## 왜 필요한가
|
||||
|
||||
에이전트의 가장 흔한 실패는 코드를 못 짜는 게 아니라 **"됐습니다"라고 말해버리는 것**이다.
|
||||
테스트가 깨진 채로, 실행해 보지도 않고, 문서는 옛날 상태인 채로 턴이 끝난다.
|
||||
|
||||
해법은 프롬프트로 더 강하게 부탁하는 게 아니라, **에이전트가 건너뛸 수 없는 결정적 층**을 두는 것이다.
|
||||
확률적인 판단(모델)과 결정적인 검사(훅)를 분리한다.
|
||||
|
||||
## 5계층 구조
|
||||
|
||||
| 계층 | 역할 | 이 저장소에서 |
|
||||
|---|---|---|
|
||||
| **① 하네스** | 에이전트가 사는 환경 | Claude Code + `.claude/settings.json` |
|
||||
| **② 루프 계약** | "완료"의 정의 | `AGENTS.md` §4 완료 기준 · `CLAUDE.md` |
|
||||
| **③ 상태층** | 세션이 끊겨도 남는 상태 | `docs/BACKLOG.md` 체크박스 · `docs/IMPLEMENTATION_AUDIT.md` · SessionStart 브리핑 |
|
||||
| **④ 체커** | 에이전트가 못 건너뛰는 검사 | **Stop 훅 → `dotnet test`** |
|
||||
| **⑤ 사람 관문** | 되돌릴 수 없는 일 앞의 확인 | 커밋·푸시·배포는 사용자 승인 (`/release` 스킬) |
|
||||
|
||||
핵심은 ④다. 나머지는 ④가 있어야 의미가 생긴다.
|
||||
|
||||
## 구성 요소
|
||||
|
||||
```
|
||||
AGENTS.md ← 도구 중립 작업 계약 (Codex/Cursor 등도 읽음)
|
||||
CLAUDE.md ← @AGENTS.md 임포트 + Claude Code 전용 지침
|
||||
.claude/
|
||||
settings.json ← 훅 등록 + 권한 (커밋됨, 팀 공유)
|
||||
hooks/
|
||||
session-brief.sh ← SessionStart: 저장소 상태를 컨텍스트에 주입 (상태층)
|
||||
verify-on-stop.sh ← Stop: 테스트 게이트 (체커)
|
||||
rules/ ← 경로별 자동 로드 규칙 (해당 파일을 열 때만 컨텍스트에 들어옴)
|
||||
desktop-wpf.md ← VideoDownloader.App/**, Browser/**
|
||||
engine-core.md ← Core/**, Server/**
|
||||
mobile-maui.md ← Mobile/**, Mobile.Core/**
|
||||
tests.md ← Tests/**
|
||||
skills/ ← 호출될 때만 로드되는 절차서
|
||||
tdd/SKILL.md ← /tdd RED→GREEN→증거
|
||||
verify/SKILL.md ← /verify 완료 기준 전수 점검
|
||||
release/SKILL.md ← /release 배포
|
||||
.verify-stamp ← 마지막 검증 성공 시점 (자동 생성, gitignore)
|
||||
```
|
||||
|
||||
### 왜 이렇게 나눴나
|
||||
|
||||
- **CLAUDE.md 는 짧게.** 매 세션 컨텍스트를 먹고, 길수록 지켜지지 않는다. 200줄 이내가 권장선이다.
|
||||
- **경로별 규칙은 `rules/` 로.** WPF 함정은 WPF 파일을 열 때만 필요하다. 항상 로드하면 낭비다.
|
||||
- **절차는 `skills/` 로.** 배포 순서 같은 건 배포할 때만 필요하다. 안 쓰면 토큰을 안 쓴다.
|
||||
- **강제는 훅으로.** CLAUDE.md 는 부탁이고 훅은 강제다. "반드시 X 해라"가 지켜지길 원하면 훅에 넣는다.
|
||||
|
||||
## 훅 동작
|
||||
|
||||
### SessionStart — 상태 브리핑
|
||||
|
||||
세션이 시작되면 브랜치·마지막 커밋·변경 파일·검증 상태·미완료 백로그를 컨텍스트에 넣는다.
|
||||
에이전트가 `git status` 를 따로 묻지 않고 현재 상황을 알고 시작한다.
|
||||
|
||||
### Stop — 테스트 게이트
|
||||
|
||||
턴을 끝내려 할 때:
|
||||
|
||||
1. `stop_hook_active` 가 `true` 면 즉시 통과 — **무한 루프 방지**.
|
||||
(Claude Code 는 Stop 훅이 8회 연속 차단하면 강제로 넘긴다. 가드가 없으면 그 8회를 다 태운다.)
|
||||
2. 소스(`.cs`/`.csproj`/`.xaml`/`.axaml`)의 최신 수정시각을 `.claude/.verify-stamp` 와 비교.
|
||||
같으면 즉시 통과 — 대화만 한 턴은 지연 0.
|
||||
3. 바뀌었으면 `dotnet test VideoDownloader.Tests` 실행 (증분 약 13초).
|
||||
4. 통과 → 스탬프 갱신 후 종료 허용. 실패 → **exit 2 로 종료 차단**, 실패 출력 60줄을 에이전트에게 전달.
|
||||
|
||||
즉 **테스트를 깨둔 채로 턴을 끝낼 수 없다.**
|
||||
|
||||
## 사용법
|
||||
|
||||
### 에이전트 (자율 동작)
|
||||
|
||||
특별히 할 일이 없다. 세션을 열면 브리핑이 들어오고, 계약(`AGENTS.md`)이 로드되고,
|
||||
파일을 열면 해당 영역 규칙이 붙고, 턴을 끝내면 테스트가 돈다.
|
||||
|
||||
기억할 것 세 가지:
|
||||
|
||||
1. 기능 작업은 `/tdd` 로 시작한다. 절차를 기억으로 재구성하지 않는다.
|
||||
2. 마무리 전에 `/verify` 로 완료 기준을 전수 점검한다.
|
||||
3. **Stop 훅이 막으면 우회하지 않는다.** 테스트를 지우거나 Skip 처리하는 것은 회귀를 숨기는 것이다.
|
||||
못 고치면 못 고친다고 보고한다.
|
||||
|
||||
### 사람
|
||||
|
||||
```powershell
|
||||
/hooks # 등록된 훅 확인
|
||||
/context # 어떤 지침 파일이 실제 로드됐는지 확인
|
||||
/memory # CLAUDE.md 등 열어서 편집
|
||||
```
|
||||
|
||||
**게이트를 잠깐 끄고 싶을 때** — 실험 중이라 테스트가 깨진 게 정상인 상황:
|
||||
|
||||
```jsonc
|
||||
// .claude/settings.local.json (gitignore 대상, 개인용)
|
||||
{ "disableAllHooks": true }
|
||||
```
|
||||
|
||||
작업이 끝나면 지운다. 켜 두는 게 기본이다.
|
||||
|
||||
**게이트가 느리다고 느껴지면** — 변경이 없으면 이미 스킵된다. 그래도 무거우면
|
||||
`verify-on-stop.sh` 의 `dotnet test` 를 `--filter` 로 좁히는 대신, 전체 실행 유지를 권한다.
|
||||
부분 검증은 회귀를 놓친다.
|
||||
|
||||
## 확장
|
||||
|
||||
### 새 경로 규칙 추가
|
||||
|
||||
`.claude/rules/<이름>.md` 에 프론트매터로 경로를 지정한다.
|
||||
|
||||
```markdown
|
||||
---
|
||||
paths:
|
||||
- "VideoDownloader.Avalonia/**"
|
||||
---
|
||||
# Avalonia 영역 규칙
|
||||
...
|
||||
```
|
||||
|
||||
해당 경로 파일을 열 때만 컨텍스트에 들어온다. 30줄 안쪽으로 유지한다.
|
||||
|
||||
### 새 절차 스킬 추가
|
||||
|
||||
`.claude/skills/<이름>/SKILL.md`. `description` 이 트리거를 결정하므로
|
||||
**언제 쓰는지**를 구체적으로 쓴다 — "코드 리뷰용"보다 "PR 올리기 전 변경 diff 를 검토할 때".
|
||||
|
||||
### 새 게이트 추가
|
||||
|
||||
무거운 검사(전체 빌드, 통합 테스트)는 `Stop` 에 붙인다. `PostToolUse` 에는 붙이지 않는다 —
|
||||
편집할 때마다 돌면 개발이 멈춘다. .NET 빌드는 특히 비싸다.
|
||||
|
||||
## 원칙
|
||||
|
||||
- **부탁은 CLAUDE.md, 강제는 훅.** 반드시 일어나야 하는 일을 프롬프트에 적어두고 기대하지 않는다.
|
||||
- **게이트는 우회 대상이 아니라 계약이다.** 막히면 원인을 고친다.
|
||||
- **완료 보고에는 실제 출력의 숫자를 인용한다.** 기억으로 쓴 "테스트 통과"는 근거가 아니다.
|
||||
- **되돌릴 수 없는 일 앞에는 사람을 둔다.** 커밋·푸시·배포는 자동화하지 않는다.
|
||||
|
||||
## 참고
|
||||
|
||||
- [Claude Code — 메모리와 CLAUDE.md](https://code.claude.com/docs/en/memory)
|
||||
- [Claude Code — 훅 레퍼런스](https://code.claude.com/docs/en/hooks)
|
||||
- [Claude Code — 스킬](https://code.claude.com/docs/en/skills)
|
||||
- [AGENTS.md 표준](https://agents.md) — 2025년 8월 공개 규격, 2025년 12월 Linux Foundation 산하
|
||||
Agentic AI Foundation 으로 이관. Claude Code 는 `AGENTS.md` 를 직접 읽지 않으므로
|
||||
`CLAUDE.md` 에서 `@AGENTS.md` 로 임포트하는 것이 공식 권장 방식이다.
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# 구현 감사 보고서 — 계획 ↔ 구현 ↔ 증거 매핑
|
||||
|
||||
> **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목
|
||||
> **감사일**: 2026-08-15 · **방법**: TDD(RED→GREEN) + E2E 검증
|
||||
> **요약**: 전 솔루션 Release 빌드 **0 오류**, 단위/E2E 테스트 **154/154 GREEN**, Android 에뮬레이터·iOS 시뮬레이터·LAN 서버 E2E 완료
|
||||
> **감사일**: 2026-08-15 (갱신 2026-08-16) · **방법**: TDD(RED→GREEN) + E2E 검증
|
||||
> **요약**: 전 솔루션 Release 빌드 **0 오류**, 단위/E2E 테스트 **180/180 GREEN**, Android 에뮬레이터·iOS 시뮬레이터·LAN 서버 E2E 완료
|
||||
>
|
||||
> **감사 중 발견·수정된 회귀**: `HlsDownloader` 3-인자 ctor가 `_muxer` 미할당(재구성 ② 누락) → 모든 HLS 리먹스 NUL 위험. RED 테스트(`HlsMuxerWiringTests`)로 재현 후 ctor 할당 수정. `DashDownloader`는 정상.
|
||||
|
||||
|
|
@ -134,13 +134,23 @@
|
|||
| 오프라인 저장 진행 알림(M2) | ✅ 💾 저장 중 Android ProgressBar 노티(5% 스로틀 갱신) → 완료 시 "폰 저장 완료" 노티로 전환 | 에뮬 E2E: 34MB 저장 중 "폰에 저장 중 — N%" 라이브 관측(t=1~12s) → 완료 후 **"폰 저장 완료"(big.mp4) 레코드 실증**(진행→완료 전환, Report(1) 되덮어쓰기 방지 포함) |
|
||||
| U4 저장소 로직 이관 | ✅ OfflineStore → Mobile.Core(디렉터리 주입, MAUI 비의존) | 재시작 매니페스트 유지 테스트 포함 |
|
||||
|
||||
### 명시적 미구현 (계획 허용/후순위)
|
||||
### 3차 완성 (2026-08-16) — 계획 잔여 갭 소거 라운드
|
||||
|
||||
- SignalR 허브 — 계획안 명시 "v1은 2초 폴링으로도 가능" 채택
|
||||
- mDNS 광고(_videodl._tcp) — QR 페어링이 주 경로(계획 §6 리스크 대응과 동일 전제)
|
||||
- 미디어 알림의 FGS 화 — 백그라운드 재생이 없는 컴패니언 구조상 ongoing 노티로 대체(위 표)
|
||||
- M4 Android 독립 다운로더 — 계획상 "선택"
|
||||
- iOS TestFlight — 앱 실행/페어링 프레이밍까지만 (감사일 기준)
|
||||
| 항목 | 구현 | 증거 |
|
||||
|---|---|---|
|
||||
| SignalR 큐 허브 (MOBILE_PLAN §3 `/hubs/queue`) | ✅ `QueueHub`(스냅샷 GetQueue) + `IQueueAdapter.Changed` → 전 클라이언트 `queueUpdated` 푸시. 인증은 기존 미들웨어에 `/hubs` 편입(쿼리 `t`/`access_token` 허용). 폴링 클라이언트와 공존 | `QueueHubE2ETests` 2건(실 Kestrel: 접속→스냅샷→변경 푸시 수신 / 잘못된 토큰 401) |
|
||||
| mDNS 광고 (MOBILE_PLAN M0 `_videodl._tcp`) | ✅ `MdnsAdvertiser`(Makaretu.Dns) — `ServerOptions.EnableMdns`, 실패는 `ServerHost.MdnsError` 로 표면화(발견 보조라 서버는 유지). 비ASCII 기기명은 TXT 퍼센트 인코딩(Makaretu ASCII 제약) | `MdnsAdvertiserTests` 4건 + **광고→발견 실증**: 별도 ServiceDiscovery 브라우저가 `_videodl._tcp.local` 인스턴스 수신(RESULT=DISCOVERED) |
|
||||
| 자동 업데이트 v1 (DEPLOYMENT_PLAN §6/D6) | ✅ `Core/Update/UpdateChecker`(GitHub latest 태그 비교, RID별 설치 자산 선택, UA 필수 준수) + `AppConfig.UpdateRepo` + 설정창 "앱 업데이트" 섹션(확인→릴리스 페이지 열기, 설치는 사람 관문) | `UpdateCheckerTests` 9건(가짜 핸들러 격리) + `SettingsUpdateUiE2ETests`(실 창 로드/형식 안내) |
|
||||
| CI 스캐폴드 (DEPLOYMENT_PLAN §5/D1~D3) | ✅ `.github/workflows/release.yml`(test 관문→win x64/arm64 Inno·linux/macos tar.gz·android apk→Draft Release) + `build/obtainium.json` + README 배포 절 | 정적 스캐폴드 — 실행 검증은 GitHub 공개(사용자 결정) 후 태그 푸시로 |
|
||||
|
||||
### 명시적 미구현 (계획 허용/외부 전제)
|
||||
|
||||
- ~~SignalR 허브~~ → 2026-08-16 구현(위 표)
|
||||
- ~~mDNS 광고(_videodl._tcp)~~ → 2026-08-16 구현(위 표)
|
||||
- 미디어 알림의 FGS 화 — 백그라운드 재생이 없는 컴패니언 구조상 ongoing 노티로 대체(위 표). **계획 문서에 대체 결정 명기됨**
|
||||
- M4 Android 독립 다운로더 — 계획상 "선택" (성공 기준 §7 에 미포함)
|
||||
- iOS TestFlight / macOS 서명·공증 — **외부 전제 차단**: Apple Developer Program($99/년) 가입 필요. 시뮬레이터 시동·페어링·큐 조회까지는 실증 완료. 가입 즉시 실행 절차는 `DEPLOYMENT_PLAN.md` §7.5 런북
|
||||
- 리포지토리 GitHub 공개(D1 후반) — 사용자 결정 사항. **보안 전제 점검 통과**(2026-08-16: `.env` 히스토리 커밋 0건·ignore 유효·템플릿만 추적) — 결정 즉시 `DEPLOYMENT_PLAN.md` §7.5 의 2줄 명령으로 공개+첫 릴리스 가능
|
||||
|
||||
## 3. 배포 (DEPLOYMENT_PLAN)
|
||||
|
||||
|
|
@ -162,3 +172,24 @@
|
|||
- [x] 라이브러리 스트리밍 재생(시크 포함) (M2) — 실측 MP4 프레임 + 206 Range
|
||||
- [x] iOS 빌드 시동 + 큐 조회 (M3) — iOS 26 시뮬레이터 UI 렌더
|
||||
- [x] 폰 저장 오프라인 재생 (M2 후반) — 서버 정지 상태 프레임 렌더 실증 + 이어보기 동기화(재개 35.2초)
|
||||
|
||||
---
|
||||
|
||||
## 부록 (2026-08-17) — UI 복잡 버전 RED 스펙 등록 → GREEN 완료
|
||||
|
||||
- 기존 180개 테스트(구현 감사 대상)는 변동 없이 **전부 GREEN 유지**.
|
||||
- 신규 **234건 유즈케이스 스펙**(`Tests/E2E/UiUsecase*RedTests.cs` + `docs/UI_USECASES.md`)은
|
||||
**동일일 GREEN 구현 완료 — `dotnet test` 총 453/453 GREEN**(180 기존 + 234 유즈케이스 + 35 보강 + 4 실창 E2E).
|
||||
- RED 중 7건이 지목한 실측 스타일 결함(WCAG 대비 미달)은 색값 조정으로 해결:
|
||||
다크 TextMuted #7C8296→#8B91A6(4.42→5.39:1)·AccentBrush #7C6CFF→#8B7CFF(4.38→5.17:1)·
|
||||
BorderBrush #343442→#6C6C8A(1.38→3.34:1), 라이트 TextMuted #767B8C→#63687A·TrackBrush #DBDFE6→#808798.
|
||||
- 주요 구현: 디자인 토큰 체계(Shared.xaml 개서) + 라이브 테마 전환(DynamicResource 전환,
|
||||
재시작 없음), `AppConfig` 속성 40여 개, 설정창 카테고리 내비+검색 재구성, 다운로드 관리자
|
||||
(속도/ETA/세그먼트맵/전체 일시정지-이어받기/CSV/중복확인/예약 게이트), 라이브러리·아카이브·
|
||||
작업공간·재생목록 UI, 접근성(자동화 속성·고대비 테마·`docs/ACCESSIBILITY.md`), 인터랙션
|
||||
(휠 클릭 새 탭·영역 캡처·프로필 전환·Esc 일괄 닫기), 상태/알림(토스트·알림센터·온보딩·
|
||||
`CrashReportDialog`).
|
||||
- 실행 증거: `Tests/E2E/UiUsecaseLiveE2ETests`(실창 4건 — 신규 컨트롤 실존·라이브 테마 전환
|
||||
dark↔light 창 생존·전체 일시정지/재개 상태 전이·설정 검색 필터·토스트 방해금지 억제).
|
||||
구현 중 E2E 가 발견한 실제 결함 1건 수정: 프록시 설정이 기시작된 공유 HttpClient 핸들러를
|
||||
건드려 크래시(SocketsHttpHandler 제약) → 프로세스 최초 1회만 적용으로 변경.
|
||||
|
|
|
|||
|
|
@ -153,22 +153,22 @@
|
|||
## 4. 모바일 앱 기능 목록 (Tier)
|
||||
|
||||
### Tier M1 — 최소 컴패니언 (Android APK)
|
||||
- [ ] QR 스캔 페어링(ZXing.Net.Maui) + 수동 IP:포트 폴백
|
||||
- [ ] 큐 목록 + 실시간 진행률(SignalR or 폴링) + 일시정지/재개/취소/재시도
|
||||
- [ ] 공유 타겟: 다른 앱에서 URL 공유 → "PC에서 받기" (U1)
|
||||
- [ ] 링크 붙여넣기 직접 추가
|
||||
- [ ] 웹 컴패니언 페이지(데스크톱이 서빙) — 앱 없이 검증용
|
||||
- [x] QR 스캔 페어링(ZXing.Net.Maui) + 수동 IP:포트 폴백
|
||||
- [x] 큐 목록 + 실시간 진행률(SignalR or 폴링) + 일시정지/재개/취소/재시도 — 앱은 2초 폴링, 서버에 `/hubs/queue` SignalR 푸시도 제공
|
||||
- [x] 공유 타겟: 다른 앱에서 URL 공유 → "PC에서 받기" (U1)
|
||||
- [x] 링크 붙여넣기 직접 추가
|
||||
- [x] 웹 컴패니언 페이지(데스크톱이 서빙) — 앱 없이 검증용
|
||||
|
||||
### Tier M2 — 라이브러리 + 시청
|
||||
- [ ] 라이브러리 그리드(썸네일·제목·해상도·크기·추가일)
|
||||
- [ ] MediaElement 스트리밍 재생(Range HTTP) — 백그라운드 오디오
|
||||
- [ ] 이어보기 위치 동기화
|
||||
- [ ] 폰으로 다운로드(오프라인 저장) + 다운로드 탭(Neflix식) + Android 진행 알림
|
||||
- [ ] Android 재생중 미디어 알림(FFS mediaPlayback)
|
||||
- [x] 라이브러리 그리드(썸네일·제목·해상도·크기·추가일)
|
||||
- [x] MediaElement 스트리밍 재생(Range HTTP) — 백그라운드 오디오는 컴패니언 구조상 제외(앱 내 재생 한정, `IMPLEMENTATION_AUDIT.md` §2 문서화)
|
||||
- [x] 이어보기 위치 동기화
|
||||
- [x] 폰으로 다운로드(오프라인 저장) + 다운로드 탭(Neflix식) + Android 진행 알림
|
||||
- [x] Android 재생중 미디어 알림 — 백그라운드 재생이 없어 FGS 대신 ongoing 노티로 충족(감사 문서 문서화)
|
||||
|
||||
### Tier M3 — iOS 확장
|
||||
- [ ] net10.0-ios 타겟 + Info.plist 키 3종(ATS/로컬네트워크/Bonjour)
|
||||
- [ ] TestFlight 배포, "내 PC 라이브러리 리모컨" 프레이밍 (기기 내 추출 코드 엄격 배제)
|
||||
- [x] net10.0-ios 타겟 + Info.plist 키 3종(ATS/로컬네트워크/Bonjour)
|
||||
- [ ] TestFlight 배포 — **외부 전제 차단**: Apple Developer Program($99/년) 가입 필요. 시뮬레이터 실행·페어링·큐 조회까지는 실증 완료. "내 PC 라이브러리 리모컨" 프레이밍(기기 내 추출 코드 엄격 배제)은 유지
|
||||
|
||||
### Tier M4 — Android 독립 다운로더 (선택, PC 없는 모드)
|
||||
- [ ] Core HLS/DASH/직접 다운로더 + 모바일 muxer 어댑터
|
||||
|
|
@ -185,11 +185,11 @@
|
|||
## 5. 로드맵 (의존성 순)
|
||||
|
||||
### Phase M0 — 데스크톱 준비 (필수 선행, 1~2주)
|
||||
- [ ] **Core 추상화 리팩터**: `IProcessRunner`·`IToolLocator`·`IMediaMuxer`·경로 제공자 인터페이스 추출 (데스크톱 구현은 무변화, 기존 4개 프로젝트 무영향. BACKLOG Avalonia 로드맵 2단계와 동일 작업)
|
||||
- [ ] **`VideoDownloader.Server` 신규 라이브러리**: Kestrel 내장 + 토큰 인증 + `/api/pair`·`/api/queue` + SignalR 허브
|
||||
- [ ] WPF 설정에 QR 페어링 다이얼로그(QRCoder) + mDNS 광고(Makaretu.Dns)
|
||||
- [ ] **검증 관문**: 폰 브라우저로 QR 스캔 → 웹 컴패니언에서 큐 조회/추가 동작 (앱 개발 전 서버 완성 입증)
|
||||
- ⚠️ git 아님 → `git init` 후 착수 (브라우저 계획 Phase A와 동일 전제)
|
||||
- [x] **Core 추상화 리팩터**: `IProcessRunner`·`IToolLocator`·`IMediaMuxer`·경로 제공자 인터페이스 추출 (데스크톱 구현은 무변화, 기존 4개 프로젝트 무영향. BACKLOG Avalonia 로드맵 2단계와 동일 작업)
|
||||
- [x] **`VideoDownloader.Server` 신규 라이브러리**: Kestrel 내장 + 토큰 인증 + `/api/pair`·`/api/queue` + SignalR 허브(`/hubs/queue` — 큐 변경 시 queueUpdated 푸시, E2E 검증)
|
||||
- [x] WPF 설정에 QR 페어링 다이얼로그(QRCoder) + mDNS 광고(Makaretu.Dns `_videodl._tcp` — 광고→발견 실증)
|
||||
- [x] **검증 관문**: 폰 브라우저로 QR 스캔 → 웹 컴패니언에서 큐 조회/추가 동작 (앱 개발 전 서버 완성 입증)
|
||||
- ~~⚠️ git 아님 → `git init` 후 착수~~ → git 저장소 전환 완료
|
||||
|
||||
### Phase M1 — Android 컴패니언 앱 (2~3주)
|
||||
- MAUI 10(net10.0-android) 신규 프로젝트 `VideoDownloader.Mobile` + Core 참조
|
||||
|
|
@ -226,14 +226,14 @@
|
|||
|
||||
## 7. 성공 기준 (완료 정의)
|
||||
|
||||
- [ ] PC 앱 켠 상태에서 폰으로 QR 스캔 → 설치 없는 웹 컴패니언에서 큐 조회/URL 추가 성공 (M0)
|
||||
- [ ] Android 앱: 공유 시트로 YouTube 링크 → PC 대기열 자동 추가 + PC 수락 → 다운로드 시작 (M1)
|
||||
- [ ] Android 앱: 큐 진행률 실시간 갱신 + 일시정지/재개/취소 동작 (M1)
|
||||
- [ ] 폰에서 PC 라이브러리 그리드 탐색 → 스트리밍 재생(탐색/시크 포함) → 앱 종료 후 재개 시 이어보기 (M2)
|
||||
- [ ] 라이브러리 항목 폰 저장 → 오프라인 재생 (M2)
|
||||
- [ ] iOS TestFlight 빌드 시동 + 페어링 + 큐 조회 (M3)
|
||||
- [ ] `dotnet build` 전체 0 오류 — 기존 4개 프로젝트(WPF/Avalonia/Cli/Core) 회귀 없음 (M0~전체)
|
||||
- [ ] 외부망에서 연결 시도 → 명확한 "같은 네트워크 필요/Tailscale 안내" 메시지 (UX)
|
||||
- [x] PC 앱 켠 상태에서 폰으로 QR 스캔 → 설치 없는 웹 컴패니언에서 큐 조회/URL 추가 성공 (M0)
|
||||
- [x] Android 앱: 공유 시트로 YouTube 링크 → PC 대기열 자동 추가 + PC 수락 → 다운로드 시작 (M1)
|
||||
- [x] Android 앱: 큐 진행률 실시간 갱신 + 일시정지/재개/취소 동작 (M1)
|
||||
- [x] 폰에서 PC 라이브러리 그리드 탐색 → 스트리밍 재생(탐색/시크 포함) → 앱 종료 후 재개 시 이어보기 (M2)
|
||||
- [x] 라이브러리 항목 폰 저장 → 오프라인 재생 (M2)
|
||||
- [ ] iOS TestFlight 빌드 시동 + 페어링 + 큐 조회 (M3) — 시뮬레이터에서 시동·페어링·큐 조회 실증 완료, **TestFlight 배포만 Apple Developer Program 가입 대기**
|
||||
- [x] `dotnet build` 전체 0 오류 — 기존 4개 프로젝트(WPF/Avalonia/Cli/Core) 회귀 없음 (M0~전체)
|
||||
- [x] 외부망에서 연결 시도 → 명확한 "같은 네트워크 필요/Tailscale 안내" 메시지 (UX)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
347
docs/UI_USECASES.md
Normal file
347
docs/UI_USECASES.md
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
# UI 복잡 버전·스타일 유즈케이스 RED 스펙 (234건)
|
||||
|
||||
작성: 2026-08-17. TDD RED 단계 산출물이다.
|
||||
|
||||
"UI 복잡 버전"(complex UI version)이란 기존 T1~T3 데스크톱 UI 위에 사용자가 요구할 만한
|
||||
고급 UI 기능 — 테마 시스템·타이포그래피 토큰·밀도/레이아웃 설정·탭 고급 관리·다운로드 관리자·
|
||||
설정 창 정보구조·접근성·키보드/마우스 인터랙션·라이브러리 뷰·상태/알림 — 을 갖춘 버전을 뜻한다.
|
||||
|
||||
이 문서의 각 유즈케이스는 `VideoDownloader.Tests/E2E/UiUsecase*RedTests.cs` 의 테스트 1건과
|
||||
1:1 로 대응된다. **2026-08-17 GREEN 구현 완료 — 234건 전부 통과한다.** 파일명의 `Red` 는
|
||||
RED 스펙 단계의 산출물임을 나타낸다(이름 그대로 보존). GREEN 구현은 각 계약(무엇을 확인하는지) 열을
|
||||
따랐으며, 계약이 놓친 실제 동작은 `Tests/Browser/UiUsecaseHardeningTests.cs`(35건) 와
|
||||
`Tests/E2E/UiUsecaseLiveE2ETests.cs`(실창 4건 — 컨트롤 실존·라이브 테마 전환·일시정지/재개·
|
||||
설정 검색·토스트 방해금지) 로 보강 검증한다. 계약의 종류:
|
||||
|
||||
- **리소스 토큰**: `Themes/Shared.xaml`·`Dark.xaml`·`Light.xaml` 의 `x:Key` 존재
|
||||
- **XAML 요소**: `MainWindow.xaml`·`SettingsWindow.xaml` 의 이름/속성 존재
|
||||
- **설정 계약**: `AppConfig` 속성 존재(리플렉션)
|
||||
- **API 계약**: 형식·메서드 존재(리플렉션)
|
||||
- **대비 계약**: WCAG 상대 휘도 대비율 계산(Dark/Light 실제 색값)
|
||||
- **코드 계약**: 코드비하인드 문자열/식별자 존재
|
||||
|
||||
| 카탈로그 | 테스트 파일 | 건수 |
|
||||
|---|---|---|
|
||||
| §A 테마·색상·대비 | `UiUsecaseThemeRedTests.cs` | 30 |
|
||||
| §B 타이포그래피·폰트 | `UiUsecaseTypographyRedTests.cs` | 22 |
|
||||
| §C 레이아웃·밀도·창 상태 | `UiUsecaseLayoutRedTests.cs` | 26 |
|
||||
| §D 탭 고급 관리 | `UiUsecaseTabsRedTests.cs` | 24 |
|
||||
| §E 다운로드 관리자 | `UiUsecaseDownloadsRedTests.cs` | 30 |
|
||||
| §F 설정 창 | `UiUsecaseSettingsRedTests.cs` | 20 |
|
||||
| §G 접근성 | `UiUsecaseAccessibilityRedTests.cs` | 20 |
|
||||
| §H 키보드·마우스 인터랙션 | `UiUsecaseInteractionRedTests.cs` | 20 |
|
||||
| §I 라이브러리·재생목록·즐겨찾기·기록·노트 | `UiUsecaseLibraryRedTests.cs` | 22 |
|
||||
| §J 상태 표시·알림·피드백 | `UiUsecaseStatusRedTests.cs` | 20 |
|
||||
|
||||
---
|
||||
|
||||
## §A 테마·색상·대비 (UC-T01~T30)
|
||||
|
||||
| ID | 사용자 유즈케이스 | 계약 |
|
||||
|---|---|---|
|
||||
| UC-T01 | OS 가 다크/라이트 모드를 바꾸면 앱도 자동으로 따라가고 싶다 | 설정창에 `system` 테마 옵션 |
|
||||
| UC-T02 | 테마를 체크박스 하나가 아니라 다크/라이트/시스템/고대비 중에서 고르고 싶다 | 설정창 `ThemeModeBox` 콤보박스 |
|
||||
| UC-T03 | 테마를 바꿀 때마다 앱이 재시작되지 않고 즉시 바뀌었으면 좋겠다 | 설정창에 "재시작" 문구 제거 |
|
||||
| UC-T04 | 테마 전환 후에도 열려 있는 창의 리소스가 살아 있어야 한다 | `MainWindow.ApplyTheme` 라이브 적용 API |
|
||||
| UC-T05 | 고대비 사용자도 테마를 쓸 수 있었으면 한다 | `Themes/HighContrast.xaml` 존재 |
|
||||
| UC-T06 | 고대비 테마도 옵션에서 직접 선택하고 싶다 | 설정창 `highcontrast` 옵션 |
|
||||
| UC-T07 | 강조색(보라)을 내 취향 색으로 바꾸고 싶다 | `AppConfig.AccentColor` 속성 |
|
||||
| UC-T08 | 강조색을 바꾸면 재시작 없이 버튼·진행바에 즉시 반영되었으면 한다 | `App.ApplyAccent` API |
|
||||
| UC-T09 | 강조색 프리셋(파랑·초록 등) 중 하나를 빠르게 고르고 싶다 | 다크 테마 `Accent.Blue` 프리셋 키 |
|
||||
| UC-T10 | 사용자 강조색이 진행바에도 적용되어야 한다 | `Shared.xaml` 진행바 하드코딩 색(`#6366F1`) 제거 |
|
||||
| UC-T11 | 대기 중 다운로드를 노란색 등 별도 상태색으로 구분해서 보고 싶다 | `MainWindow.xaml` 이 `PendingBrush` 사용 |
|
||||
| UC-T12 | 내가 만든 테마 파일을 불러와 쓰고 싶다 | `AppConfig.CustomThemePath` 속성 |
|
||||
| UC-T13 | 시스템 테마 값을 판별하는 로직이 앱에 있어야 자동 추종이 가능하다 | `ThemeResolver` 형식 존재 |
|
||||
| UC-T14 | 흐린 보조 텍스트도 카드 위에서 충분히 읽히길 원한다 | 다크 `TextMuted`/`CardBg` 대비 ≥ 4.5:1 (현재 ~4.4:1) |
|
||||
| UC-T15 | 카드에 마우스를 올렸을 때도 텍스트가 읽혀야 한다 | 다크 `TextMuted`/`CardBgHover` 대비 ≥ 4.5:1 (현재 ~4.0:1) |
|
||||
| UC-T16 | 라이트 테마에서도 흐린 텍스트가 카드 위에서 읽혀야 한다 | 라이트 `TextMuted`/`CardBg` 대비 ≥ 4.5:1 (현재 ~4.2:1) |
|
||||
| UC-T17 | 라이트 테마 유리판 배경 위 보조 텍스트도 읽혀야 한다 | 라이트 `TextMuted`/`GlassBg` 대비 ≥ 4.5:1 (현재 ~3.5:1) |
|
||||
| UC-T18 | 강조색 텍스트(포맷 배지)도 본문처럼 또렷해야 한다 | 다크 `AccentBrush`/`CardBg` 대비 ≥ 4.5:1 (현재 ~4.4:1) |
|
||||
| UC-T19 | 비활성화된 컨트롤의 텍스트 색이 명시적으로 있어야 한다 | 테마 `TextDisabled` 토큰 |
|
||||
| UC-T20 | 텍스트 선택 영역 색을 테마로 관리하고 싶다 | 테마 `SelectionBrush` 토큰 |
|
||||
| UC-T21 | 키보드 포커스 링 색이 테마 토큰으로 있어야 한다 | 테마 `FocusAdornerBrush` 토큰 |
|
||||
| UC-T22 | 스크롤바 썸 색을 테마에서 바꾸고 싶다 | `Shared.xaml` `ScrollBarThumbBrush` 토큰 |
|
||||
| UC-T23 | 툴팁에 그림자가 있어 콘텐츠 위에서 분리돼 보였으면 한다 | `Shared.xaml` `TooltipShadow` 토큰 |
|
||||
| UC-T24 | 팝업(플라이아웃)에도 일관된 그림자가 필요하다 | `Shared.xaml` `PopupShadow` 토큰 |
|
||||
| UC-T25 | 경고 상태용 색이 대기(Pending)와 구분되어 있어야 한다 | 테마 `WarningBrush` 토큰 |
|
||||
| UC-T26 | 링크 텍스트 색이 테마에 정의되어 있어야 한다 | 테마 `LinkBrush` 토큰 |
|
||||
| UC-T27 | 방문한 링크 색을 구분해서 보고 싶다 | 테마 `VisitedLinkBrush` 토큰 |
|
||||
| UC-T28 | 정보성 안내 색(파랑 계열)이 필요하다 | 테마 `InfoBrush` 토큰 |
|
||||
| UC-T29 | 강조색을 배경·텍스트 용도별로 분리해 관리하고 싶다 | 테마 `AccentTextBrush` 토큰 |
|
||||
| UC-T30 | 테마 전환 시 창 테두리(타이틀바)도 즉시 따라가야 한다 | `MainWindow.UpdateTitleBarTheme` API |
|
||||
|
||||
## §B 타이포그래피·폰트 (UC-F01~F22)
|
||||
|
||||
| ID | 사용자 유즈케이스 | 계약 |
|
||||
|---|---|---|
|
||||
| UC-F01 | UI 기본 폰트를 내가 고르고 싶다 | `AppConfig.UiFontFamily` |
|
||||
| UC-F02 | 폰트 크기를 키워서 읽기 편하게 하고 싶다 | `AppConfig.UiFontSize` |
|
||||
| UC-F03 | 한국어 폴백 폰트를 직접 지정하고 싶다 | `AppConfig.UiFontKoreanFallback` |
|
||||
| UC-F04 | 기본 폰트 크기가 토큰으로 관리되어 설정이 전체에 적용돼야 한다 | `Shared.xaml` `FontSize.Base` |
|
||||
| UC-F05 | 보조 정보용 작은 폰트 크기 토큰이 필요하다 | `FontSize.Small` |
|
||||
| UC-F06 | 제목용 큰 폰트 크기 토큰이 필요하다 | `FontSize.Large` |
|
||||
| UC-F07 | 화면 제목용 초대형 토큰이 필요하다 | `FontSize.XLarge` |
|
||||
| UC-F08 | 화면 제목(H1) 스타일이 일관되게 정의되어야 한다 | `Type.H1` |
|
||||
| UC-F09 | 본문 스타일이 토큰으로 정의되어야 한다 | `Type.Body` |
|
||||
| UC-F10 | 캡션(도움말 텍스트) 스타일이 필요하다 | `Type.Caption` |
|
||||
| UC-F11 | 진행률·속도 숫자가 등폭 폰트로 흔들리지 않게 보고 싶다 | `Type.Mono` |
|
||||
| UC-F12 | 한국어 가독성 폰트(Pretendard) 프리셋을 쓰고 싶다 | `UiFont` 정의에 Pretendard 포함 |
|
||||
| UC-F13 | 설정창에 글꼴 섹션이 있어야 폰트를 바꿀 수 있다 | 설정창 "글꼴" 섹션 |
|
||||
| UC-F14 | 폰트 바꾸기 전에 미리보기 텍스트로 확인하고 싶다 | 설정창 `FontPreviewText` |
|
||||
| UC-F15 | 폰트 크기 프리셋을 콤보박스로 고르고 싶다 | 설정창 `FontSizeBox` |
|
||||
| UC-F16 | 폰트 설정 변경이 즉시 적용되었으면 한다 | `App.ApplyFontSettings` API |
|
||||
| UC-F17 | 리더 모드도 앱 폰트 설정을 따라야 한다 | ReaderMode 폰트 하드코딩 제거 |
|
||||
| UC-F18 | 새 탭 다이얼도 앱 폰트 설정을 따라야 한다 | 코드비하인드 폰트 하드코딩 제거 |
|
||||
| UC-F19 | 탭 제목 스타일이 공용 스타일로 관리되어야 한다 | `TabTitleStyle` |
|
||||
| UC-F20 | 상태바 텍스트 스타일이 공용으로 관리되어야 한다 | `StatusTextStyle` |
|
||||
| UC-F21 | 아이콘 크기도 토큰으로 관리하고 싶다 | `IconSize.Small` |
|
||||
| UC-F22 | 본문 줄간격을 토큰으로 조절하고 싶다 | `Type.LineHeight` |
|
||||
|
||||
## §C 레이아웃·밀도·창 상태 (UC-L01~L26)
|
||||
|
||||
| ID | 사용자 유즈케이스 | 계약 |
|
||||
|---|---|---|
|
||||
| UC-L01 | 정보 밀도(컴팩트/보통/여유)를 고르고 싶다 | `AppConfig.UiDensity` |
|
||||
| UC-L02 | 설정창에서 밀도를 바꿀 수 있어야 한다 | 설정창 "밀도" 항목 |
|
||||
| UC-L03 | 작은 간격 토큰으로 여백이 일관되게 관리되어야 한다 | `Spacing.S` |
|
||||
| UC-L04 | 중간 간격 토큰이 필요하다 | `Spacing.M` |
|
||||
| UC-L05 | 큰 간격 토큰이 필요하다 | `Spacing.L` |
|
||||
| UC-L06 | 카드 모서리 둥글기가 토큰으로 일관되어야 한다 | `CornerRadius.Card` |
|
||||
| UC-L07 | 컴팩트 모드 상태가 앱 재시작 후에도 유지되었으면 한다 | `AppConfig.CompactMode` |
|
||||
| UC-L08 | 툴바에 컴팩트 모드 토글 버튼이 있으면 빠르게 켜고 싶다 | `MainWindow` `CompactModeBtn` |
|
||||
| UC-L09 | 세로 탭을 기본 레이아웃으로 쓰고 싶다 | `AppConfig.VerticalTabsDefault` |
|
||||
| UC-L10 | 분할 화면 비율이 저장되어 재현되었으면 한다 | `AppConfig.SplitViewRatio` |
|
||||
| UC-L11 | 앱을 껐다 켰을 때 창 위치·크기가 그대로였으면 한다 | `AppConfig.WindowBounds` |
|
||||
| UC-L12 | 최대화 상태도 함께 복원되어야 한다 | `AppConfig.WindowMaximized` |
|
||||
| UC-L13 | 고DPI 화면에서 레이아웃이 흐릿하지 않아야 한다 | `UseLayoutRounding` 설정 |
|
||||
| UC-L14 | 텍스트·요소가 픽셀에 정렬되어 선명해야 한다 | `Shared.xaml` `SnapsToDevicePixels` |
|
||||
| UC-L15 | 안 쓰는 상태바는 자동으로 숨기고 싶다 | `AppConfig.StatusBarAutoHide` |
|
||||
| UC-L16 | 툴바 버튼 노출 여부를 직접 고르고 싶다 | `AppConfig.ToolbarButtons` |
|
||||
| UC-L17 | 북마크바 표시 여부가 저장되었으면 한다 | `AppConfig.BookmarkBarVisible` |
|
||||
| UC-L18 | 탭 스트립 위치(위/아래/왼쪽)를 고르고 싶다 | `AppConfig.TabStripPosition` |
|
||||
| UC-L19 | 패널 폭을 실수로 바꿨을 때 더블클릭으로 기본 복원하고 싶다 | `MainWindow.ResetPanelWidth` |
|
||||
| UC-L20 | 패널 최소 폭을 직접 정하고 싶다 | `AppConfig.RightPanelMinWidth` |
|
||||
| UC-L21 | 움직임에 민감하므로 애니메이션을 줄이고 싶다 | `AppConfig.ReduceMotion` |
|
||||
| UC-L22 | 빠른 전환 애니메이션 지속 시간이 토큰으로 관리되어야 한다 | `Duration.Fast` |
|
||||
| UC-L23 | 일반 애니메이션 지속 시간 토큰이 필요하다 | `Duration.Normal` |
|
||||
| UC-L24 | 컴팩트 모드 엣지 호버 반응 속도를 조절하고 싶다 | `AppConfig.EdgeRevealDelayMs` |
|
||||
| UC-L25 | UI 전체 배율을 키워서 보고 싶다 | `AppConfig.UiScale` |
|
||||
| UC-L26 | 전체화면 여부도 저장되었으면 한다 | `AppConfig.FullScreen` |
|
||||
|
||||
## §D 탭 고급 관리 (UC-B01~B24)
|
||||
|
||||
| ID | 사용자 유즈케이스 | 계약 |
|
||||
|---|---|---|
|
||||
| UC-B01 | 실수로 탭을 닫지 않도록 탭을 잠그고 싶다 | `TabItem.IsLocked` |
|
||||
| UC-B02 | 탭마다 내가 지정한 색 표시를 달고 싶다 | `TabItem.CustomColor` |
|
||||
| UC-B03 | 탭 그룹을 접어서 스트립을 정리하고 싶다 | `TabItem.IsGroupCollapsed` |
|
||||
| UC-B04 | 탭 그룹 이름을 나중에 바꾸고 싶다 | 그룹 이름 변경 API(`RenameGroup`) |
|
||||
| UC-B05 | 탭에 마우스를 올리면 미리보기(제목+URL+상태)가 보였으면 한다 | `TabPreview` 형식 |
|
||||
| UC-B06 | 잠자기 탭이 시각적으로(zzz 등) 구분되었으면 한다 | 탭 템플릿 `IsSuspended` 반영 |
|
||||
| UC-B07 | 탭이 많을 때 번호 배지로 위치를 파악하고 싶다 | `TabIndexBadge` 요소 |
|
||||
| UC-B08 | 탭 드래그 중 삽입 위치 표시기가 보였으면 한다 | `TabInsertIndicator` 요소 |
|
||||
| UC-B09 | 탭 여러 개를 한 번에 닫을 때 확인을 받았으면 한다 | `AppConfig.ConfirmCloseMultipleTabs` |
|
||||
| UC-B10 | 탭 위에서 휠을 굴려 탭을 전환하고 싶다 | `AppConfig.TabWheelSwitch` |
|
||||
| UC-B11 | 탭 최대 개수를 직접 정하고 싶다 | `AppConfig.MaxTabs` |
|
||||
| UC-B12 | 세션 복원 시 탭을 잠자기 상태로만 복원해 빨리 뜨게 하고 싶다 | `AppConfig.RestoreTabsSuspended` |
|
||||
| UC-B13 | 닫은 탭 아카이브를 전용 팝업에서 보고 싶다 | `ArchivePopup` 요소 |
|
||||
| UC-B14 | 아카이브에서 원하는 탭을 검색해서 복구하고 싶다 | `ArchiveSearchBox` 요소 |
|
||||
| UC-B15 | 아카이브를 한 번에 비우고 싶다 | `TabArchive.Clear()` |
|
||||
| UC-B16 | 아카이브 항목을 클릭해 해당 탭을 다시 열고 싶다 | `TabArchive.Restore()` |
|
||||
| UC-B17 | 탭 그룹 색 팔레트가 테마로 관리되어 일관되었으면 한다 | `GroupPalette` 토큰 |
|
||||
| UC-B18 | 현재 작업공간 이름이 항상 보였으면 한다 | `WorkspaceIndicator` 요소 |
|
||||
| UC-B19 | 작업공간 이름을 내가 짓고 싶다 | `AppConfig.WorkspaceNames` |
|
||||
| UC-B20 | 작업공간을 클릭 한 번에 전환하고 싶다 | `WorkspaceSwitcher` 요소 |
|
||||
| UC-B21 | 새 탭 버튼에서 최근 페이지·즐겨찾기로 바로 열고 싶다 | 새 탭 보조 메뉴(`NewTabMore_Click`) |
|
||||
| UC-B22 | 긴 탭 제목을 두 줄로 보고 싶다 | `AppConfig.TabTitleMultiline` |
|
||||
| UC-B23 | 탭에서 바로 볼륨 슬라이더를 조절하고 싶다 | `TabVolumePopup` 요소 |
|
||||
| UC-B24 | 탭 그룹 전체를 즐겨찾기로 저장하고 싶다 | `MainWindow.BookmarkGroup()` |
|
||||
|
||||
## §E 다운로드 관리자 (UC-D01~D30)
|
||||
|
||||
| ID | 사용자 유즈케이스 | 계약 |
|
||||
|---|---|---|
|
||||
| UC-D01 | 다운로드 목록을 최신순·크기순 등으로 정렬하고 싶다 | `AppConfig.DownloadSortOrder` |
|
||||
| UC-D02 | 진행 중/완료/실패 항목을 필터 탭으로 골라 보고 싶다 | `DownloadFilterTabs` 요소 |
|
||||
| UC-D03 | 다운로드 목록 안에서 검색하고 싶다 | `DownloadSearchBox` 요소 |
|
||||
| UC-D04 | 남은 시간(ETA)을 보고 언제 끝나는지 알고 싶다 | 카드 VM `RemainingText` |
|
||||
| UC-D05 | 다운로드 카드에 현재 속도가 표시되었으면 한다 | 카드 VM `SpeedText` |
|
||||
| UC-D06 | 진행 중인 다운로드를 한 번에 전부 일시정지하고 싶다 | `MainWindow.PauseAllDownloads()` |
|
||||
| UC-D07 | 일시정지한 다운로드를 한 번에 전부 재개하고 싶다 | `MainWindow.ResumeAllDownloads()` |
|
||||
| UC-D08 | 완료된 항목을 자동으로 목록에서 치우고 싶다 | `AppConfig.AutoClearCompleted` |
|
||||
| UC-D09 | 몇 번 재시도했는지 카드에 표시되었으면 한다 | 카드 VM `RetryCount` |
|
||||
| UC-D10 | 세그먼트 진행 상황을 막대 그리드로 시각적으로 보고 싶다 | 카드 VM `SegmentMap` |
|
||||
| UC-D11 | 다운로드 완료를 토스트 알림으로 알고 싶다 | `ToastNotifier` 형식 |
|
||||
| UC-D12 | 알림 소리를 켜고 끄고 싶다 | `AppConfig.NotifySound` |
|
||||
| UC-D13 | 완료 시 다운로드 폴더가 자동으로 열렸으면 한다 | `AppConfig.OpenFolderOnComplete` |
|
||||
| UC-D14 | 다운로드 카드를 우클릭해 메뉴(복사·삭제 등)를 쓰고 싶다 | `CardContextMenu` 요소 |
|
||||
| UC-D15 | 카드를 펼쳐 출처 URL·포맷 등 상세 정보를 보고 싶다 | `CardDetailExpander` 요소 |
|
||||
| UC-D16 | 패널 상단에 대기 현황 요약(예: 3 대기 · 1 실패)이 필요하다 | `QueueSummaryText` 요소 |
|
||||
| UC-D17 | 속도 변화를 스파크라인으로 보고 싶다 | `SpeedSparkline` 요소 |
|
||||
| UC-D18 | 전체 다운로드 속도 합계가 표시되었으면 한다 | `GlobalSpeedText` 요소 |
|
||||
| UC-D19 | 파일 총 크기를 카드에서 미리 보고 싶다 | 카드 VM `TotalSizeText` |
|
||||
| UC-D20 | 영상 자막도 함께 받고 언어를 지정하고 싶다 | 설정창 `SubtitleLangBox` |
|
||||
| UC-D21 | 야간 등 지정 시간대에만 다운로드하게 예약하고 싶다 | `DownloadScheduler` 형식 |
|
||||
| UC-D22 | 다운로드 속도 상한을 두고 싶다 | `AppConfig.MaxDownloadSpeedKbps` |
|
||||
| UC-D23 | 다운로드 목록을 CSV 로 내보내 관리하고 싶다 | `MainWindow.ExportDownloads()` |
|
||||
| UC-D24 | 중요한 항목을 먼저 받도록 우선순위를 정하고 싶다 | 카드 VM `Priority` |
|
||||
| UC-D25 | 같은 영상을 또 받으려 할 때 중복 경고를 받고 싶다 | `MainWindow.ConfirmDuplicateDownload()` |
|
||||
| UC-D26 | 충돌 회피 후 최종 저장 파일명이 카드에 표시되었으면 한다 | 카드 VM `FinalFileName` |
|
||||
| UC-D27 | 오류 메시지를 버튼 한 번으로 복사해 공유하고 싶다 | `CopyErrorButton` 요소 |
|
||||
| UC-D28 | 페이지 파일 다운로드도 전체 일시정지가 되었으면 한다 | `PageDownloadTracker.PauseAll()` |
|
||||
| UC-D29 | 감지된 미디어 종류(HLS/DASH/MP4)를 배지로 보고 싶다 | `MediaKindBadge` 요소 |
|
||||
| UC-D30 | 사이트별로 다운로드 항목이 묶여 보였으면 한다 | `SiteGroupHeader` 요소 |
|
||||
|
||||
## §F 설정 창 (UC-S01~S20)
|
||||
|
||||
| ID | 사용자 유즈케이스 | 계약 |
|
||||
|---|---|---|
|
||||
| UC-S01 | 설정 항목이 많아서 검색으로 찾고 싶다 | `SettingsSearchBox` |
|
||||
| UC-S02 | 좌측 카테고리 내비게이션으로 섹션을 오가고 싶다 | `SettingsNavList` |
|
||||
| UC-S03 | 재시작이 필요한 변경은 배너로 미리 알려줬으면 한다 | `RestartBanner` |
|
||||
| UC-S04 | 설정을 기본값으로 한 번에 되돌리고 싶다 | `ResetDefaultsButton` |
|
||||
| UC-S05 | 설정을 파일로 내보내 백업하고 싶다 | `ExportSettingsButton` |
|
||||
| UC-S06 | 다른 기기에서 내보낸 설정을 가져오고 싶다 | `ImportSettingsButton` |
|
||||
| UC-S07 | 고급 항목은 접어두고 기본만 보고 싶다 | `AdvancedSection` |
|
||||
| UC-S08 | 확인 버튼 대신 Ctrl+S 로 저장하고 싶다 | 설정창 `Key.S` 처리 |
|
||||
| UC-S09 | 취소할 때 변경 사항 폐기 확인을 받고 싶다 | `ConfirmDiscardChanges()` |
|
||||
| UC-S10 | 설정창 크기·위치도 기억되었으면 한다 | `AppConfig.SettingsWindowBounds` |
|
||||
| UC-S11 | 재시도 횟수를 UI 에서 바꾸고 싶다 | `RetriesBox` |
|
||||
| UC-S12 | User-Agent 를 UI 에서 바꾸고 싶다 | `UserAgentBox` |
|
||||
| UC-S13 | 프록시 서버를 설정하고 싶다 | `ProxyBox` |
|
||||
| UC-S14 | UI 언어(한/영)를 바꾸고 싶다 | `LanguageBox` |
|
||||
| UC-S15 | Windows 시작 시 자동 실행을 켜고 싶다 | `AutoStartBox` |
|
||||
| UC-S16 | 닫기 버튼으로 트레이로 최소화하고 싶다 | `MinimizeToTrayBox` |
|
||||
| UC-S17 | 알림 종류를 섹션에서 한곳에 모아 관리하고 싶다 | 설정창 "알림" 섹션 |
|
||||
| UC-S18 | 실험적 기능을 명시적으로 켜고 싶다 | 설정창 "실험적" 섹션 |
|
||||
| UC-S19 | 잘못된 값은 저장 전에 힌트로 알려줬으면 한다 | `ValidationHint` 요소 |
|
||||
| UC-S20 | 앱 버전·저장소 정보를 설정에서 보고 싶다 | `VersionText` 요소 |
|
||||
|
||||
## §G 접근성 (UC-A01~A20)
|
||||
|
||||
| ID | 사용자 유즈케이스 | 계약 |
|
||||
|---|---|---|
|
||||
| UC-A01 | 화면 낭독기가 버튼 이름을 읽어줬으면 한다 | `AutomationProperties` 사용 |
|
||||
| UC-A02 | 주요 컨트롤 15개 이상이 자동화 이름을 갖춰야 한다 | `AutomationProperties.Name` ≥ 15 |
|
||||
| UC-A03 | 상태 변화(다운로드 완료 등)가 낭독기에 자동 전달되어야 한다 | `LiveSetting` 사용 |
|
||||
| UC-A04 | Windows 고대비 모드를 켜면 앱도 반응했으면 한다 | `HighContrast` 감지 코드 |
|
||||
| UC-A05 | 키보드 포커스가 어디 있는지 항상 보였으면 한다 | `FocusVisualStyle` 정의 |
|
||||
| UC-A06 | Tab 키 순서가 논리적으로 지정되어야 한다 | `TabIndex` 사용 |
|
||||
| UC-A07 | 툴팁 표시 시간 등 ToolTipService 설정이 필요하다 | `ToolTipService` 사용 |
|
||||
| UC-A08 | 메뉴·버튼에 Alt 접근 키가 있었으면 한다 | `AccessText` 사용 |
|
||||
| UC-A09 | 목록·탭 컨트롤에 맞춤 자동화 피어가 필요하다 | `AutomationPeer` 구현 |
|
||||
| UC-A10 | 컨트롤에 추가 설명(HelpText)이 제공되어야 한다 | `AutomationProperties.HelpText` |
|
||||
| UC-A11 | 색을 구분 못 해도 완료 상태를 아이콘으로 알고 싶다 | `SuccessIcon` 요소 |
|
||||
| UC-A12 | 접근성 관련 설정을 한 섹션에서 관리하고 싶다 | 설정창 "접근성" 섹션 |
|
||||
| UC-A13 | 저시력으로 UI 를 최대 200% 까지 키워서 쓰고 싶다 | `AppConfig.UiFontScale` |
|
||||
| UC-A14 | 팝업 안에서도 키보드 방향키 탐색이 되어야 한다 | `KeyboardNavigation.TabNavigation` |
|
||||
| UC-A15 | 영상 자막 표시 옵션이 필요하다 | `AppConfig.ShowCaptions` |
|
||||
| UC-A16 | 애니메이션 감소 설정이 스타일에 반영되어야 한다 | `Shared.xaml` `ReduceMotion` 연동 |
|
||||
| UC-A17 | 리더 모드 HTML 에 언어(lang=ko)가 선언되어야 낭독기가 한국어로 읽는다 | ReaderMode `lang=` 선언 |
|
||||
| UC-A18 | 접근성 지원 범위를 문서로 확인하고 싶다 | `docs/ACCESSIBILITY.md` 존재 |
|
||||
| UC-A19 | 라이트 테마에서 진행 트랙이 배경과 구별되어야 한다 | 라이트 `TrackBrush`/`PanelBg` 대비 ≥ 3:1 (현재 ~1.2:1) |
|
||||
| UC-A20 | 버튼 테두리가 배경과 구별되어야 클릭 대상이 보인다 | 다크 `BorderBrush`/`CardBg` 대비 ≥ 3:1 (현재 ~1.4:1) |
|
||||
|
||||
## §H 키보드·마우스 인터랙션 (UC-K01~K20)
|
||||
|
||||
| ID | 사용자 유즈케이스 | 계약 |
|
||||
|---|---|---|
|
||||
| UC-K01 | 단축키 목록을 보고 설정하고 싶다 | 설정창 "단축키" 항목 |
|
||||
| UC-K02 | 단축키를 내 키 배치에 맞게 바꾸고 싶다 | `AppConfig.Shortcuts` |
|
||||
| UC-K03 | 단축키 충돌을 저장 전에 알려줬으면 한다 | `ShortcutManager` 형식 |
|
||||
| UC-K04 | 단축키 전체 목록을 앱 안에서 조회하고 싶다 | `ShortcutHelpPopup` 요소 |
|
||||
| UC-K05 | 마우스 우클릭+휠 등 제스처로 뒤로/앞으로 이동하고 싶다 | `MouseGesture`/`XButton` 처리 |
|
||||
| UC-K06 | 링크를 휠 클릭해 새 탭으로 열고 싶다 | `MiddleButton` 처리 |
|
||||
| UC-K07 | 주소를 붙여넣으면 자동으로 이동하게 하고 싶다 | `AppConfig.PasteAndGo` |
|
||||
| UC-K08 | 주소창을 클릭하면 전체 선택되어 바로 덮어쓰고 싶다 | `AppConfig.FocusSelectsAddress` |
|
||||
| UC-K09 | 팔레트에서 "모두 일시정지" 명령을 바로 쓰고 싶다 | "모두 일시정지" 명령 등록 |
|
||||
| UC-K10 | Esc 한 번으로 열린 모든 팝업을 닫고 싶다 | `CloseAllPopups` 통일 처리 |
|
||||
| UC-K11 | 다운로드 중 종료할 때 확인을 받고 싶다 | 종료 확인 다이얼로그 |
|
||||
| UC-K12 | F12 로 개발자 도구를 열고 싶다 | `OpenDevTools` 처리 |
|
||||
| UC-K13 | 프로필(세션)을 전환하며 쓰고 싶다 | "프로필 전환" 기능 |
|
||||
| UC-K14 | 현재 페이지 줌 배율이 표시되었으면 한다 | `ZoomIndicator` 요소 |
|
||||
| UC-K15 | 페이지 기본 줌을 설정하고 싶다 | `AppConfig.DefaultZoomFactor` |
|
||||
| UC-K16 | 즐겨찾기 관리 창에서 정리하고 싶다 | `BookmarkManagerWindow` 형식 |
|
||||
| UC-K17 | 방문 기록에서 특정 항목만 골라 삭제하고 싶다 | `DeleteHistory_Click` 처리기 |
|
||||
| UC-K18 | 화면 영역을 지정해 캡처하고 싶다 | `RegionCapture` 기능 |
|
||||
| UC-K19 | 노트 편집 중 Ctrl+S 로 저장하고 싶다 | `NotesSave_Click` 처리기 |
|
||||
| UC-K20 | 페이지 안 링크를 우클릭해 바로 다운로드하고 싶다 | "링크 다운로드" 컨텍스트 메뉴 |
|
||||
|
||||
## §I 라이브러리·재생목록·즐겨찾기·기록·노트 (UC-M01~M22)
|
||||
|
||||
| ID | 사용자 유즈케이스 | 계약 |
|
||||
|---|---|---|
|
||||
| UC-M01 | 받은 파일들을 앱 안 라이브러리 뷰에서 보고 싶다 | `LibraryPopup`/`LibraryBtn` 요소 |
|
||||
| UC-M02 | 라이브러리를 그리드/리스트로 전환해 보고 싶다 | `LibraryViewToggle` 요소 |
|
||||
| UC-M03 | 라이브러리에서 파일을 검색하고 싶다 | `LibrarySearchBox` 요소 |
|
||||
| UC-M04 | 라이브러리를 이름·날짜·크기순으로 정렬하고 싶다 | `LibrarySortMenu` 요소 |
|
||||
| UC-M05 | 라이브러리 항목에 추가한 날짜가 보였으면 한다 | `AddedDateText` 요소 |
|
||||
| UC-M06 | 라이브러리 항목에 썸네일이 보였으면 한다 | `LibraryThumbnail` 요소 |
|
||||
| UC-M07 | 라이브러리 항목을 더블클릭해 재생하고 싶다 | `LibraryPlay_Click` 처리기 |
|
||||
| UC-M08 | 라이브러리에서 항목을 삭제하고 싶다 | `LibraryDelete_Click` 처리기 |
|
||||
| UC-M09 | 라이브러리에서 항목이 포함된 폴더를 열고 싶다 | `LibraryOpenFolder_Click` 처리기 |
|
||||
| UC-M10 | 라이브러리가 비었을 때 안내가 뜨길 원한다 | `LibraryEmptyHint` 요소 |
|
||||
| UC-M11 | 재생목록을 셔플로 듣고 싶다 | `PlaylistShuffle` 요소 |
|
||||
| UC-M12 | 반복 모드(한곡/전체)가 저장되었으면 한다 | `AppConfig.PlaylistLoopMode` |
|
||||
| UC-M13 | 재생목록을 파일로 저장·불러오고 싶다 | `PlaylistExport` 기능 |
|
||||
| UC-M14 | 재생목록 순서를 위/아래 버튼으로 바꾸고 싶다 | `PlaylistMoveUp` 요소 |
|
||||
| UC-M15 | 재생목록 안에서 검색하고 싶다 | `PlaylistSearchBox` 요소 |
|
||||
| UC-M16 | 즐겨찾기를 폴더로 정리하고 싶다 | `BookmarkFolder` 기능 |
|
||||
| UC-M17 | 즐겨찾기 이름·URL 을 나중에 편집하고 싶다 | `EditBookmark` 기능 |
|
||||
| UC-M18 | 즐겨찾기를 검색해서 찾고 싶다 | `BookmarkSearchBox` 요소 |
|
||||
| UC-M19 | 즐겨찾기를 브라우저 HTML 로 내보내고 싶다 | `ExportBookmarks` 기능 |
|
||||
| UC-M20 | 방문 기록이 날짜별로 묶여 보였으면 한다 | `GroupHistoryByDate` 기능 |
|
||||
| UC-M21 | 방문 기록을 검색해서 찾고 싶다 | `HistorySearchBox` 요소 |
|
||||
| UC-M22 | 노트를 검색해서 찾고 싶다 | `NotesSearchBox` 요소 |
|
||||
|
||||
## §J 상태 표시·알림·피드백 (UC-N01~N20)
|
||||
|
||||
| ID | 사용자 유즈케이스 | 계약 |
|
||||
|---|---|---|
|
||||
| UC-N01 | 상태바에서 전체 다운로드 진행률을 한눈에 보고 싶다 | `AggregateProgress` 요소 |
|
||||
| UC-N02 | 완료·오류 알림이 토스트로 떴으면 한다 | `ToastHost` 요소 |
|
||||
| UC-N03 | 토스트가 떠 있는 시간을 조절하고 싶다 | `AppConfig.ToastDurationMs` |
|
||||
| UC-N04 | 치명 오류는 상단 배너로 고정되었으면 한다 | `ErrorBanner` 요소 |
|
||||
| UC-N05 | 오류 배너를 펼쳐 상세 스택을 보고 싶다 | `ErrorDetailExpander` 요소 |
|
||||
| UC-N06 | 복사한 URL 을 앱이 감지해 바로 받기 제안을 했으면 한다 | `ClipboardHint` 요소 |
|
||||
| UC-N07 | 원격 접속 서버 상태가 상시 표시되었으면 한다 | `ServerStatusIcon` 요소 |
|
||||
| UC-N08 | QR 페어링을 툴바에서 바로 열고 싶다 | `PairingBtn` 요소 |
|
||||
| UC-N09 | 새 버전이 있으면 배지로 알려줬으면 한다 | `UpdateBadge` 요소 |
|
||||
| UC-N10 | 차단된 추적기 도메인별 통계를 보고 싶다 | `ShieldDomainStats` 요소 |
|
||||
| UC-N11 | 네트워크 끊김을 상태에 표시했으면 한다 | `NetworkChange` 감지 |
|
||||
| UC-N12 | 절전 모드 진입 시 다운로드를 일시정지하고 싶다 | `AppConfig.PauseOnPowerSaver` |
|
||||
| UC-N13 | 상태바 텍스트를 클릭해 복사하고 싶다 | `CopyStatus_Click` 처리기 |
|
||||
| UC-N14 | 지나간 알림을 모아보는 알림 센터가 필요하다 | `NotificationCenter` 요소 |
|
||||
| UC-N15 | 방해 금지 모드로 알림을 끄고 싶다 | `AppConfig.DoNotDisturb` |
|
||||
| UC-N16 | 처음 실행 시 기능을 안내하는 온보딩이 있었으면 한다 | `Onboarding` 요소 |
|
||||
| UC-N17 | 빈 상태 안내가 공용 스타일로 일관되었으면 한다 | `EmptyStateStyle` 토큰 |
|
||||
| UC-N18 | 충돌 발생 시 리포트 대화상자로 알려줬으면 한다 | `CrashReportDialog` 형식 |
|
||||
| UC-N19 | 툴바 다운로드 버튼에 진행 개수 배지가 떴으면 한다 | `DownloadsBtnBadge` 요소 |
|
||||
| UC-N20 | 상태바 오른쪽에 줌·서버 등 보조 정보가 보였으면 한다 | `StatusRight` 요소 |
|
||||
|
||||
---
|
||||
|
||||
## GREEN 구현 완료 (2026-08-17)
|
||||
|
||||
제안했던 순서대로 완료했다. 주요 산출물:
|
||||
|
||||
1. **토큰/테마(§A·§B·§C)** — `Shared.xaml` 전면 개서(디자인 토큰·그림자·GroupPalette·FocusVisual·
|
||||
ReduceMotion·SnapsToDevicePixels), `Themes/HighContrast.xaml` 신규, Dark/Light 대비 결함 수정
|
||||
(TextMuted #8B91A6/#63687A, AccentBrush #8B7CFF, BorderBrush #6C6C8A, TrackBrush #808798).
|
||||
브러시 참조를 DynamicResource 로 전환해 **재시작 없는 라이브 테마 전환**(UC-T03/T04) 실현.
|
||||
`App.ApplyAccent`(강조색 즉시 교체)·`App.ApplyFontSettings`·`App.ApplyReduceMotion`·
|
||||
`Services/ThemeResolver`(시스템 테마 판별).
|
||||
2. **설정 계약 + 설정창(§F)** — `AppConfig` 신규 속성 40여 개, `SettingsWindow` 를 좌측 카테고리
|
||||
내비게이션 + 검색 + 섹션 구조로 재구성(글꼴·밀도·알림·접근성·단축키·실험적·자막·예약·프록시).
|
||||
Ctrl+S 저장, 취소 폐기 확인, 기본값 복원, JSON 내보내기/가져오기, 검증 힌트.
|
||||
3. **다운로드 관리자(§E)** — 카드 VM 확장(속도/ETA/총크기/재시도/세그먼트맵/우선순위/최종파일명),
|
||||
필터 탭·검색·정렬, 전체 일시정지/재개(엔진 취소+파트 보존 이어받기), CSV 내보내기, 중복 확인,
|
||||
`ToastNotifier`·`DownloadScheduler`(예약 창 게이트), 페이지 다운로드 PauseAll.
|
||||
4. **라이브러리·탭(§D·§I)** — 라이브러리 팝업(검색·정렬·재생·삭제·폴더·빈 상태), 탭 잠금·사용자 색·
|
||||
그룹 이름 변경/접기·번호 배지·드래그 삽입 표시기·볼륨 팝업, 아카이브 팝업(검색·복구·비움),
|
||||
작업공간 표시/전환, 재생목록 셔플·반복·M3U 내보내기, 즐겨찾기 편집·폴더·HTML 내보내기,
|
||||
기록 날짜 묶음·개별 삭제, 노트 검색·저장.
|
||||
5. **접근성·인터랙션(§G·§H·§J)** — AutomationProperties 30여 개·LiveSetting·TabIndex·AccessText·
|
||||
ToolTipService·커스텀 AutomationPeer, `docs/ACCESSIBILITY.md` 신규, 휠 클릭 새 탭(JS auxclick)·
|
||||
XButton 탐색·F12 개발자 도구·Esc 팝업 일괄 닫기·영역 캡처·프로필 전환, 상태바 통합 진행률·
|
||||
클립보드 제안·서버 상태·알림 센터·온보딩·오류 배너, `CrashReportDialog`.
|
||||
477
downloader.py
477
downloader.py
|
|
@ -1,477 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
HLS / MP4 비디오 다운로더 (maccms 계열 스트리밍 사이트 대응)
|
||||
==========================================================
|
||||
- 페이지 URL 입력 시 영상 주소(m3u8/mp4) 자동 추출 (maccms player_aaaa, 정규식, iframe)
|
||||
- HLS 완전 지원: 마스터 재생목록(최고 화질 자동 선택), AES-128 복호화, fMP4(#EXT-X-MAP) 초기화 세그먼트
|
||||
- 세그먼트 병렬 다운로드 + 이어받기(중단 후 재실행 시 받은 분할 건너뜀)
|
||||
- ffmpeg 자동 리먹스(TS/fMP4 -> MP4)
|
||||
- Referer 등 커스텀 헤더 지원 (CDN 토큰 인증 대응)
|
||||
|
||||
의존성: requests (필수) / pycryptodome (AES 암호화 영상, 옵션) / tqdm (진행률, 옵션) / ffmpeg (리먹스, 권장)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
# Windows 콘솔 한글 깨짐 방지
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
DEFAULT_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": DEFAULT_UA,
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
|
||||
}
|
||||
|
||||
# m3u8 속성 파싱: KEY="VAL",KEY2=VAL
|
||||
_ATTR_RE = re.compile(r'([A-Z0-9\-]+)=("([^"]*)"|([^,]*))')
|
||||
|
||||
# 페이지에서 영상 주소 추출용 정규식
|
||||
_PLAYER_AAAA_RE = re.compile(r"player_aaaa\s*=\s*(\{.*?\})\s*[;<]", re.S)
|
||||
_PLAYER_DATA_RE = re.compile(r"player_data\s*=\s*(\{.*?\})\s*[;<]", re.S)
|
||||
_M3U8_RE = re.compile(r"https?://[^\s\"'<>]*\.m3u8[^\s\"'<>]*", re.I)
|
||||
_MP4_RE = re.compile(r"https?://[^\s\"'<>]*\.mp4[^\s\"'<>]*", re.I)
|
||||
_IFRAME_RE = re.compile(r'<iframe[^>]+src=["\']([^"\']+)["\']', re.I)
|
||||
|
||||
|
||||
def parse_attrs(s):
|
||||
d = {}
|
||||
for m in _ATTR_RE.finditer(s):
|
||||
d[m.group(1)] = m.group(3) if m.group(3) is not None else m.group(4)
|
||||
return d
|
||||
|
||||
|
||||
def parse_m3u8(text, base_url):
|
||||
"""m3u8 재생목록 파싱. 마스터(변환재생목록)와 미디어 재생목록을 모두 처리."""
|
||||
result = {"variants": [], "segments": [], "init": None, "is_master": False}
|
||||
pending_variant = None # 직전 #EXT-X-STREAM-INF 속성
|
||||
cur_key = None # 현재 유효한 #EXT-X-KEY (세그먼트별로 참조 복사)
|
||||
seq = 0
|
||||
idx = 0
|
||||
saw_seq = False
|
||||
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("#EXT-X-STREAM-INF:"):
|
||||
pending_variant = parse_attrs(line[len("#EXT-X-STREAM-INF:"):])
|
||||
continue
|
||||
|
||||
if line.startswith("#EXT-X-MEDIA-SEQUENCE:"):
|
||||
try:
|
||||
seq = int(line.split(":", 1)[1].strip())
|
||||
saw_seq = True
|
||||
except ValueError:
|
||||
pass
|
||||
continue
|
||||
|
||||
if line.startswith("#EXT-X-KEY:"):
|
||||
a = parse_attrs(line[len("#EXT-X-KEY:"):])
|
||||
method = a.get("METHOD", "NONE").upper()
|
||||
if method == "AES-128":
|
||||
uri = a.get("URI")
|
||||
if uri:
|
||||
uri = urljoin(base_url, uri) # 재생목록 기준 절대경로화
|
||||
cur_key = {"uri": uri, "iv": a.get("IV")}
|
||||
else:
|
||||
cur_key = None
|
||||
continue
|
||||
|
||||
if line.startswith("#EXT-X-MAP:"):
|
||||
a = parse_attrs(line[len("#EXT-X-MAP:"):])
|
||||
if a.get("URI"):
|
||||
result["init"] = urljoin(base_url, a["URI"]) # fMP4 초기화 세그먼트
|
||||
continue
|
||||
|
||||
if line.startswith("#"):
|
||||
continue
|
||||
|
||||
# URL 라인
|
||||
abs_url = urljoin(base_url, line)
|
||||
if pending_variant is not None:
|
||||
bw = 0
|
||||
try:
|
||||
bw = int(pending_variant.get("BANDWIDTH", "0"))
|
||||
except ValueError:
|
||||
pass
|
||||
result["variants"].append(
|
||||
{
|
||||
"url": abs_url,
|
||||
"bandwidth": bw,
|
||||
"res": pending_variant.get("RESOLUTION", ""),
|
||||
"codecs": pending_variant.get("CODECS", ""),
|
||||
}
|
||||
)
|
||||
pending_variant = None
|
||||
result["is_master"] = True
|
||||
else:
|
||||
result["segments"].append(
|
||||
{"url": abs_url, "index": idx, "seq": seq, "key": cur_key}
|
||||
)
|
||||
idx += 1
|
||||
seq += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 진행률 표시 (tqdm이 있으면 사용, 없으면 단순 폴백)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _SimpleProgress:
|
||||
def __init__(self, total=None, desc="", unit=""):
|
||||
self.total = total
|
||||
self.n = 0
|
||||
self.desc = desc
|
||||
self.unit = unit
|
||||
self._last_pct = -1
|
||||
|
||||
def update(self, n=1):
|
||||
self.n += n
|
||||
if self.total:
|
||||
pct = int(self.n * 100 / self.total)
|
||||
if pct != self._last_pct and pct % 5 == 0:
|
||||
self._last_pct = pct
|
||||
if self.unit == "B":
|
||||
sys.stdout.write(
|
||||
f"\r{self.desc}: {pct}% "
|
||||
f"({self.n/1048576:.1f}/{self.total/1048576:.1f} MB)"
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(f"\r{self.desc}: {pct}% ({self.n}/{self.total})")
|
||||
sys.stdout.flush()
|
||||
|
||||
def close(self):
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def make_progress(total=None, **kw):
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
|
||||
return tqdm(total=total, **kw)
|
||||
except Exception:
|
||||
return _SimpleProgress(total=total, desc=kw.get("desc", ""), unit=kw.get("unit", ""))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 다운로더 본체
|
||||
# --------------------------------------------------------------------------- #
|
||||
class Downloader:
|
||||
def __init__(self, output, headers=None, concurrency=8, retries=6, timeout=30):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(DEFAULT_HEADERS)
|
||||
for k, v in (headers or {}).items():
|
||||
self.session.headers[k] = v
|
||||
self.concurrency = max(1, concurrency)
|
||||
self.retries = retries
|
||||
self.timeout = timeout
|
||||
self.output = output
|
||||
self.tmp = output + ".parts"
|
||||
os.makedirs(self.tmp, exist_ok=True)
|
||||
self._key_cache = {}
|
||||
|
||||
# ---- 공통 HTTP ---- #
|
||||
def _get_text(self, url):
|
||||
r = self.session.get(url, timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
return r.text
|
||||
|
||||
def _download_file(self, url, path):
|
||||
tmp = path + ".tmp"
|
||||
with self.session.get(url, timeout=self.timeout, stream=True) as r:
|
||||
r.raise_for_status()
|
||||
with open(tmp, "wb") as f:
|
||||
for chunk in r.iter_content(65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
os.replace(tmp, path)
|
||||
|
||||
# ---- 진입: URL 종류 판별 ---- #
|
||||
def run(self, url):
|
||||
path = urlparse(url).path.lower()
|
||||
if ".m3u8" in path:
|
||||
return self.run_hls(url)
|
||||
if path.endswith(".mp4"):
|
||||
return self.run_direct(url)
|
||||
return self.run_page(url)
|
||||
|
||||
# ---- 페이지에서 영상 주소 추출 ---- #
|
||||
def run_page(self, page_url):
|
||||
print(f"페이지 분석 중: {page_url}")
|
||||
self.session.headers.setdefault("Referer", page_url)
|
||||
html = self._get_text(page_url)
|
||||
|
||||
cands = [] # (kind, url)
|
||||
|
||||
def scan(text):
|
||||
for rx, kind in ((_PLAYER_AAAA_RE, "player_aaaa"), (_PLAYER_DATA_RE, "player_data")):
|
||||
for m in rx.finditer(text):
|
||||
try:
|
||||
obj = json.loads(m.group(1))
|
||||
except ValueError:
|
||||
continue
|
||||
for field in ("url", "parse", "parse2"):
|
||||
u = obj.get(field)
|
||||
if isinstance(u, str) and u.startswith("http"):
|
||||
cands.append((f"{kind}.{field}", u))
|
||||
for u in _M3U8_RE.findall(text):
|
||||
cands.append(("m3u8", u))
|
||||
for u in _MP4_RE.findall(text):
|
||||
cands.append(("mp4", u))
|
||||
|
||||
scan(html)
|
||||
|
||||
# iframe 내부 플레이어 페이지도 1단계까지 탐색
|
||||
for src in _IFRAME_RE.findall(html):
|
||||
abs_src = urljoin(page_url, src)
|
||||
if "http" not in abs_src:
|
||||
continue
|
||||
try:
|
||||
scan(self._get_text(abs_src))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 중복 제거(순서 유지) 후 m3u8 우선
|
||||
seen, uniq = set(), []
|
||||
for kind, u in cands:
|
||||
if u in seen:
|
||||
continue
|
||||
seen.add(u)
|
||||
uniq.append((kind, u))
|
||||
|
||||
if not uniq:
|
||||
raise RuntimeError(
|
||||
"페이지에서 영상 주소를 찾지 못했습니다. README의 "
|
||||
"'m3u8 주소 직접 구하기' 방법으로 주소를 얻어 .m3u8 주소를 직접 넘겨주세요."
|
||||
)
|
||||
|
||||
print("발견된 영상 주소 후보:")
|
||||
for i, (kind, u) in enumerate(uniq):
|
||||
print(f" [{i}] ({kind}) {u}")
|
||||
|
||||
chosen = next((u for _, u in uniq if ".m3u8" in u.lower()), None)
|
||||
if chosen is None:
|
||||
chosen = next((u for _, u in uniq if ".mp4" in u.lower()), uniq[0][1])
|
||||
print(f"\n선택: {chosen}\n")
|
||||
|
||||
if ".m3u8" in chosen.lower():
|
||||
return self.run_hls(chosen)
|
||||
return self.run_direct(chosen)
|
||||
|
||||
# ---- HLS 다운로드 ---- #
|
||||
def run_hls(self, m3u8_url):
|
||||
print(f"HLS 재생목록 로드: {m3u8_url}")
|
||||
parsed = parse_m3u8(self._get_text(m3u8_url), m3u8_url)
|
||||
|
||||
# 마스터 재생목록이면 최고 화질 변환 선택 후 미디어 재생목록 로드
|
||||
while parsed["is_master"]:
|
||||
best = max(parsed["variants"], key=lambda v: v["bandwidth"])
|
||||
label = best["res"] or f"{best['bandwidth']}bps"
|
||||
print(f"마스터 재생목록 -> 최고 화질 선택: {label}")
|
||||
parsed = parse_m3u8(self._get_text(best["url"]), best["url"])
|
||||
|
||||
segs = parsed["segments"]
|
||||
if not segs:
|
||||
raise RuntimeError("재생목록에 다운로드할 세그먼트가 없습니다.")
|
||||
|
||||
encrypted = any(s["key"] for s in segs)
|
||||
print(f"세그먼트 수: {len(segs)} | 암호화: {'AES-128' if encrypted else '없음'}")
|
||||
|
||||
# fMP4 초기화 세그먼트
|
||||
init_path = None
|
||||
if parsed["init"]:
|
||||
init_path = os.path.join(self.tmp, "init.fmp4")
|
||||
print("fMP4 초기화 세그먼트 다운로드 ...")
|
||||
self._download_file(parsed["init"], init_path)
|
||||
|
||||
self._download_segments(segs)
|
||||
merged = self._merge(segs, init_path)
|
||||
final = self._remux(merged, init_path is not None)
|
||||
return final
|
||||
|
||||
def _seg_path(self, seg):
|
||||
return os.path.join(self.tmp, f"{seg['index']:06d}.seg")
|
||||
|
||||
def _download_segments(self, segs):
|
||||
todo = [s for s in segs if not (os.path.exists(self._seg_path(s)) and os.path.getsize(self._seg_path(s)) > 0)]
|
||||
skipped = len(segs) - len(todo)
|
||||
print(f"다운로드: {len(todo)}개 (이미 받은 {skipped}개 건너뜀), 동시 {self.concurrency}연결")
|
||||
|
||||
prog = make_progress(total=len(segs), initial=skipped, desc="세그먼트", unit="seg")
|
||||
failures = []
|
||||
|
||||
def work(seg):
|
||||
for attempt in range(1, self.retries + 1):
|
||||
try:
|
||||
self._download_file(seg["url"], self._seg_path(seg))
|
||||
return None
|
||||
except Exception as e:
|
||||
if attempt == self.retries:
|
||||
return (seg, str(e))
|
||||
time.sleep(min(2 ** attempt, 10))
|
||||
return None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.concurrency) as ex:
|
||||
futures = [ex.submit(work, s) for s in todo]
|
||||
for fut in as_completed(futures):
|
||||
err = fut.result()
|
||||
if err:
|
||||
failures.append(err)
|
||||
prog.update(1)
|
||||
prog.close()
|
||||
|
||||
if failures:
|
||||
sample = "; ".join(f"{s['index']}({m})" for s, m in failures[:3])
|
||||
raise RuntimeError(f"{len(failures)}개 세그먼트 실패(재시도 후): {sample} ...")
|
||||
|
||||
@staticmethod
|
||||
def _aes():
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
return AES
|
||||
except ImportError:
|
||||
raise RuntimeError("암호화 영상입니다. 'pip install pycryptodome' 설치 후 재시도하세요.")
|
||||
|
||||
def _decrypt(self, data, seg):
|
||||
key = seg["key"]
|
||||
if not key:
|
||||
return data
|
||||
if key["uri"] not in self._key_cache:
|
||||
r = self.session.get(key["uri"], timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
self._key_cache[key["uri"]] = r.content # 보통 16바이트
|
||||
keybytes = self._key_cache[key["uri"]]
|
||||
if key["iv"]:
|
||||
ivb = bytes.fromhex(key["iv"].replace("0x", "").replace(" ", ""))
|
||||
else:
|
||||
ivb = seg["seq"].to_bytes(16, "big") # IV 미지정 시 시퀀스 번호
|
||||
cipher = self._aes().new(keybytes, self._aes().MODE_CBC, ivb)
|
||||
return cipher.decrypt(data)
|
||||
|
||||
def _merge(self, segs, init_path):
|
||||
is_fmp4 = init_path is not None
|
||||
ext = ".mp4" if is_fmp4 else ".ts"
|
||||
merged = os.path.abspath(self.output + ".merged" + ext)
|
||||
print("세그먼트 병합 중 ...")
|
||||
with open(merged, "wb") as out:
|
||||
if init_path and os.path.exists(init_path):
|
||||
with open(init_path, "rb") as f:
|
||||
out.write(f.read())
|
||||
for seg in sorted(segs, key=lambda x: x["index"]):
|
||||
with open(self._seg_path(seg), "rb") as f:
|
||||
out.write(self._decrypt(f.read(), seg))
|
||||
return merged
|
||||
|
||||
def _remux(self, merged, is_fmp4):
|
||||
base, ext = os.path.splitext(self.output)
|
||||
if ext.lower() in (".ts", ".m3u8", ".m4s", ".fmp4"):
|
||||
ext = ""
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg:
|
||||
final = (base or "video") + ".mp4"
|
||||
if os.path.abspath(final) == os.path.abspath(merged):
|
||||
final = (base or "video") + ".out.mp4"
|
||||
print(f"ffmpeg 리먹스(-c copy) -> {os.path.basename(final)}")
|
||||
ret = subprocess.run(
|
||||
[ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-i", merged, "-c", "copy", final]
|
||||
)
|
||||
if ret.returncode == 0 and os.path.exists(final) and os.path.getsize(final) > 0:
|
||||
os.remove(merged)
|
||||
return final
|
||||
print("경고: ffmpeg 리먹스 실패, 병합본을 그대로 유지합니다.", file=sys.stderr)
|
||||
return merged
|
||||
# ffmpeg 없음: 확장자만 맞춰 이동
|
||||
final = (base or "video") + (".mp4" if is_fmp4 else ".ts")
|
||||
os.replace(merged, final)
|
||||
print("안내: ffmpeg가 없어 리먹스를 생략했습니다. MP4 변환을 권장합니다.", file=sys.stderr)
|
||||
return final
|
||||
|
||||
# ---- 직접 MP4 다운로드 ---- #
|
||||
def run_direct(self, url):
|
||||
base, ext = os.path.splitext(self.output)
|
||||
final = self.output if ext.lower() in (".mp4", ".mkv", ".ts") else (base or "video") + ".mp4"
|
||||
print(f"직접 다운로드: {url}")
|
||||
with self.session.get(url, timeout=self.timeout, stream=True) as r:
|
||||
r.raise_for_status()
|
||||
total = int(r.headers.get("Content-Length", 0)) or None
|
||||
prog = make_progress(total=total, desc="다운로드", unit="B", unit_scale=True)
|
||||
tmp = final + ".tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
for chunk in r.iter_content(65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
prog.update(len(chunk))
|
||||
prog.close()
|
||||
os.replace(tmp, final)
|
||||
return final
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="HLS/MP4 비디오 다운로더 (maccms 계열 사이트 대응)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"예제:\n"
|
||||
' python downloader.py "https://site/vod/play/id/123/sid/1/nid/1.html" -o movie\n'
|
||||
' python downloader.py "https://cdn.example.com/v/index.m3u8" -o movie --referer "https://site/"\n'
|
||||
' python downloader.py "https://cdn.example.com/v/index.m3u8" --referer "https://site/" -H "Origin: https://site"\n'
|
||||
),
|
||||
)
|
||||
ap.add_argument("url", help="재생페이지 URL 또는 .m3u8 / .mp4 직접 주소")
|
||||
ap.add_argument("-o", "--output", default="video", help="출력 파일명(확장자 생략 가능, 기본 video)")
|
||||
ap.add_argument("--referer", help="Referer 헤더 (CDN 토큰 인증에 필요한 경우)")
|
||||
ap.add_argument("-H", "--header", action="append", default=[], metavar='"Key: Value"', help="추가 헤더 (여러개 가능)")
|
||||
ap.add_argument("-c", "--concurrency", type=int, default=8, help="동시 다운로드 연결 수 (기본 8)")
|
||||
ap.add_argument("--user-agent", default=None, help="User-Agent 재정의")
|
||||
ap.add_argument("--keep-parts", action="store_true", help="병합 후 세그먼트 임시파일 유지(디버그용)")
|
||||
args = ap.parse_args()
|
||||
|
||||
headers = {}
|
||||
for h in args.header:
|
||||
if ":" in h:
|
||||
k, v = h.split(":", 1)
|
||||
headers[k.strip()] = v.strip()
|
||||
if args.referer:
|
||||
headers["Referer"] = args.referer
|
||||
if args.user_agent:
|
||||
headers["User-Agent"] = args.user_agent
|
||||
|
||||
dl = Downloader(args.output, headers, concurrency=args.concurrency)
|
||||
try:
|
||||
final = dl.run(args.url)
|
||||
except KeyboardInterrupt:
|
||||
print("\n중단됨. 받은 세그먼트는 유지되어 재실행 시 이어받기됩니다.")
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"\n오류: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n완료: {final}")
|
||||
if not args.keep_parts:
|
||||
shutil.rmtree(dl.tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
requests>=2.28
|
||||
# 아래는 옵션: 없으면 자동 폴백 또는 해당 기능만 비활성
|
||||
pycryptodome>=3.18 # AES-128 암호화 영상 복호화용
|
||||
tqdm>=4.60 # 진행륕 바 표시
|
||||
# ffmpeg은 시스템에 설치(PATH)되어 있으면 자동 사용 — TS/fMP4 -> MP4 리먹스
|
||||
Loading…
Add table
Add a link
Reference in a new issue