feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization
This commit is contained in:
parent
a88aafa1e5
commit
0e568f1f0a
975 changed files with 130593 additions and 16783 deletions
|
|
@ -1,56 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# SessionStart 훅 — 폐쇄루프의 "state layer".
|
||||
#
|
||||
# 세션 시작 시 저장소의 현재 상태를 요약해 컨텍스트에 넣는다.
|
||||
# 에이전트가 매번 git status/log 를 따로 물어보지 않아도 지금 상황을 알고 시작한다.
|
||||
# stdout 이 그대로 컨텍스트로 들어가므로 짧게 유지한다.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-}"
|
||||
if [ -z "$ROOT" ]; then
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
fi
|
||||
cd "$ROOT" || exit 0
|
||||
|
||||
echo "## 저장소 현재 상태 (SessionStart 자동 브리핑)"
|
||||
echo
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")
|
||||
echo "- 브랜치: \`$BRANCH\`"
|
||||
|
||||
LAST=$(git log -1 --format='%h %s' 2>/dev/null | cut -c1-110)
|
||||
echo "- 마지막 커밋: $LAST"
|
||||
|
||||
CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$CHANGED" = "0" ]; then
|
||||
echo "- 작업트리: 깨끗함"
|
||||
else
|
||||
echo "- 작업트리: 변경 $CHANGED 건"
|
||||
git status --porcelain 2>/dev/null | head -10 | sed 's/^/ /'
|
||||
if [ "$CHANGED" -gt 10 ]; then
|
||||
echo " … 외 $((CHANGED - 10))건"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 마지막 검증 이후 소스가 바뀌었는지 (Stop 훅이 쓰는 스탬프와 같은 기준)
|
||||
STAMP=".claude/.verify-stamp"
|
||||
NEWEST=$(find ./VideoDownloader.* -type f \
|
||||
\( -name '*.cs' -o -name '*.csproj' -o -name '*.xaml' -o -name '*.axaml' \) \
|
||||
-not -path '*/bin/*' -not -path '*/obj/*' \
|
||||
-printf '%T@\n' 2>/dev/null | sort -rn | head -1)
|
||||
|
||||
if [ -f "$STAMP" ] && [ "$(cat "$STAMP" 2>/dev/null)" = "$NEWEST" ]; then
|
||||
echo "- 검증: 마지막 \`dotnet test\` 이후 소스 변경 없음 (GREEN 상태)"
|
||||
else
|
||||
echo "- 검증: **미검증 변경 있음** — 작업 전에 \`dotnet test VideoDownloader.Tests\` 로 기준선을 확인할 것"
|
||||
fi
|
||||
|
||||
# 미완료 백로그
|
||||
if [ -f docs/BACKLOG.md ]; then
|
||||
OPEN=$(grep -c '^- \[ \]' docs/BACKLOG.md 2>/dev/null) || OPEN=0
|
||||
echo "- 백로그 미완료: ${OPEN}건 (\`docs/BACKLOG.md\`)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "작업 계약은 \`AGENTS.md\`, 하네스 운영법은 \`docs/HARNESS.md\` 참고."
|
||||
exit 0
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Stop 훅 — 폐쇄루프의 "checker" 층.
|
||||
#
|
||||
# 소스(.cs/.csproj/.xaml/.axaml)가 마지막 성공 검증 이후 바뀌었으면 테스트를 돌린다.
|
||||
# 실패하면 exit 2 로 턴 종료를 막고, 실패 내용을 에이전트에게 돌려준다.
|
||||
# 변경이 없으면 즉시 통과하므로 대화만 하는 턴은 지연이 없다.
|
||||
#
|
||||
# 무한 루프 방지: stop_hook_active 가 true 면(=이미 이 훅 때문에 재개된 상태) 바로 통과.
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# ── 1. 재진입 가드 ────────────────────────────────────────────────────
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
ACTIVE=$(printf '%s' "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false)
|
||||
else
|
||||
case "$INPUT" in
|
||||
*'"stop_hook_active"'*[Tt]rue*) ACTIVE=true ;;
|
||||
*) ACTIVE=false ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$ACTIVE" = "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 2. 프로젝트 루트 ──────────────────────────────────────────────────
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-}"
|
||||
if [ -z "$ROOT" ]; then
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
fi
|
||||
cd "$ROOT" || exit 0
|
||||
|
||||
STAMP=".claude/.verify-stamp"
|
||||
|
||||
# ── 3. 변경 감지 (소스 최신 수정시각) ─────────────────────────────────
|
||||
NEWEST=$(find ./VideoDownloader.* -type f \
|
||||
\( -name '*.cs' -o -name '*.csproj' -o -name '*.xaml' -o -name '*.axaml' \) \
|
||||
-not -path '*/bin/*' -not -path '*/obj/*' \
|
||||
-printf '%T@\n' 2>/dev/null | sort -rn | head -1)
|
||||
|
||||
# 소스를 못 찾으면(경로 이상 등) 훅이 개발을 막지 않도록 통과시킨다.
|
||||
if [ -z "$NEWEST" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f "$STAMP" ] && [ "$(cat "$STAMP" 2>/dev/null)" = "$NEWEST" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 4. 검증 ───────────────────────────────────────────────────────────
|
||||
LOG=$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/vd-verify.$$")
|
||||
dotnet test VideoDownloader.Tests/VideoDownloader.Tests.csproj \
|
||||
--nologo -v quiet >"$LOG" 2>&1
|
||||
CODE=$?
|
||||
|
||||
if [ $CODE -eq 0 ]; then
|
||||
printf '%s' "$NEWEST" > "$STAMP"
|
||||
rm -f "$LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 5. 실패 → 종료 차단 ───────────────────────────────────────────────
|
||||
{
|
||||
echo "검증 실패: dotnet test 가 통과하지 않았다. 테스트를 GREEN 으로 만들기 전에는 턴을 끝낼 수 없다."
|
||||
echo "테스트를 삭제하거나 Skip 처리해서 통과시키지 말 것. 근본 원인을 고칠 것."
|
||||
echo "---- dotnet test 출력 (마지막 60줄) ----"
|
||||
tail -60 "$LOG"
|
||||
} >&2
|
||||
|
||||
rm -f "$LOG"
|
||||
exit 2
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "VideoDownloader.App/**"
|
||||
- "VideoDownloader.Browser/**"
|
||||
---
|
||||
|
||||
# WPF · WebView2 영역 규칙
|
||||
|
||||
## UI 스레드
|
||||
|
||||
- WebView2·엔진 이벤트는 백그라운드 스레드에서 온다. UI 를 만지려면
|
||||
`Dispatcher.BeginInvoke` 로 마샬링한다 (`MainWindow.xaml.cs` 전반의 기존 패턴).
|
||||
- 백그라운드에서 갱신되는 컬렉션은 `BindingOperations.EnableCollectionSynchronization`
|
||||
으로 락을 등록한다 (`Core/DownloadManager.cs:58` 참조). 이걸 빼면 랜덤하게 죽는다.
|
||||
|
||||
## dispose 이후 이벤트
|
||||
|
||||
WebView2 가 dispose 된 뒤에도 이벤트 콜백이 도착한다. 콜백 안에서 `CoreWebView2` 를 만질 때는
|
||||
로컬 변수로 받아 null 을 확인하고 try 로 감싼다 — `UpdateNavState`(`MainWindow.xaml.cs:453`)가
|
||||
기준 패턴이다. 탭을 닫는 경로를 건드렸다면 이 방어를 반드시 확인한다.
|
||||
|
||||
## 탭
|
||||
|
||||
- Environment 는 전 탭 공유, WebView2 인스턴스는 탭마다. 이 구조를 바꾸지 않는다.
|
||||
- 팝업(`NewWindowRequested`)은 deferral 패턴으로 새 탭에 붙인다. 앱 통제를 벗어나게 두지 않는다.
|
||||
|
||||
## 거대 파일
|
||||
|
||||
`MainWindow.xaml.cs`(86KB) · `MainWindow.xaml`(55KB)은 통째로 읽지 않는다.
|
||||
Grep 으로 심볼 위치를 찾고 해당 구간만 Read 한다. 분할 리팩터링은 별도 승인 사항이다.
|
||||
|
||||
## 프로세스 실행
|
||||
|
||||
외부 프로세스는 문자열 인자 연결로 실행하지 않는다. `ProcessStartInfo` 에 인자를 배열로 넘긴다
|
||||
(과거 `explorer.exe` 인자 인젝션 취약점이 있었다).
|
||||
|
||||
## 테스트 접근
|
||||
|
||||
`VideoDownloader.App` 은 `InternalsVisibleTo("VideoDownloader.Tests")` 가 걸려 있다.
|
||||
테스트를 위해 멤버를 `public` 으로 승격하지 말고 `internal` 로 두면 된다.
|
||||
|
||||
## 아이콘 글리프 (PUA · Segoe Fluent Icons)
|
||||
|
||||
- **암시적(키 없는) TextBlock 스타일에 FontFamily 를 넣지 않는다.** 버튼 ContentPresenter 가 만드는
|
||||
자동 TextBlock 이 IconFont 상속을 잃어 모든 아이콘이 tofu(□)가 된다(2026-08-18 실결함 — 원인 규명에
|
||||
실캡처·픽셀 비교까지 필요했다). UI 폰트 통일은 각 창 루트의 `FontFamily="{DynamicResource UiFont}"`
|
||||
상속으로 한다. 코드 생성 창은 `SetResourceReference(FontFamilyProperty, "UiFont")`.
|
||||
- PUA 글리프는 IconFont 보장 스타일(IconButton·FluentIcon·CaptionButton 계열)이나 명시
|
||||
`FontFamily="{DynamicResource IconFont}"` 에서만 쓴다. **한 문자열에 PUA 와 일반 텍스트를 섞지 않는다.**
|
||||
`Infrastructure/IconGlyphXamlTests` 가 정적으로, `E2E/IconContentFontTests` 가 실창에서 이를 강제한다.
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "VideoDownloader.Core/**"
|
||||
- "VideoDownloader.Server/**"
|
||||
---
|
||||
|
||||
# 엔진 · 서버 영역 규칙
|
||||
|
||||
## 플랫폼 중립
|
||||
|
||||
`Core` 는 `net8.0` 이다. WPF·Windows 전용 API 를 끌어들이지 않는다.
|
||||
Avalonia/MAUI 가 같은 엔진을 쓰므로 여기 들어간 Windows 의존은 크로스플랫폼 이관을 막는다.
|
||||
OS별 경로는 `Platform/CorePaths.cs` 를 통한다.
|
||||
|
||||
## 외부 프로세스 (ffmpeg 등)
|
||||
|
||||
`Platform/ProcessModels.cs` 의 실행 헬퍼를 쓴다. 직접 `Process.Start` 를 새로 쓰지 않는다.
|
||||
그 헬퍼가 보장하는 것들을 우회하면 과거 버그가 재발한다:
|
||||
|
||||
- stdout/stderr **동시 드레인** — 안 읽으면 파이프 버퍼가 차서 ffmpeg 가 hang 한다
|
||||
- 취소·실패 시 `Kill(entireProcessTree: true)` — 안 하면 좀비 프로세스가 남는다
|
||||
- 인자는 배열 전달 — 문자열 연결 금지
|
||||
- exit code ≠ 0 이면 예외 — 조용히 넘어가면 실패가 `.ts` 잔재로만 드러난다
|
||||
|
||||
## 취소
|
||||
|
||||
모든 장기 작업은 `CancellationToken` 을 끝까지 전달한다. 중간에 삼키지 않는다.
|
||||
`Parallel.ForEachAsync` 는 `ParallelOptions.CancellationToken` 에 넣는다
|
||||
(`Hls/HlsDownloader.cs:77` 패턴).
|
||||
|
||||
## 동시성
|
||||
|
||||
세그먼트 병렬도는 `AppConfig.Concurrency`, 동시 다운로드 수는 `MaxConcurrentDownloads`(기본 3)로
|
||||
제어한다. 하드코딩된 병렬도를 새로 만들지 않는다 — 소켓 고갈로 이어진다.
|
||||
|
||||
## 데이터 경로
|
||||
|
||||
사용자 데이터는 `CorePaths` 를 통해서만 접근한다. `VD_DATA_DIR` 환경변수 오버라이드가
|
||||
E2E 테스트 격리의 공식 후크이므로, 경로를 직접 조립하면 테스트가 실사용자 폴더를 오염시킨다.
|
||||
|
||||
## 서버
|
||||
|
||||
`Server` 는 App 안에서 호스팅되는 라이브러리다. 포트 0 이면 빈 포트를 자동 할당한다.
|
||||
LAN 노출 API 이므로 인증·경로 검증을 우회하는 엔드포인트를 추가하지 않는다.
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "VideoDownloader.Mobile/**"
|
||||
- "VideoDownloader.Mobile.Core/**"
|
||||
---
|
||||
|
||||
# MAUI 모바일 영역 규칙
|
||||
|
||||
## 로직은 Mobile.Core 로
|
||||
|
||||
`VideoDownloader.Mobile` 은 `net10.0-android`(macOS 에서는 +`net10.0-ios`)이라
|
||||
`VideoDownloader.Tests`(net8.0-windows)가 참조할 수 없다. **테스트 가능한 로직은
|
||||
`VideoDownloader.Mobile.Core`(net8.0)에 둔다.** UI 프로젝트에는 화면 결선만 남긴다.
|
||||
|
||||
새 기능을 UI 프로젝트에 통째로 넣으면 검증할 방법이 사라진다.
|
||||
|
||||
## 링커 함정
|
||||
|
||||
Release/링커 트리밍에서 참조가 정적으로 안 보이는 타입은 통째로 잘려나가고,
|
||||
**런타임 `FileNotFoundException` 으로만** 드러난다. 빌드는 통과한다.
|
||||
|
||||
실제 사례: 모달 `NavigationPage` 가 크래시 → `Xamarin.AndroidX.LocalBroadcastManager` 를
|
||||
csproj 에 명시 참조해 해결. 리플렉션·XAML 로만 참조되는 타입을 쓸 때 이 함정을 의심한다.
|
||||
|
||||
## 플랫폼 분기
|
||||
|
||||
TFM 조건은 `$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'`
|
||||
형식으로 쓴다(csproj 기존 패턴). Windows 개발머신에는 iOS 워크로드가 없으므로
|
||||
iOS TFM 은 macOS 조건부로만 켠다 — 이 조건을 무조건부로 바꾸면 Windows 빌드가 깨진다.
|
||||
|
||||
## 빌드 · 검증
|
||||
|
||||
```powershell
|
||||
dotnet build VideoDownloader.Mobile -f net10.0-android
|
||||
```
|
||||
|
||||
- MAUI 워크로드가 없으면 실패한다. 없는 환경이면 `Mobile.Core` 만 고치고
|
||||
UI 결선은 워크로드가 있는 환경에서 확인하도록 사용자에게 알린다.
|
||||
- 화면 동작을 바꿨으면 에뮬레이터에 배포해 확인한 내용을 보고에 적는다.
|
||||
단위 테스트만으로 "됐다"고 하지 않는다.
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "VideoDownloader.Tests/**"
|
||||
---
|
||||
|
||||
# 테스트 영역 규칙
|
||||
|
||||
## 배치
|
||||
|
||||
`VideoDownloader.Tests/<영역>/<대상>Tests.cs` — 영역은
|
||||
`Browser` · `E2E` · `Infrastructure` · `Mobile` · `Platform` · `Server`.
|
||||
새 영역을 만들기 전에 기존 영역에 맞는지 먼저 본다.
|
||||
|
||||
## 격리 (필수)
|
||||
|
||||
사용자 데이터 경로를 건드리는 테스트는 `VD_DATA_DIR` 을 임시 디렉터리로 지정하고
|
||||
끝나면 `null` 로 되돌린다. `E2E/MainWindowSmokeE2ETests.cs:17` 이 기준 패턴이다.
|
||||
이걸 빼면 테스트가 실사용자의 설정·세션·프로필을 덮어쓴다.
|
||||
|
||||
## WPF 테스트
|
||||
|
||||
테스트 프로세스에는 `App.xaml` 리소스가 없다. WPF 요소를 만드는 테스트는
|
||||
`Infrastructure/WpfTestApp.Ensure(dispatcher)` 를 먼저 호출해 테마 사전을 로드한다.
|
||||
직접 `new Application()` 을 만들지 않는다.
|
||||
|
||||
## 금지
|
||||
|
||||
- 통과시키려고 테스트를 **삭제하거나 `Skip` 처리하지 않는다.** 회귀를 숨기는 것이다.
|
||||
- 실제 네트워크에 의존하는 단정을 새로 만들지 않는다. 외부 사이트가 바뀌면 무작위로 깨진다.
|
||||
(`Cli` 의 `--dashparse` 같은 수동 검증 도구는 예외 — 테스트가 아니다.)
|
||||
- 시간·순서에 의존하는 `Thread.Sleep` 기반 단정을 쓰지 않는다.
|
||||
|
||||
## 알려진 경고
|
||||
|
||||
이 프로젝트에 남아 있는 경고: CS8602(null 역참조) 몇 건, xUnit1031(블로킹 대기),
|
||||
xUnit2013(`Assert.Equal` 로 개수 확인). **개수를 늘리지 않는다.**
|
||||
새 테스트에서는 `Assert.Single`/`Assert.Empty` 와 `async` 대기를 쓴다.
|
||||
|
||||
## 실행
|
||||
|
||||
```powershell
|
||||
dotnet test VideoDownloader.Tests # 전체 (~13초)
|
||||
dotnet test VideoDownloader.Tests --filter "FullyQualifiedName~<이름>" # 단건
|
||||
```
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash",
|
||||
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/session-brief.sh"],
|
||||
"timeout": 30,
|
||||
"statusMessage": "저장소 상태 브리핑…"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash",
|
||||
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-on-stop.sh"],
|
||||
"timeout": 420,
|
||||
"statusMessage": "dotnet test 검증 중…"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet build *)",
|
||||
"Bash(dotnet test *)",
|
||||
"Bash(dotnet restore *)",
|
||||
"Bash(dotnet run *)",
|
||||
"Bash(dotnet format *)",
|
||||
"Bash(dotnet --*)",
|
||||
"Bash(git status *)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git show *)",
|
||||
"Bash(git branch *)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(.env)",
|
||||
"Edit(.env)",
|
||||
"Write(.env)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
---
|
||||
name: release
|
||||
description: VideoDownloader 배포 절차. Windows 설치자(x64/arm64) 생성과 macOS/Linux 원격 빌드머신 빌드를 수행한다. "배포", "설치자 만들어", "릴리스", "publish" 요청에 사용.
|
||||
---
|
||||
|
||||
# 배포
|
||||
|
||||
배포는 되돌리기 어렵다. **각 단계 전에 사용자 확인을 받는다.** 요청받지 않은 배포를 임의로 하지 않는다.
|
||||
|
||||
## 0. 사전 점검 (필수)
|
||||
|
||||
```powershell
|
||||
dotnet test VideoDownloader.Tests
|
||||
git status --short
|
||||
```
|
||||
|
||||
- 테스트 실패가 있으면 배포하지 않는다.
|
||||
- 커밋되지 않은 변경이 있으면 사용자에게 알린다. 원격 빌드는 `git archive HEAD` 로 소스를 보내므로
|
||||
**커밋되지 않은 변경은 원격 빌드에 반영되지 않는다.**
|
||||
|
||||
## 1. Windows 설치자
|
||||
|
||||
```powershell
|
||||
./build/publish.ps1 -Config Release -Version <버전>
|
||||
```
|
||||
|
||||
- 하는 일: `VideoDownloader.App` 을 `win-x64`/`win-arm64` self-contained 로 publish (`publish/<arch>/`)
|
||||
→ Inno Setup 으로 설치자 컴파일 → `out/MediaBrowser-Setup-<arch>.exe`
|
||||
- 필요: **Inno Setup 6** (`winget install --id JRSoftware.InnoSetup -e`).
|
||||
없으면 스크립트가 명시적으로 throw 한다.
|
||||
- 특정 아키텍처만: `-Archs x64`
|
||||
- arm64 첫 빌드는 yt-dlp/WebView2 런타임 관련으로 실패할 수 있다. 실패 메시지를 그대로 사용자에게 전달한다.
|
||||
- 완료 시 스크립트가 산출물 크기를 출력한다. **그 출력을 인용해 보고한다.**
|
||||
|
||||
## 2. macOS / Linux 원격 빌드
|
||||
|
||||
```bash
|
||||
bash build/remote/build-remote.sh all # 또는 linux | mac
|
||||
```
|
||||
|
||||
- 하는 일: `git archive HEAD` 로 현재 커밋 소스를 빌드머신에 SSH 전송 →
|
||||
`Cli` + `Avalonia` 를 RID별 self-contained publish → `out/remote/<rid>/artifacts-<rid>.tar.gz` 수집
|
||||
- 자격 증명은 `.env` 의 `MAC_OS_HOST`/`LINUX_OS_HOST` 등을 쓴다.
|
||||
**`.env` 내용을 읽어서 출력하거나 로그에 남기지 않는다.**
|
||||
- 빌드머신이 절전이면 SSH 가 끊긴다. 재연결 후 재시도한다.
|
||||
- Avalonia 12.1 은 빌드머신에 .NET 10 SDK 가 있어야 한다. 8만 있으면 CS0103 으로 실패한다.
|
||||
|
||||
## 3. 배포 후
|
||||
|
||||
- 산출물 경로와 크기를 보고한다.
|
||||
- `out/`, `publish/` 는 gitignore 대상이다. 커밋하지 않는다.
|
||||
- 릴리스 배포처(GitHub Releases 등)에 올리는 것은 **별도 승인 사항**이다. 임의로 업로드하지 않는다.
|
||||
- 배포 체계 전반은 `docs/DEPLOYMENT_PLAN.md` 참고.
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
---
|
||||
name: tdd
|
||||
description: VideoDownloader 에서 기능을 추가하거나 버그를 고칠 때 쓰는 RED→GREEN→증거 폐쇄루프 절차. 실패 테스트를 먼저 만들고, 최소 구현으로 통과시키고, 실행 증거까지 남긴다. "기능 추가", "버그 수정", "TDD로", "테스트부터" 같은 작업에 사용.
|
||||
---
|
||||
|
||||
# TDD 폐쇄루프
|
||||
|
||||
이 저장소는 RED→GREEN→증거로 개발해 왔다. 이 순서를 건너뛰지 않는다.
|
||||
|
||||
## 0. 기준선 확보 (건너뛰지 말 것)
|
||||
|
||||
```powershell
|
||||
dotnet test VideoDownloader.Tests
|
||||
```
|
||||
|
||||
지금 몇 개가 통과하는지 **숫자를 기록**한다. 이미 깨져 있다면 그것부터 사용자에게 알린다.
|
||||
내 변경으로 깨진 것과 원래 깨져 있던 것을 섞지 않기 위해 반드시 먼저 한다.
|
||||
|
||||
## 1. RED — 실패를 먼저 본다
|
||||
|
||||
고칠 동작을 **재현하는 테스트**를 쓴다.
|
||||
|
||||
- 위치: `VideoDownloader.Tests/<영역>/<대상>Tests.cs`
|
||||
(영역 = `Browser` · `E2E` · `Infrastructure` · `Mobile` · `Platform` · `Server`)
|
||||
- 순수 로직이면 `Core`/`Mobile.Core` 쪽으로 밀어 넣어 UI 없이 테스트되게 만든다.
|
||||
WPF 의존이 꼭 필요하면 `Infrastructure/WpfTestApp.cs` 패턴을 따른다.
|
||||
- 사용자 데이터 경로를 건드리는 테스트는 `VD_DATA_DIR` 로 격리한다.
|
||||
|
||||
```powershell
|
||||
dotnet test VideoDownloader.Tests --filter "FullyQualifiedName~<새테스트이름>"
|
||||
```
|
||||
|
||||
**실패하는 것을 눈으로 확인한다.** 여기서 통과해 버리면 테스트가 대상을 못 잡고 있는 것이다.
|
||||
테스트를 고쳐서 진짜 실패하게 만든 다음 진행한다.
|
||||
|
||||
## 2. GREEN — 최소 구현
|
||||
|
||||
- 테스트를 통과시키는 가장 작은 변경만 한다. 겸사겸사 리팩터링하지 않는다.
|
||||
- 에러를 삼키지 않는다. 빈 `catch`, 가짜 폴백 금지. 근본 원인을 고친다.
|
||||
- 거대 파일(`MainWindow.xaml.cs` 86KB)은 Grep 으로 위치를 찾아 필요한 구간만 읽고 고친다.
|
||||
|
||||
```powershell
|
||||
dotnet test VideoDownloader.Tests
|
||||
```
|
||||
|
||||
**전체**가 통과해야 한다. 기준선보다 테스트 수가 줄면 무언가를 지운 것이다 — 되돌린다.
|
||||
|
||||
## 3. 증거 — 테스트로 부족한 것
|
||||
|
||||
UI·플랫폼 동작을 바꿨다면 테스트 GREEN 만으로는 "된다"는 근거가 아니다.
|
||||
|
||||
| 바꾼 것 | 확인 방법 |
|
||||
|---|---|
|
||||
| WPF 화면·상호작용 | `dotnet run --project VideoDownloader.App` 로 실제 창을 띄워 확인 |
|
||||
| WebView2 감지·쿠키 | 실제 사이트를 열어 미디어가 목록에 잡히는지 확인 |
|
||||
| 서버 API | 앱 기동 후 해당 엔드포인트 호출 |
|
||||
| MAUI 화면 | Android 에뮬레이터에 배포해 확인 |
|
||||
| 다운로드 엔진 | `dotnet run --project VideoDownloader.Cli -- <검증 옵션>` |
|
||||
|
||||
무엇을 어떻게 확인했는지 보고에 쓴다. "확인했습니다"만 쓰지 말고 관측한 내용을 쓴다.
|
||||
|
||||
## 4. 마무리
|
||||
|
||||
- `docs/BACKLOG.md` 해당 항목 `- [ ]` → `- [x]`
|
||||
- 계획 대비 구현 상태가 바뀌었으면 `docs/IMPLEMENTATION_AUDIT.md` 갱신
|
||||
- 보고에는 **실제 테스트 출력의 숫자**를 인용한다 (예: "164/164 통과"). 기억으로 쓰지 않는다.
|
||||
|
||||
## 막혔을 때
|
||||
|
||||
- 원인을 모르겠으면 추측으로 코드를 바꾸지 말고, 먼저 재현 범위를 좁히는 테스트를 더 쓴다.
|
||||
- 그래도 안 되면 무엇을 시도했고 무엇이 관측됐는지 정리해 사용자에게 보고한다.
|
||||
통과하지 못한 것을 통과한 것처럼 쓰지 않는다.
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
---
|
||||
name: verify
|
||||
description: VideoDownloader 변경사항이 완료 기준(Definition of Done)을 만족하는지 전수 점검한다. 빌드·테스트·경고·문서 동기화·산출물 오염까지 확인하고 통과/미통과를 판정한다. "다 됐나 확인", "검증해줘", "완료 기준 점검", 작업을 마무리하기 직전에 사용.
|
||||
---
|
||||
|
||||
# 완료 검증
|
||||
|
||||
작업을 "완료"로 보고하기 전에 아래를 순서대로 실행하고, 각 항목의 **실제 출력**을 근거로 판정한다.
|
||||
하나라도 실패하면 완료가 아니다. 고치거나, 못 고치는 이유를 명시한다.
|
||||
|
||||
## 1. 테스트
|
||||
|
||||
```powershell
|
||||
dotnet test VideoDownloader.Tests
|
||||
```
|
||||
|
||||
- 실패 0건인가?
|
||||
- 통과 수가 작업 전보다 줄지 않았는가? (현재 기준선 164)
|
||||
- 새로 추가한 동작을 실제로 검증하는 테스트가 있는가? 없으면 지금 쓴다.
|
||||
|
||||
## 2. 빌드 · 경고
|
||||
|
||||
```powershell
|
||||
dotnet build VideoDownloader.App
|
||||
```
|
||||
|
||||
- 오류 0인가?
|
||||
- **내가 새로 만든 경고가 있는가?** 기존 경고(`MainWindow.xaml.cs`·테스트의 CS8602 몇 건,
|
||||
xUnit 분석기 경고)는 알려진 것이다. 개수가 늘었으면 내가 늘린 것이다 — 고친다.
|
||||
|
||||
모바일을 건드렸다면 (MAUI 워크로드가 설치돼 있을 때):
|
||||
|
||||
```powershell
|
||||
dotnet build VideoDownloader.Mobile -f net10.0-android
|
||||
```
|
||||
|
||||
## 3. 실행 증거
|
||||
|
||||
UI·플랫폼 동작을 바꿨는데 실행해 보지 않았다면 여기서 멈추고 실행한다.
|
||||
`/tdd` 스킬의 "3. 증거" 표를 따른다. 무엇을 관측했는지 기록한다.
|
||||
|
||||
## 4. 저장소 위생
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
```
|
||||
|
||||
- 산출물(`out/`, `publish/`, `bin/`, `obj/`)이 변경 목록에 보이는가? → `.gitignore` 가 뚫린 것이다.
|
||||
- `.env` 가 보이는가? → **즉시 멈추고 사용자에게 알린다.** 실제 자격 증명이 들어 있다.
|
||||
- 의도하지 않은 파일이 섞였는가?
|
||||
|
||||
```powershell
|
||||
git diff --stat
|
||||
```
|
||||
|
||||
- 변경 규모가 작업 범위와 맞는가? 요청하지 않은 리팩터링이 섞이지 않았는가?
|
||||
|
||||
## 5. 문서 동기화
|
||||
|
||||
- `docs/BACKLOG.md` — 완료 항목 체크박스 반영했는가?
|
||||
- `docs/IMPLEMENTATION_AUDIT.md` — 계획 대비 구현 상태가 바뀌었으면 갱신했는가?
|
||||
- 명령·구조·규약이 바뀌었으면 `AGENTS.md` 와 `README.md` 도 갱신했는가?
|
||||
|
||||
## 6. 판정 보고
|
||||
|
||||
아래 형식으로 사용자에게 보고한다. **실제 출력에서 숫자를 인용한다.**
|
||||
|
||||
```
|
||||
검증 결과
|
||||
- 테스트: <통과>/<전체> 통과, 실패 <n>건
|
||||
- 빌드: 오류 <n>, 신규 경고 <n>
|
||||
- 실행 증거: <무엇을 어떻게 확인했는지 / 해당 없음>
|
||||
- 저장소: 변경 <n>건, 산출물 오염 없음
|
||||
- 문서: <갱신한 파일 / 갱신 불필요>
|
||||
판정: 완료 | 미완료(사유: …)
|
||||
```
|
||||
|
||||
미통과 항목을 숨기거나 완곡하게 쓰지 않는다.
|
||||
3
.cursorrules
Normal file
3
.cursorrules
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Cursor AI Rules
|
||||
# Single Source of Truth: AGENTS.md (Linux Foundation AAIF Standard)
|
||||
@AGENTS.md
|
||||
30
.env.example
30
.env.example
|
|
@ -2,9 +2,11 @@
|
|||
# 이 파일을 .env 로 복사한 뒤 실제 값을 채운다. .env 는 git 에 커밋되지 않는다(.gitignore).
|
||||
# 값에 따옴표는 붙이지 않는다.
|
||||
|
||||
# ── Cloudflare Pages (다운로드 사이트 배포) ──────────────────────────
|
||||
# ── Cloudflare Pages / DNS (다운로드 사이트 배포 & 도메인 관리) ─────────
|
||||
# https://dash.cloudflare.com/profile/api-tokens → Create Token
|
||||
# 권한: Account → Cloudflare Pages → Edit (+ Zone → Cache Purge)
|
||||
# 권한: Account → Cloudflare Pages → Edit
|
||||
# Zone → DNS → Edit (특정 영역: chanpaca.net)
|
||||
# (선택) Zone → Cache Purge → Purge
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_PAGES_PROJECT=
|
||||
|
|
@ -31,9 +33,29 @@ LINUX_OS_PASSWORD=
|
|||
# GITHUB_REPO=owner/videodownloader
|
||||
|
||||
# ---- Forgejo(git 원격 서버) ----
|
||||
# 로컬 네트워크 Git 서버(Synology NAS/Forgejo). git push 자동화에 사용.
|
||||
# FORGEJO_URL=http://192.168.0.39:3000
|
||||
# 로컬/외부 Git 서버(Synology NAS/Forgejo). git push 자동화 및 릴리스 관리에 사용.
|
||||
# FORGEJO_URL=https://git.chanpaca.net
|
||||
# FORGEJO_USER=<Forgejo 계정>
|
||||
# FORGEJO_PASSWORD=<Forgejo 비밀번호>
|
||||
# (선택) 개인 액세스 토큰 — 값이 있으면 비밀번호 대신 우선 사용
|
||||
# FORGEJO_TOKEN=
|
||||
|
||||
# ── Google AdMob / Desktop Ad Units (PACA) ───────────────────────────
|
||||
# Google AdMob 앱 ID (PACA Android)
|
||||
# ADMOB_APP_ID=ca-app-pub-1039714767792854~5680462775
|
||||
|
||||
# 모바일 Android 하단 배너 (PACA Bottom Banner)
|
||||
# ADMOB_BANNER_ID=ca-app-pub-1039714767792854/3110491400
|
||||
|
||||
# 데스크톱 다운로드 패널 배너 (PACA Desktop Banner)
|
||||
# ADMOB_DESKTOP_BANNER_ID=ca-app-pub-1039714767792854/1521297311
|
||||
|
||||
# 데스크톱 새 탭 페이지 배너 (PACA Desktop NTP Banner)
|
||||
# ADMOB_DESKTOP_NTP_BANNER_ID=ca-app-pub-1039714767792854/4387520844
|
||||
|
||||
# Google 공식 웹/디스플레이 테스트 태그 (Google Publisher Tag / Ad Manager)
|
||||
# GOOGLE_AD_MANAGER_TEST_SLOT=/6499/example/banner
|
||||
|
||||
# 테스트 광고 강제 활성화 여부
|
||||
# ADMOB_FORCE_TEST_ADS=true
|
||||
# ADMOB_USE_PRODUCTION_ADS=false
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ env:
|
|||
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
|
||||
# 릴리스 자산은 공개 배포 저장소로 업로드한다(소스 저장소는 비공개라 익명 다운로드 불가).
|
||||
# draft 상태는 익명에게 보이지 않으므로 QA 후 수동 Publish 흐름은 그대로다.
|
||||
RELEASE_REPO: "Video-Downloader/releases"
|
||||
RELEASE_REPO: "Paca/releases"
|
||||
|
||||
jobs:
|
||||
# ── 관문: 전 플랫폼 빌드 전에 단위+E2E 통과 ──
|
||||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
- name: 환경 정보
|
||||
run: dotnet --list-sdks
|
||||
- name: dotnet test
|
||||
run: dotnet test VideoDownloader.Tests -c Release
|
||||
run: dotnet test tests/Paca.Tests -c Release
|
||||
|
||||
# ── Windows 설치자 (x64 / arm64 교차 publish + Inno Setup) ──
|
||||
windows:
|
||||
|
|
@ -74,7 +74,7 @@ jobs:
|
|||
run: ./build/publish.ps1 -Config Release -Version $env:APP_VERSION
|
||||
- name: 릴리스 업로드
|
||||
env: { RELEASE_TOKEN: "${{ secrets.RELEASE_TOKEN }}" }
|
||||
run: ./build/ci/upload-release-asset.ps1 -Tag $env:RELTAG -Files out/MediaBrowser-Setup-x64.exe, out/MediaBrowser-Setup-arm64.exe
|
||||
run: ./build/ci/upload-release-asset.ps1 -Tag $env:RELTAG -Files out/Paca-Setup-x64.exe, out/Paca-Setup-arm64.exe
|
||||
|
||||
# ── Android APK (키스토어 시크릿 없으면 디버그 서명 APK) ──
|
||||
android:
|
||||
|
|
@ -120,16 +120,16 @@ jobs:
|
|||
} else { Write-Host "ANDROID_KEYSTORE 시크릿 없음 — 디버그 서명 APK 생성" }
|
||||
# RID 는 CLI 전역 속성이 아니라 csproj 게이트(CiPackArm64Only)로 좁힌다 — 전역 -p:RuntimeIdentifiers 는
|
||||
# 참조 프로젝트(net8.0 Mobile.Core)까지 전파돼 MSB3030 으로 깨진다(2026-08-17 리허설 실증)
|
||||
dotnet publish VideoDownloader.Mobile -f net10.0-android -c Release `
|
||||
dotnet publish src/Paca.Mobile -f net10.0-android -c Release `
|
||||
-p:AndroidPackageFormat=apk -p:CiPackArm64Only=true @sign -o out/android
|
||||
if ($LASTEXITCODE -ne 0) { throw "publish 실패" }
|
||||
$apk = Get-ChildItem out/android/*-Signed.apk | Select-Object -First 1
|
||||
if (-not $apk) { $apk = Get-ChildItem out/android/*.apk | Select-Object -First 1 }
|
||||
if (-not $apk) { throw "APK 산출물 없음" }
|
||||
Copy-Item $apk.FullName "out/MediaBrowser-$env:APP_VERSION-arm64.apk"
|
||||
Copy-Item $apk.FullName "out/Paca-$env:APP_VERSION-arm64.apk"
|
||||
- name: 릴리스 업로드
|
||||
env: { RELEASE_TOKEN: "${{ secrets.RELEASE_TOKEN }}" }
|
||||
run: ./build/ci/upload-release-asset.ps1 -Tag $env:RELTAG -Files "out/MediaBrowser-$env:APP_VERSION-arm64.apk"
|
||||
run: ./build/ci/upload-release-asset.ps1 -Tag $env:RELTAG -Files "out/Paca-$env:APP_VERSION-arm64.apk"
|
||||
|
||||
# ── Linux 셸 (tar.gz self-contained — Cli + Avalonia, x64 네이티브 + arm64 교차) ──
|
||||
linux:
|
||||
|
|
@ -161,13 +161,13 @@ jobs:
|
|||
run: |
|
||||
dotnet --list-sdks
|
||||
for rid in linux-x64 linux-arm64; do
|
||||
dotnet publish VideoDownloader.Cli -c Release -r "$rid" --self-contained -o "out/cli-$rid"
|
||||
dotnet publish VideoDownloader.Avalonia -c Release -r "$rid" --self-contained -o "out/avalonia-$rid"
|
||||
tar -C out -czf "out/mediabrowser-$rid.tar.gz" "cli-$rid" "avalonia-$rid"
|
||||
dotnet publish src/Paca.Cli -c Release -r "$rid" --self-contained -o "out/cli-$rid"
|
||||
dotnet publish src/Paca.Avalonia -c Release -r "$rid" --self-contained -o "out/avalonia-$rid"
|
||||
tar -C out -czf "out/Paca-$rid.tar.gz" "cli-$rid" "avalonia-$rid"
|
||||
done
|
||||
- name: 릴리스 업로드
|
||||
env: { RELEASE_TOKEN: "${{ secrets.RELEASE_TOKEN }}" }
|
||||
run: ./build/ci/upload-release-asset.sh "$RELTAG" out/mediabrowser-linux-x64.tar.gz out/mediabrowser-linux-arm64.tar.gz
|
||||
run: ./build/ci/upload-release-asset.sh "$RELTAG" out/Paca-linux-x64.tar.gz out/Paca-linux-arm64.tar.gz
|
||||
|
||||
# ── macOS 셸 (tar.gz — arm64 네이티브 + x64 교차. 서명·공증은 D4, Apple Developer Program 후) ──
|
||||
macos:
|
||||
|
|
@ -199,10 +199,10 @@ jobs:
|
|||
run: |
|
||||
dotnet --list-sdks
|
||||
for rid in osx-arm64 osx-x64; do
|
||||
dotnet publish VideoDownloader.Cli -c Release -r "$rid" --self-contained -o "out/cli-$rid"
|
||||
dotnet publish VideoDownloader.Avalonia -c Release -r "$rid" --self-contained -o "out/avalonia-$rid"
|
||||
tar -C out -czf "out/mediabrowser-$rid.tar.gz" "cli-$rid" "avalonia-$rid"
|
||||
dotnet publish src/Paca.Cli -c Release -r "$rid" --self-contained -o "out/cli-$rid"
|
||||
dotnet publish src/Paca.Avalonia -c Release -r "$rid" --self-contained -o "out/avalonia-$rid"
|
||||
tar -C out -czf "out/Paca-$rid.tar.gz" "cli-$rid" "avalonia-$rid"
|
||||
done
|
||||
- name: 릴리스 업로드
|
||||
env: { RELEASE_TOKEN: "${{ secrets.RELEASE_TOKEN }}" }
|
||||
run: ./build/ci/upload-release-asset.sh "$RELTAG" out/mediabrowser-osx-arm64.tar.gz out/mediabrowser-osx-x64.tar.gz
|
||||
run: ./build/ci/upload-release-asset.sh "$RELTAG" out/Paca-osx-arm64.tar.gz out/Paca-osx-x64.tar.gz
|
||||
|
|
|
|||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
8.0.x
|
||||
10.0.x
|
||||
- name: dotnet test
|
||||
run: dotnet test VideoDownloader.Tests -c Release
|
||||
run: dotnet test tests/Paca.Tests -c Release
|
||||
|
||||
# ── Windows 설치자 (x64 / arm64 교차 publish + Inno Setup) ──
|
||||
windows:
|
||||
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: win-${{ matrix.arch }}
|
||||
path: out/MediaBrowser-Setup-${{ matrix.arch }}.exe
|
||||
path: out/Paca-Setup-${{ matrix.arch }}.exe
|
||||
|
||||
# ── Linux 셸 (tar.gz self-contained — Avalonia + Cli) ──
|
||||
linux:
|
||||
|
|
@ -72,13 +72,13 @@ jobs:
|
|||
10.0.x
|
||||
- name: publish (Cli + Avalonia)
|
||||
run: |
|
||||
dotnet publish VideoDownloader.Cli -c Release -r ${{ matrix.rid }} --self-contained -o out/cli-${{ matrix.rid }}
|
||||
dotnet publish VideoDownloader.Avalonia -c Release -r ${{ matrix.rid }} --self-contained -o out/avalonia-${{ matrix.rid }}
|
||||
tar -C out -czf out/mediabrowser-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }}
|
||||
dotnet publish src/Paca.Cli -c Release -r ${{ matrix.rid }} --self-contained -o out/cli-${{ matrix.rid }}
|
||||
dotnet publish src/Paca.Avalonia -c Release -r ${{ matrix.rid }} --self-contained -o out/avalonia-${{ matrix.rid }}
|
||||
tar -C out -czf out/Paca-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.rid }}
|
||||
path: out/mediabrowser-${{ matrix.rid }}.tar.gz
|
||||
path: out/Paca-${{ matrix.rid }}.tar.gz
|
||||
|
||||
# ── macOS 셸 (tar.gz — D4 서명·공증은 Developer ID 시크릿 등록 후 확장) ──
|
||||
macos:
|
||||
|
|
@ -98,14 +98,14 @@ jobs:
|
|||
10.0.x
|
||||
- name: publish (Cli + Avalonia)
|
||||
run: |
|
||||
dotnet publish VideoDownloader.Cli -c Release -r ${{ matrix.rid }} --self-contained -o out/cli-${{ matrix.rid }}
|
||||
dotnet publish VideoDownloader.Avalonia -c Release -r ${{ matrix.rid }} --self-contained -o out/avalonia-${{ matrix.rid }}
|
||||
tar -C out -czf out/mediabrowser-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }}
|
||||
dotnet publish src/Paca.Cli -c Release -r ${{ matrix.rid }} --self-contained -o out/cli-${{ matrix.rid }}
|
||||
dotnet publish src/Paca.Avalonia -c Release -r ${{ matrix.rid }} --self-contained -o out/avalonia-${{ matrix.rid }}
|
||||
tar -C out -czf out/Paca-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }}
|
||||
# D4: MACOS_P12/KEYCHAIN_PASS/NOTARY_PROFILE 시크릿 등록 시 codesign+notarytool+DMG 스텝 추가
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.rid }}
|
||||
path: out/mediabrowser-${{ matrix.rid }}.tar.gz
|
||||
path: out/Paca-${{ matrix.rid }}.tar.gz
|
||||
|
||||
# ── Android APK (D2 — 서명 키스토어 시크릿 없으면 미서명 APK) ──
|
||||
android:
|
||||
|
|
@ -137,7 +137,7 @@ jobs:
|
|||
else
|
||||
echo "::notice::ANDROID_KEYSTORE 시크릿 없음 — 미서명 APK 생성"
|
||||
fi
|
||||
dotnet publish VideoDownloader.Mobile -f net10.0-android -c Release \
|
||||
dotnet publish src/Paca.Mobile -f net10.0-android -c Release \
|
||||
-p:AndroidPackageFormat=apk -p:RuntimeIdentifiers=android-arm64 $SIGN_ARGS \
|
||||
-o out/android
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
|
@ -160,7 +160,7 @@ jobs:
|
|||
run: dotnet workload install maui-ios
|
||||
- name: publish ipa (배포 인증서/프로비저닝 시크릿 필요)
|
||||
run: |
|
||||
dotnet publish VideoDownloader.Mobile -f net10.0-ios -c Release \
|
||||
dotnet publish src/Paca.Mobile -f net10.0-ios -c Release \
|
||||
-p:ArchiveOnBuild=true -p:CodesignKey="${{ secrets.IOS_SIGNING_KEY }}" \
|
||||
-p:CodesignProvision="${{ secrets.IOS_PROVISION }}" -o out/ios
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
|
|
|||
32
.gitignore
vendored
32
.gitignore
vendored
|
|
@ -75,19 +75,39 @@ packages/
|
|||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
|
||||
# ── Claude Code (프로젝트 공용 설정은 추적, 로컬/생성물만 제외) ──
|
||||
# settings.json · hooks/ · rules/ · skills/ 는 팀 공유 자산이므로 커밋한다. docs/HARNESS.md 참고.
|
||||
.claude/settings.local.json
|
||||
.claude/.verify-stamp
|
||||
# ── AI 어시스턴트 & 로컬 메타데이터 ────────────────────────────────
|
||||
.claude/
|
||||
.cursor/
|
||||
CLAUDE.local.md
|
||||
.gemini/
|
||||
agy-gui-launch.log
|
||||
launch.ps1
|
||||
|
||||
# ── OS (macOS · Linux 원격 빌드머신 포함) ───────────────────────────
|
||||
# ── OS 및 임시/백업 파일 (macOS · Linux 원격 빌드머신 포함) ───────────
|
||||
.DS_Store
|
||||
._*
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
*.tmp
|
||||
*.bak
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
*.pid
|
||||
crash.dmp
|
||||
|
||||
# ── 배포 사이트 산출물 (deploy-site 가 배포 시점에 생성) ─────────────
|
||||
# ── 배포 사이트 산출물 & 스크래치 파일 ────────────────────────────
|
||||
site/releases.json
|
||||
.playwright-mcp/
|
||||
.wrangler/
|
||||
icon-candidates.html
|
||||
site/icon-candidates.html
|
||||
site/assets/icon_candidates/
|
||||
scratch/
|
||||
scratch_*
|
||||
mobile_*.png
|
||||
paca_live*.png
|
||||
disp*.png
|
||||
current_*.png
|
||||
anr_temp.txt
|
||||
.design-ref/
|
||||
|
|
|
|||
163
AGENTS.md
163
AGENTS.md
|
|
@ -1,90 +1,115 @@
|
|||
# AGENTS.md — VideoDownloader 에이전트 작업 계약
|
||||
# AGENTS.md — Paca 에이전트 작업 계약 (SSOT)
|
||||
|
||||
이 파일은 도구 중립 표준(AGENTS.md)이다. Claude Code · Codex · Cursor 등 어떤 코딩 에이전트든
|
||||
작업 시작 시 이 파일을 읽고, 여기 적힌 명령·규약·완료 기준을 따른다.
|
||||
Claude Code 는 `CLAUDE.md` 가 이 파일을 `@AGENTS.md` 로 임포트해 함께 읽는다.
|
||||
> 모든 AI 코딩 에이전트(AGY, Claude Code, DeepSeek, Cursor 등) 공용 표준 계약.
|
||||
> Claude Code는 `CLAUDE.md`의 `@AGENTS.md`, Cursor는 `.cursorrules`로 본 파일을 참조한다.
|
||||
> 이론적 토대 및 상세 표준: `docs/TDD_THEORY_SSOT.md` (Paca TDD & 디자인 감사 SSOT).
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
---
|
||||
|
||||
브라우저 내장 데스크톱 영상 다운로더 + 모바일 컴패니언. 앱 내 브라우저가 재생 중인 미디어
|
||||
(HLS/DASH/MP4)를 감지 → 쿠키·Referer 를 물려받아 다운로드 → 내장 서버로 휴대폰에 스트리밍/전송.
|
||||
## 0. TDD 절대주의 헌법 (The Supreme Law of TDD)
|
||||
|
||||
| 프로젝트 | TFM | 역할 |
|
||||
|---|---|---|
|
||||
| `VideoDownloader.Core` | net8.0 | 다운로드 엔진 (HLS/DASH 파싱, AES-128, 병렬·이어받기, 감지, ffmpeg 리먹스) |
|
||||
| `VideoDownloader.Browser` | net8.0-windows | WebView2 탭 컴포넌트 |
|
||||
| `VideoDownloader.App` | net8.0-windows | 메인 WPF 앱 (브라우저 + 다운로드 UI + 서버 호스팅) |
|
||||
| `VideoDownloader.Server` | net8.0 | ASP.NET 라이브러리. App 안에서 기동, 모바일/웹에 API 제공 |
|
||||
| `VideoDownloader.Mobile` | net10.0-android (+ios on macOS) | MAUI 컴패니언 |
|
||||
| `VideoDownloader.Mobile.Core` | net8.0 | 모바일 공용 로직 (UI 없이 테스트 가능하게 분리) |
|
||||
| `VideoDownloader.Avalonia` | net8.0 | macOS/Linux 셸 (진행 중) |
|
||||
| `VideoDownloader.Cli` | net8.0 | 엔진 검증용 개발 도구 |
|
||||
| `VideoDownloader.Tests` | net8.0-windows | xUnit. Mobile 을 제외한 전 영역 + E2E |
|
||||
1. **테스트 없는 코드는 존재하지 않는다 (No Production Code Without RED)**:
|
||||
- 실패하는 자동화 테스트(RED)가 존재하지 않는 한, 단 한 줄의 프로덕션 코드도 추가하거나 수정할 수 없다.
|
||||
- Robert C. Martin의 TDD 3대 법칙(Three Laws)을 엄수한다: 실패를 증명할 최소 테스트 작성 → 컴파일 에러를 포함한 실패 확인 → 오직 통과만을 위한 최소 구현.
|
||||
2. **기계적 검증 없는 디자인은 결함이다 (Strict Design Audit in RED)**:
|
||||
- 모든 UI/UX 요소는 눈대중이 아닌 기계적 테스트 게이트(`VisualDesignGateTests`, `IconGlyphDesignSystemTests`, `UiContractUseCasesMegaPipelineTests`)로 사전에 RED화되고 검증되어야 한다.
|
||||
3. **RED 체계의 수명주기 관리 (Pruning & Scenario Evolution)**:
|
||||
- 무의미한 RED 남발(Red Inflation)을 금지한다.
|
||||
- 사소한 단편 단정문은 과감히 추리거나, 실제 사용자 행동(입력창 타이핑, 버튼 클릭, 옵션 변경, 진행률 표시)이 결합된 **복합 시나리오(End-to-End User Journey)**로 통폐합한다.
|
||||
|
||||
## 2. 명령 (이 명령들이 진실이다 — 추측하지 말 것)
|
||||
---
|
||||
|
||||
## 1. 아키텍처 매핑 (Pitchfork Layout: `src/` + `tests/`)
|
||||
|
||||
| 프로젝트 | TFM | 위치 | 역할 |
|
||||
|---|---|---|---|
|
||||
| `Paca.Core` | net8.0 | `src/Paca.Core` | 다운로드 엔진 (HLS/DASH, AES-128, 병렬/이어받기, ffmpeg) |
|
||||
| `Paca.Browser` | net8.0-windows | `src/Paca.Browser` | WebView2 탭 컴포넌트, 브라우저 확장 프로그램 가로채기 |
|
||||
| `Paca.App` | net8.0-windows | `src/Paca.App` | 메인 WPF 데스크톱 앱 (UI + 서버 호스팅, 반응형 테마) |
|
||||
| `Paca.Server` | net8.0 | `src/Paca.Server` | 내장 ASP.NET Core API 서버 |
|
||||
| `Paca.Mobile` / `.Mobile.Core` | net10.0-android / net8.0;net10.0 | `src/Paca.Mobile` / `src/Paca.Mobile.Core` | MAUI 컴패니언 앱 및 공용 로직 |
|
||||
| `Paca.Avalonia` | net8.0 | `src/Paca.Avalonia` | macOS/Linux 셸 |
|
||||
| `Paca.Cli` | net8.0 | `src/Paca.Cli` | CLI 도구 |
|
||||
| `Paca.Tests` | net8.0-windows | `tests/Paca.Tests` | xUnit 전체 테스트 (1,886+ 건 전수 무결성) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 실행 명령
|
||||
|
||||
```powershell
|
||||
dotnet test VideoDownloader.Tests # 전체 검증. 증분 ~13초, 현재 180/180 통과
|
||||
dotnet build VideoDownloader.App # 데스크톱 빌드
|
||||
dotnet run --project VideoDownloader.App # 데스크톱 실행
|
||||
dotnet build VideoDownloader.Mobile -f net10.0-android # 모바일 (MAUI 워크로드 필요)
|
||||
./build/publish.ps1 -Config Release -Version 1.0.0 # Windows 설치자 → out/
|
||||
bash build/remote/build-remote.sh all # macOS/Linux 원격 빌드 → out/remote/
|
||||
dotnet test tests/Paca.Tests # 전체 검증 (1,886건 전수 PASS)
|
||||
dotnet test tests/Paca.Tests --filter "FullyQualifiedName~<이름>" # 단건 검증
|
||||
dotnet build src/Paca.App # 데스크톱 빌드
|
||||
# GUI 앱 실행 (WMI 세션 분리 — 세션 종료 후 창 유지 필수)
|
||||
Stop-Process -Name 'Paca' -Force -ErrorAction SilentlyContinue
|
||||
powershell -NoProfile -Command "Start-Process powershell -ArgumentList '-NoProfile','-WindowStyle','Hidden','-ExecutionPolicy','Bypass','-File','C:\Users\encep\.gemini\agy-gui-launch.ps1','-ExePath','D:\workspace\videodownloader\src\Paca.App\bin\Debug\net8.0-windows\win-x64\Paca.exe','-ProcessName','Paca'"
|
||||
Start-Sleep -Seconds 120
|
||||
Get-Content C:\Users\encep\.gemini\agy-gui-launch.log -Tail 3
|
||||
```
|
||||
|
||||
- 솔루션 전체 빌드(`dotnet build VideoDownloader.slnx`)는 MAUI 워크로드가 있어야 통과한다.
|
||||
워크로드 없이 작업할 땐 프로젝트를 개별 지정한다.
|
||||
- 단위 테스트 하나만: `dotnet test VideoDownloader.Tests --filter "FullyQualifiedName~HlsMuxerWiring"`
|
||||
- 환경: Windows 11 · .NET 8/10 SDK 공존 · ffmpeg PATH 필요 · 셸은 PowerShell 7 (Git Bash 도 사용 가능)
|
||||
---
|
||||
|
||||
## 3. 작업 방식 — TDD 폐쇄루프
|
||||
## 3. 안드레 카파시 4대 원칙 (Karpathy Principles)
|
||||
|
||||
이 저장소는 **RED → GREEN → 증거** 사이클로 개발해 왔다. 이 순서를 지킨다.
|
||||
1. **Think Before Coding**: 모호하면 추측 금지. 코드 위치를 확인하고, 3개 이상 파일 수정 시 플랜 수립.
|
||||
2. **Simplicity First**: 오버엔지니어링 금지. RED를 GREEN으로 바꾸는 최소한의 코드만 작성.
|
||||
3. **Surgical Changes**: 요청 외 주변 코드·주석·공백 무단 수정 금지. 폭발 반경(Blast Radius) 최소화.
|
||||
4. **Goal-Driven Closed Loop**: "다 됐다" 선언 금지. 기계적 검증(Exit 0) 통과 전까지 자율 루프 유지.
|
||||
|
||||
1. **RED**: 변경 전에 실패하는 테스트를 먼저 쓴다. 실패를 눈으로 확인한다.
|
||||
2. **GREEN**: 최소 구현으로 통과시킨다. `dotnet test` 전체가 통과해야 한다.
|
||||
3. **증거**: UI·플랫폼 동작을 바꿨다면 테스트만으로 부족하다. 실제 앱 실행/에뮬레이터/스크린샷으로
|
||||
확인하고, 무엇을 어떻게 확인했는지 보고에 적는다.
|
||||
4. **문서 동기화**: 계획 대비 구현 상태는 `docs/IMPLEMENTATION_AUDIT.md`, 작업 항목은
|
||||
`docs/BACKLOG.md` 체크박스에 반영한다.
|
||||
---
|
||||
|
||||
## 4. 완료 기준 (Definition of Done)
|
||||
## 4. 저가형 모델(Flash/DeepSeek/Lite) 하네스 수칙
|
||||
|
||||
아래를 모두 만족하기 전에는 "완료"라고 보고하지 않는다.
|
||||
- **Token Diet**: 80KB+ 대용량 파일 전체 읽기 금지. `grep_search`로 위치 확인 후 라인 슬라이스(`view_file`).
|
||||
- **Anti-Cheating Guard**: 테스트 삭제, `[Fact(Skip="...")]`, 빈 `catch { }` 엄격 금지 (위반 시 즉시 실패).
|
||||
- **Error Hill-Climbing**: 컴파일 에러(`CSxxxx`) 및 단정 실패 diff의 파일:라인을 1~2턴 내 정밀 타격.
|
||||
- **Subagent Hierarchy**: 넓은 범위 탐색/조사는 초경량 서브에이전트(`invoke_subagent`)로 격리.
|
||||
|
||||
- [ ] `dotnet test VideoDownloader.Tests` — **실패 0건**. 테스트 수는 줄지 않는다(180 이상).
|
||||
- [ ] 빌드 **오류 0**. 새로 만든 경고를 남기지 않는다.
|
||||
(기존 경고: `MainWindow.xaml.cs`·테스트의 CS8602 몇 건, xUnit 분석기 경고 — 늘리지 말 것)
|
||||
- [ ] 동작을 바꿨으면 그 동작을 검증하는 테스트가 있다.
|
||||
- [ ] UI/플랫폼 변경이면 실행 증거가 있다.
|
||||
- [ ] 관련 문서(`docs/`)를 갱신했다.
|
||||
---
|
||||
|
||||
실패한 것을 통과한 것처럼 보고하지 않는다. 막혔으면 막혔다고 쓴다.
|
||||
## 5. TDD 폐쇄 루프 & 초고강도 디자인 감사 완료 기준 (DoD)
|
||||
|
||||
## 5. 코드 규약
|
||||
### 5.1 25대 디자인/인터랙션/접근성 감사 게이트 (Strict Design & Interaction Gate)
|
||||
1. **중앙 정렬 및 시각적 치우침 방지**: 헤더, 툴바, 팝업의 중앙 정렬 요소 오차 0px.
|
||||
2. **컨테이너 오버플로우 방지**: 가로 스크롤 버그(`scrollWidth > clientWidth`) 금지, `TextTrimming="CharacterEllipsis"` 강제.
|
||||
3. **WCAG 2.1/2.2 AA/AAA & APCA 지각 대비**: 텍스트-배경 대비 AA 4.5:1, 고대비 AAA 7.0:1, APCA Lc 60+ 엄수.
|
||||
4. **인풋 필드 규격 및 내부 패딩**: 텍스트 박스 패딩 `Padding="8,6"` 이상, 최소 높이 36px~44px 유지.
|
||||
5. **아이콘-텍스트 수직 정렬**: `VerticalAlignment="Center"` 및 폰트 베이스라인 정합성 100% 일치.
|
||||
6. **타이포그래피 계층구조**: H1(24~32px) → H2(18~22px) → Body(13~15px) → Caption(11~12px) 토큰 일치.
|
||||
7. **Flexbox / Grid 레이아웃 왜곡 방지**: 컬럼 비율 깨짐 및 0픽셀 축소 방지.
|
||||
8. **클릭 타깃 최소 면적**: 기본 인터랙션 요소 최소 32x32px 이상 확보.
|
||||
9. **디자인 철학 일치**: Designpaca / Swiss Typography / Modern Minimalist 토큰 일치.
|
||||
10. **모달 오버레이 무결성**: 딤 오버레이 및 포커스 트랩 보장.
|
||||
11. **WCAG 2.2 타깃 크기 (SC 2.5.8/2.5.5)**: 24x24px 최소 타깃 엄수 및 간격 원 중첩 방지, 중요 액션 44x44px 준수.
|
||||
12. **WCAG 2.2 포커스 시인성 (SC 2.4.11/2.4.13)**: 포커스 링 최소 2px 둘레 및 3:1 대비비, 가림(Obscured) 방지.
|
||||
13. **다크모드 광륜/난시 생리학적 보호**: 완전 검정(#000000) 위 순백(#FFFFFF) 직접 배치 금지, 표면 표고 및 오프화이트(#E2E8F0) 강제.
|
||||
14. **스위스 8pt/4pt 그리드 불변식**: 마진/패딩/간격 4px 단위 배수 엄수 (7px/9px 등 분수/홀수 픽셀 금지).
|
||||
15. **마이크로 타이포그래피 안티지터**: 속도/시간/카운터 실시간 갱신 요소 OpenType `Typography.NumeralAlignment="Tabular"`(`tnum`) 강제.
|
||||
16. **피츠의 법칙 (Fitts's Law) 가장자리 무한 타깃**: 창 캡션 단추 모서리 밀착(마진 0), 주 액션 CTA 타깃 면적 최적화.
|
||||
17. **힉-하이먼 & 밀러의 법칙 인지 밀도**: 툴바 단일 계층 버튼 $7 \pm 2$개 제한, 서랍(Drawer) 위계화 및 3단 카드 청크 분할.
|
||||
18. **WCAG 2.2 드래그 대안 (SC 2.5.7)**: 드래그 탭/큐 이동에 대한 단일 클릭 컨텍스트 메뉴 및 키보드 단축키 대안 보증.
|
||||
19. **WCAG 2.2 중복 입력 방지 (SC 3.3.7)**: 히스토리 피커, 클립보드 자동 인식, DPAPI CredentialVault 자동 채움 지원.
|
||||
20. **Microsoft UIA FastPass 스크린 리더 계약**: 모든 인터랙티브 컨트롤 `AutomationProperties.Name` 매핑 및 장식 요소 배제.
|
||||
21. **윈도우 고대비 모드(WHCM / Forced Colors) WCAG AAA 7.0:1**: 시맨틱 브러시 7.0:1 초고대비 보증.
|
||||
22. **전정기관 보호 움직임 감소(Reduced Motion SC 2.3.3)**: 애니메이션 지속시간 상한(<=300ms) 및 무한 모션 배제.
|
||||
23. **포커스 비가림(Focus Not Obscured SC 2.4.11/2.4.12)**: 스크롤 및 고정 요소에 의한 포커스 요소 가림 원천 차단.
|
||||
24. **모달 포커스 트랩 및 Esc 안전 복원**: 팝업/모달 포커스 순환 및 원래 호출 트리거로 포커스 복원.
|
||||
25. **게슈탈트 근접성 및 최적 행폭(CPL 50~75) 독서 인체공학**: 안구 피로 방지 MaxWidth 제약 및 여백 기반 시각 청킹.
|
||||
|
||||
- **C#**: file-scoped namespace, 4스페이스, Allman 중괄호, `_camelCase` private 필드, nullable enable.
|
||||
세부는 `.editorconfig` 가 강제한다. 공통 빌드 속성은 `Directory.Build.props`.
|
||||
- **주석·커밋 메시지·문서는 한국어.** 기존 코드가 한국어 주석이므로 섞지 않는다.
|
||||
- **테스트**: xUnit. 파일은 `VideoDownloader.Tests/<영역>/<대상>Tests.cs`.
|
||||
영역 = `Browser` · `E2E` · `Infrastructure` · `Mobile` · `Platform` · `Server`.
|
||||
- 에러를 삼키지 않는다. 근본 원인을 고친다. 빈 `catch`, 가짜 폴백 데이터 금지.
|
||||
### 5.2 자율 완료 체크리스트 (Definition of Done)
|
||||
- [ ] `dotnet test tests/Paca.Tests` 실패 0건, 건너뜀 0건 (1,886 전수 통과)
|
||||
- [ ] 빌드 에러 0건, 신규 컴파일러 경고 0건
|
||||
- [ ] 초고강도 디자인 감사 게이트(`VisualDesignGateTests`) 100% 통과
|
||||
- [ ] WMI 분리 런처 기반 GUI 실창 렌더링 검증 완료 (`MainWindowHandle != 0`)
|
||||
- [ ] 문서 동기화 완료 (`docs/TDD_THEORY_SSOT.md`, `docs/IMPLEMENTATION_AUDIT.md`, `docs/BACKLOG.md`)
|
||||
|
||||
## 6. 경계 — 하지 말 것
|
||||
---
|
||||
|
||||
- `.env` 를 커밋하거나 그 값을 출력·로그·문서에 남기지 않는다. 실제 Cloudflare 토큰과
|
||||
빌드머신 SSH 비밀번호가 들어 있다. 템플릿은 `.env.example` 만 추적한다.
|
||||
- `out/`, `publish/`, `bin/`, `obj/` 는 산출물이다. 커밋하지 않고, 내용을 소스로 참조하지 않는다.
|
||||
- `VideoDownloader.App/Assets/*.min.js`(hls/dash/readability)는 서드파티 번들이다. 수정 금지.
|
||||
- 사용자가 요청하지 않은 커밋·푸시·배포를 하지 않는다.
|
||||
- 대규모 리팩터링을 임의로 시작하지 않는다. `MainWindow.xaml.cs` 는 86KB 로 비대하지만,
|
||||
분할은 별도 승인 후 진행한다.
|
||||
## 6. 절대 금기 (Boundaries)
|
||||
|
||||
## 7. 알아둘 함정
|
||||
|
||||
- **WebView2**: 탭마다 인스턴스, Environment 는 공유. dispose 순서를 어기면 UI 스레드에서 죽는다.
|
||||
- **MAUI**: 링커가 안 싣는 타입은 런타임 `FileNotFoundException` 으로만 드러난다
|
||||
(실제 사례: 모달 NavigationPage → `Xamarin.AndroidX.LocalBroadcastManager` 명시 참조로 해결).
|
||||
- **ffmpeg**: stderr 를 읽지 않으면 파이프 버퍼가 차서 hang 한다. 취소 시 `Kill(entireProcessTree)`.
|
||||
- **Avalonia 12.1** 은 .NET 10 SDK 를 요구한다. 8만 있으면 CS0103 으로 실패한다.
|
||||
- 테스트는 `VD_DATA_DIR` 로 사용자 데이터 경로를 격리한다. 실제 사용자 폴더를 건드리지 않는다.
|
||||
- `.env` 커밋/노출 절대 금지 (Cloudflare 토큰, SSH 암호 보호).
|
||||
- `Paca.App/Assets/*.min.js` 서드파티 번들 수정 금지.
|
||||
- 사용자 승인 없는 임의 커밋/푸시/배포 금지.
|
||||
- WebView2: dispose 후 이벤트 콜백 방어 (`null` 체크 + `try`).
|
||||
- 외부 프로세스: `ProcessModels.cs` 헬퍼 필수 (stderr 동시 드레인 + 전체 프로세스 트리 킬).
|
||||
- 테스트 격리: 사용자 경로 접근 시 `VD_DATA_DIR` 환경변수 필수. 외부 제3자 서비스 장애에 깨지지 않도록 테스트 더블/오프라인 피스처 방어 구축.
|
||||
|
|
|
|||
41
CLAUDE.md
41
CLAUDE.md
|
|
@ -1,38 +1,25 @@
|
|||
@AGENTS.md
|
||||
|
||||
# Claude Code 전용 지침
|
||||
# Claude Code 전용 바인딩
|
||||
|
||||
위 계약(AGENTS.md)이 기본이다. 아래는 이 저장소에서 Claude Code 가 쓰는 하네스 설명이다.
|
||||
전체 설계와 운영법은 `docs/HARNESS.md` 에 있다.
|
||||
`AGENTS.md`가 진실의 원천(SSOT)이다. 아래는 Claude Code 전용 훅 및 스킬 바인딩이다.
|
||||
|
||||
## 자동으로 도는 검증 (내가 끌 수 없는 층)
|
||||
## 1. 자동 검증 훅
|
||||
|
||||
- **Stop 훅** — 턴을 끝내려 할 때 소스가 바뀌었으면 `dotnet test` 를 돌린다.
|
||||
실패하면 종료가 차단되고 실패 내용이 돌아온다. **테스트를 깨둔 채로 턴을 끝낼 수 없다.**
|
||||
차단되면 변명하지 말고 고친다. 고칠 수 없으면 왜 못 고치는지 사용자에게 보고한다.
|
||||
- **SessionStart 훅** — 세션 시작 시 브랜치·변경 파일·마지막 검증 상태를 컨텍스트에 넣는다.
|
||||
이 브리핑을 먼저 읽고 지금 저장소가 어떤 상태인지 파악한 뒤 움직인다.
|
||||
- **Stop 훅**: 턴 종료 시 소스 변경 감지 → `dotnet test` 자동 실행. 실패 시 종료 차단 (`exit 2`).
|
||||
- **SessionStart 훅**: 브랜치, 변경 파일, 백로그 상태 자동 브리핑.
|
||||
|
||||
훅 때문에 작업이 막히면 훅을 우회하거나 무력화하지 않는다. 원인을 고친다.
|
||||
## 2. 절차 스킬 (On-Demand)
|
||||
|
||||
## 스킬 — 절차는 여기 있다
|
||||
|
||||
| 스킬 | 언제 |
|
||||
| 스킬 | 사용 시점 |
|
||||
|---|---|
|
||||
| `/tdd` | 기능 추가·버그 수정. RED→GREEN→증거 사이클을 끝까지 진행 |
|
||||
| `/verify` | "다 됐나?" 확인. 빌드+테스트+문서 동기화까지 완료 기준 전체 점검 |
|
||||
| `/release` | Windows 설치자 / 원격 크로스플랫폼 빌드 |
|
||||
| `/tdd` | 기능 추가 및 버그 수정 (RED → GREEN → 증거 사이클) |
|
||||
| `/verify` | 완료 기준 전수 점검 (빌드 + 테스트 + 문서 동기화) |
|
||||
| `/release` | Windows 설치자 및 원격 크로스플랫폼 빌드 |
|
||||
|
||||
절차를 기억에 의존해 재구성하지 말고 해당 스킬을 부른다.
|
||||
## 3. 핵심 규칙
|
||||
|
||||
## 이 저장소에서의 작업 태도
|
||||
- **한국어로 응답** (코드 식별자/기술 용어는 원문 유지).
|
||||
- **대형 파일 토큰 절약**: `MainWindow.xaml.cs`(86KB) 통째 읽기 금지, Grep + 슬라이스 조회.
|
||||
- **치팅 금지**: 테스트 삭제/Skip 금지. 실패 시 실제 출력 숫자를 인용하여 보고.
|
||||
|
||||
- **한국어로 답한다.** 코드 식별자와 기술 용어는 원문 유지.
|
||||
- **계획이 필요한 크기면 플랜 모드**를 먼저 쓴다. 기준: 파일 3개 이상 변경, 또는 공개 API·
|
||||
스레딩·dispose 순서 변경, 또는 어디를 고쳐야 할지 아직 모를 때.
|
||||
- **탐색은 서브에이전트에게** 맡긴다. "이 기능 어디 있지" 류의 넓은 검색은 Explore 로 위임하고
|
||||
결론만 받는다. 파일 위치를 이미 아는 단건 확인은 직접 한다.
|
||||
- **거대 파일 주의**: `MainWindow.xaml.cs`(86KB)·`MainWindow.xaml`(55KB)은 통째로 읽지 말고
|
||||
Grep 으로 위치를 찾아 필요한 구간만 Read 한다.
|
||||
- **테스트를 지우거나 `Skip` 으로 무력화해서 GREEN 을 만들지 않는다.** 그건 회귀를 숨기는 것이다.
|
||||
- 작업 결과를 보고할 때 테스트 수를 실제 출력에서 인용한다. 기억으로 쓰지 않는다.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@
|
|||
|
||||
<!-- 한글 파일명·정렬을 다루므로 InvariantGlobalization 을 절대 켜지 않는다. -->
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
<!-- 모든 플랫폼에서 UTF-8 인코딩 소스코드 해석 강제 (문자열 깨짐 방지) -->
|
||||
<CodePage>65001</CodePage>
|
||||
|
||||
<!-- 제품 브랜딩 메타데이터 -->
|
||||
<Product>Paca</Product>
|
||||
<Company>Paca</Company>
|
||||
<Authors>Paca Team</Authors>
|
||||
<Copyright>Copyright © 2026 Paca</Copyright>
|
||||
|
||||
<!-- 재현 가능한 빌드 + VS 증분 빌드 가속 -->
|
||||
<Deterministic>true</Deterministic>
|
||||
|
|
|
|||
11
Paca.slnx
Normal file
11
Paca.slnx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Solution>
|
||||
<Project Path="src/Paca.App/Paca.App.csproj" />
|
||||
<Project Path="src/Paca.Avalonia/Paca.Avalonia.csproj" />
|
||||
<Project Path="src/Paca.Browser/Paca.Browser.csproj" />
|
||||
<Project Path="src/Paca.Cli/Paca.Cli.csproj" />
|
||||
<Project Path="src/Paca.Core/Paca.Core.csproj" />
|
||||
<Project Path="src/Paca.Mobile/Paca.Mobile.csproj" />
|
||||
<Project Path="src/Paca.Mobile.Core/Paca.Mobile.Core.csproj" />
|
||||
<Project Path="src/Paca.Server/Paca.Server.csproj" />
|
||||
<Project Path="tests/Paca.Tests/Paca.Tests.csproj" />
|
||||
</Solution>
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<Window x:Class="VideoDownloader.App.BookmarkManagerWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="즐겨찾기 관리" Height="520" Width="640"
|
||||
FontFamily="{DynamicResource UiFont}"
|
||||
Background="{DynamicResource WindowBgGradient}"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<Grid Margin="16">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- UC-M18 즐겨찾기 검색 -->
|
||||
<DockPanel Grid.Row="0" Margin="0,0,0,10">
|
||||
<TextBlock Text="이름 또는 URL 검색" Foreground="{DynamicResource TextMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<TextBox x:Name="SearchBox" Style="{StaticResource DarkTextBox}" TextChanged="SearchBox_TextChanged"/>
|
||||
</DockPanel>
|
||||
|
||||
<ListView x:Name="List" Grid.Row="1" Background="Transparent" BorderThickness="0"
|
||||
Foreground="{DynamicResource TextPrimary}"
|
||||
SelectionMode="Extended" KeyDown="List_KeyDown">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0,3">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<Button Style="{StaticResource CardActionButton}" Content="편집" Click="Edit_Click" Margin="4,0,0,0"/>
|
||||
<Button Style="{StaticResource CardActionButton}" Content="삭제" Click="Delete_Click" Margin="4,0,0,0"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" FontWeight="Medium"/>
|
||||
<TextBlock Text="{Binding Url}" Foreground="{DynamicResource TextMuted}" FontSize="10.5" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<!-- UC-M19 Netscape 북마크 HTML 내보내기/가져오기 -->
|
||||
<Button Style="{StaticResource GhostButton}" Content="HTML 내보내기" Click="Export_Click" Margin="0,0,8,0"/>
|
||||
<Button Style="{StaticResource GhostButton}" Content="HTML 가져오기" Click="Import_Click" Margin="0,0,8,0"/>
|
||||
<Button Style="{StaticResource PrimaryButton}" Content="닫기" Width="90" Click="Close_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media.Imaging;
|
||||
using QRCoder;
|
||||
using VideoDownloader.Server;
|
||||
|
||||
namespace VideoDownloader.App.Browser;
|
||||
|
||||
/// <summary>M0 원격 접근 페어링: QR(주소+토큰 동시 전달) + 상태 표시.</summary>
|
||||
public sealed class PairingWindow : Window
|
||||
{
|
||||
public PairingWindow(ServerHost server, string deviceName)
|
||||
{
|
||||
Title = "기기 연결 - MediaBrowser";
|
||||
Width = 420; Height = 560;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
SetResourceReference(FontFamilyProperty, "UiFont"); // 암시 스타일 폰트 강제 제거 후 창 루트 상속으로 통일
|
||||
|
||||
Background = System.Windows.Media.Brushes.White;
|
||||
|
||||
var lanIp = GetLanIp();
|
||||
var url = $"http://{lanIp}:{server.Port}/#t={server.Token}";
|
||||
|
||||
var qrPayload = new QRCodeGenerator().CreateQrCode(url, QRCodeGenerator.ECCLevel.M);
|
||||
var qr = new QRCode(qrPayload);
|
||||
var bmp = qr.GetGraphic(12);
|
||||
|
||||
var stack = new StackPanel { Margin = new Thickness(24) };
|
||||
var title = new TextBlock { Text = "📱 폰 카메라로 QR 스캔", FontSize = 16, FontWeight = FontWeights.Bold, Margin = new Thickness(0, 0, 0, 4) };
|
||||
var sub = new TextBlock { Text = "브라우저에서 바로 열립니다(앱 설치 불필요). 같은 Wi-Fi 에 있어야 합니다.", FontSize = 12, Foreground = System.Windows.Media.Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16) };
|
||||
var img = new Image { Width = 300, Height = 300 };
|
||||
using var ms = new MemoryStream();
|
||||
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
|
||||
ms.Position = 0;
|
||||
img.Source = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
|
||||
|
||||
var urlBox = new TextBox { Text = url, IsReadOnly = true, FontSize = 11, Margin = new Thickness(0, 12, 0, 4), TextWrapping = TextWrapping.Wrap };
|
||||
var copyBtn = new Button { Content = "주소 복사", Padding = new Thickness(14, 6, 14, 6), HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 8, 0, 0) };
|
||||
copyBtn.Click += (_, _) => { try { Clipboard.SetText(url); } catch { } };
|
||||
var note = new TextBlock { Text = $"포트 {server.Port} · Windows 방화벽 확인이 뜨면 '허용'을 선택하세요\n토큰은 이 기기와 페어링된 폰에서만 유효합니다",
|
||||
FontSize = 11, Foreground = System.Windows.Media.Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 12, 0, 0) };
|
||||
|
||||
stack.Children.Add(title);
|
||||
stack.Children.Add(sub);
|
||||
stack.Children.Add(img);
|
||||
stack.Children.Add(urlBox);
|
||||
stack.Children.Add(copyBtn);
|
||||
stack.Children.Add(note);
|
||||
Content = stack;
|
||||
}
|
||||
|
||||
/// <summary>LAN IPv4(게이트웨이 있는 첫 후보) — 없으면 loopback.</summary>
|
||||
private static string GetLanIp()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
|
||||
socket.Connect("8.8.8.8", 65530); // 외부 행선지 지정 → 라우팅되는 인터페이스 IP 획득(패킷 전송 없음)
|
||||
return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString() ?? "127.0.0.1";
|
||||
}
|
||||
catch { return "127.0.0.1"; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using VideoDownloader.App.Browser.Features;
|
||||
using VideoDownloader.Core.Config;
|
||||
using Wv2 = Microsoft.Web.WebView2.Wpf;
|
||||
|
||||
namespace VideoDownloader.App.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// T2-10 미디어 재생목록 플레이어: Direct(MP4)/HLS(hls.js)/DASH(dash.js) 를
|
||||
/// 생성된 HTML 플레이어로 재생. 곡 종료 시 다음 트랙 자동 재생(연속 재생).
|
||||
/// </summary>
|
||||
public sealed class PlayerWindow : Window
|
||||
{
|
||||
private readonly CoreWebView2Environment _env;
|
||||
private readonly AppConfig _config;
|
||||
private readonly Wv2.WebView2 _web = new() { DefaultBackgroundColor = System.Drawing.Color.Black };
|
||||
private MediaPlaylist _playlist;
|
||||
private readonly TextBlock _title;
|
||||
|
||||
public PlayerWindow(CoreWebView2Environment env, AppConfig config, MediaPlaylist playlist)
|
||||
{
|
||||
_env = env;
|
||||
_config = config;
|
||||
_playlist = playlist;
|
||||
|
||||
Title = "재생목록 - MediaBrowser";
|
||||
Width = 900; Height = 620;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
SetResourceReference(FontFamilyProperty, "UiFont"); // 암시 스타일 폰트 강제 제거 후 창 루트 상속으로 통일
|
||||
|
||||
|
||||
var bar = new Border { Background = System.Windows.Media.Brushes.Black, Padding = new Thickness(8, 6, 8, 6) };
|
||||
var dock = new DockPanel();
|
||||
var prev = MkBtn("⏮ 이전", () => { _playlist.Previous(); PlayCurrent(); });
|
||||
var next = MkBtn("⏭ 다음", () => { _playlist.Next(); PlayCurrent(); });
|
||||
var close = MkBtn("✕ 닫기", () => Close());
|
||||
_title = new TextBlock { Foreground = System.Windows.Media.Brushes.LightGray, FontSize = 12, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Margin = new Thickness(8, 0, 8, 0) };
|
||||
DockPanel.SetDock(close, Dock.Right);
|
||||
DockPanel.SetDock(next, Dock.Right);
|
||||
DockPanel.SetDock(prev, Dock.Left);
|
||||
dock.Children.Add(close);
|
||||
dock.Children.Add(next);
|
||||
dock.Children.Add(prev);
|
||||
dock.Children.Add(_title);
|
||||
bar.Child = dock;
|
||||
|
||||
var grid = new Grid { RowDefinitions = { new RowDefinition { Height = GridLength.Auto }, new RowDefinition { Height = new GridLength(1, GridUnitType.Star) } } };
|
||||
Grid.SetRow(bar, 0); Grid.SetRow(_web, 1);
|
||||
grid.Children.Add(bar);
|
||||
grid.Children.Add(_web);
|
||||
Content = grid;
|
||||
|
||||
Loaded += async (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = _env.CreateCoreWebView2ControllerOptions();
|
||||
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true;
|
||||
await _web.EnsureCoreWebView2Async(_env, options);
|
||||
var cv = _web.CoreWebView2!;
|
||||
cv.WebMessageReceived += (_, we) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = we.WebMessageAsJson;
|
||||
if (string.IsNullOrEmpty(msg)) return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(msg);
|
||||
if (doc.RootElement.TryGetProperty("vdEnded", out var e) && e.GetBoolean())
|
||||
{
|
||||
Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
var cur = _playlist.Current;
|
||||
if (cur != null) _playlist.MarkPlayed(cur);
|
||||
// UC-M12 반복 모드: one=현 트랙 재생, all=끝에서 처음으로 순환, none=끝에서 정지
|
||||
var mode = _config.PlaylistLoopMode;
|
||||
if (mode == "one") { PlayCurrent(); return; }
|
||||
var atEnd = _playlist.Tracks.Count > 0 && _playlist.CurrentIndex >= _playlist.Tracks.Count - 1;
|
||||
if (atEnd && mode != "all") return;
|
||||
_playlist.Next();
|
||||
PlayCurrent();
|
||||
}));
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
PlayCurrent();
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
protected override void OnSourceInitialized(EventArgs e)
|
||||
{
|
||||
base.OnSourceInitialized(e);
|
||||
try
|
||||
{
|
||||
var hwnd = new WindowInteropHelper(this).Handle;
|
||||
int dark = 1;
|
||||
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
|
||||
|
||||
private Button MkBtn(string text, Action onClick)
|
||||
{
|
||||
var b = new Button
|
||||
{
|
||||
Content = text,
|
||||
Padding = new Thickness(10, 3, 10, 3),
|
||||
Margin = new Thickness(4, 0, 4, 0),
|
||||
Background = System.Windows.Media.Brushes.Transparent,
|
||||
Foreground = System.Windows.Media.Brushes.White,
|
||||
BorderBrush = System.Windows.Media.Brushes.Gray
|
||||
};
|
||||
b.Click += (_, _) => onClick();
|
||||
return b;
|
||||
}
|
||||
|
||||
public void PlayCurrent()
|
||||
{
|
||||
var track = _playlist.Current;
|
||||
if (track == null) { _title.Text = "재생목록이 비었습니다"; return; }
|
||||
_title.Text = $"{_playlist.CurrentIndex + 1}/{_playlist.Tracks.Count} · {track.Title} [{track.Kind}]";
|
||||
try { _web.CoreWebView2?.NavigateToString(BuildPlayerHtml(track)); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>트랙 재생 HTML — HLS 는 hls.js, DASH 는 dash.js 번들 인라인.</summary>
|
||||
public static string BuildPlayerHtml(MediaTrack track)
|
||||
{
|
||||
var url = track.Url.Replace("&", "&").Replace("\"", """).Replace("<", "<");
|
||||
var isHls = track.Kind == "Hls" || track.Url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase);
|
||||
var isDash = track.Kind == "Dash" || track.Url.Contains(".mpd", StringComparison.OrdinalIgnoreCase);
|
||||
var hls = isHls ? ReadResource("VideoDownloader.App.Assets.hls.min.js") : "";
|
||||
var dash = isDash ? ReadResource("VideoDownloader.App.Assets.dash.all.min.js") : "";
|
||||
|
||||
var loader = "";
|
||||
if (isHls) loader = "if (Hls.isSupported()) { var h = new Hls(); h.loadSource(SRC); h.attachMedia(v); } else v.src = SRC;";
|
||||
else if (isDash) loader = "var p = dashjs.MediaPlayer().create(); p.initialize(v, SRC, true);";
|
||||
else loader = "v.src = SRC;";
|
||||
|
||||
var html = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>" +
|
||||
"body{background:#000;margin:0;overflow:hidden}video{width:100vw;height:100vh}" +
|
||||
"</style></head><body><video id='v' controls autoplay playsinline></video>" +
|
||||
"<script>var SRC=" + System.Text.Json.JsonSerializer.Serialize(track.Url) + ";" +
|
||||
(hls.Length > 0 ? "<script>" + hls + "</script>" : "") +
|
||||
(dash.Length > 0 ? "<script>" + dash + "</script>" : "") +
|
||||
"<script>(function(){var v=document.getElementById('v');" + loader +
|
||||
"v.addEventListener('ended',function(){try{window.chrome.webview.postMessage({vdEnded:true});}catch(e){}});" +
|
||||
"v.addEventListener('error',function(){try{window.chrome.webview.postMessage({vdEnded:true});}catch(e){}});})();" +
|
||||
"</script></body></html>";
|
||||
return html;
|
||||
}
|
||||
|
||||
private static string ReadResource(string name)
|
||||
{
|
||||
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(name);
|
||||
return stream == null ? "" : new StreamReader(stream).ReadToEnd();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace VideoDownloader.App;
|
||||
|
||||
/// <summary>컬렉션 카운트 → 빈 상태 표시용 (0일 때 Visible)</summary>
|
||||
public class InverseCountToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> (value is int i && i > 0) ? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>다운로드 상태 문자열 → 표시 여부. parameter="done" 이면 완료일 때만 Visible(UC-A11 완료 아이콘).</summary>
|
||||
public class StatusToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public static readonly StatusToVisibilityConverter Done = new();
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is string s && s == "완료" ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>UC-D10 세그먼트 진행도(0~1) → 블록 배경색(진행 중 강조/대기 트랙).</summary>
|
||||
public class SegmentFillConverter : IValueConverter
|
||||
{
|
||||
public static readonly SegmentFillConverter Instance = new();
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is double d && d >= 1.0
|
||||
? System.Windows.Media.Brushes.DodgerBlue
|
||||
: value is double p && p > 0
|
||||
? (System.Windows.Media.Brush)System.Windows.Application.Current.FindResource("AccentBrush")
|
||||
: (System.Windows.Media.Brush)System.Windows.Application.Current.FindResource("TrackBrush");
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
using System.Text.Json;
|
||||
using VideoDownloader.Core.Config;
|
||||
|
||||
namespace VideoDownloader.App.Core;
|
||||
|
||||
/// <summary>%AppData%/VideoDownloader/config.json 으로 설정 영속화</summary>
|
||||
public static class ConfigStore
|
||||
{
|
||||
private static string Dir => AppPaths.DataDir;
|
||||
private static string FilePath => Path.Combine(Dir, "config.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
|
||||
|
||||
public static AppConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(FilePath))
|
||||
return JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(FilePath)) ?? new AppConfig();
|
||||
}
|
||||
catch { /* 손상 시 기본값 */ }
|
||||
return new AppConfig();
|
||||
}
|
||||
|
||||
public static void Save(AppConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Dir);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(config, JsonOpts));
|
||||
}
|
||||
catch { /* 저장 실패는 무시 */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<Window x:Class="VideoDownloader.App.CrashReportDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="예기치 않은 오류" Height="380" Width="560"
|
||||
FontFamily="{DynamicResource UiFont}"
|
||||
Background="{DynamicResource WindowBgGradient}"
|
||||
WindowStartupLocation="CenterScreen" ShowInTaskbar="False" Topmost="True">
|
||||
<Grid Margin="18">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,12">
|
||||
<TextBlock Text="" FontFamily="{DynamicResource IconFont}" FontSize="22"
|
||||
Foreground="{DynamicResource ErrorBrush}" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
<TextBlock Text="문제가 발생해 작업을 계속하지 못했습니다" Style="{StaticResource Type.Body}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- UC-N05 오류 상세 펼침 -->
|
||||
<Expander Grid.Row="1" Header="상세 정보" Foreground="{DynamicResource TextSecondary}">
|
||||
<TextBox x:Name="DetailBox" Style="{StaticResource DarkTextBox}" IsReadOnly="True"
|
||||
TextWrapping="NoWrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto" FontFamily="{DynamicResource MonoFont}" FontSize="11"/>
|
||||
</Expander>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Style="{StaticResource GhostButton}" Content="오류 복사" Click="Copy_Click" Margin="0,0,8,0"/>
|
||||
<Button Style="{StaticResource PrimaryButton}" Content="닫기" Width="90" IsCancel="True" Click="Close_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,314 +0,0 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using VideoDownloader.Browser;
|
||||
using VideoDownloader.Browser.Tabs;
|
||||
using VideoDownloader.Core.Config;
|
||||
using Wv2 = Microsoft.Web.WebView2.Wpf;
|
||||
using TabItem = VideoDownloader.Browser.Tabs.TabItem;
|
||||
|
||||
namespace VideoDownloader.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 탭별 WebView2 생명주기·이벤트 배선 전담(재구성 ④ — MainWindow 갓-클래스 해체).
|
||||
/// 일반 브라우저 동작(제목/URL/로딩/오디오/검색세션/줌 적용/초기 탐색/뉴윈도우→탭)은
|
||||
/// 이 서비스가, 앱 고유 기능(미디어 감지/다운로드 라우팅/소셜/기록/주소창 반영)은
|
||||
/// 이벤트 훅으로 호스트(MainWindow)가 주입한다.
|
||||
/// </summary>
|
||||
public sealed class TabViewService
|
||||
{
|
||||
private readonly CoreWebView2Environment _env;
|
||||
private readonly AppConfig _config;
|
||||
private readonly Panel _host;
|
||||
private readonly SiteZoomStore _zoomStore;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly Dictionary<TabItem, Task> _init = new();
|
||||
|
||||
/// <summary>뉴윈도우(팝업) 요청 시 새 탭을 만들어 반환하는 공급자(호스트가 TabManager 에 연결).</summary>
|
||||
public Func<Task<TabItem>>? NewTabProvider { get; set; }
|
||||
|
||||
/// <summary>탭 CoreWebView2 초기화 완료(호스트가 감지 등 탭별 구독 부착).</summary>
|
||||
public event Action<TabItem, CoreWebView2>? TabCoreReady;
|
||||
|
||||
/// <summary>페이지 내 a[download] 등 다운로드 시작(호스트가 저장 폴더로 라우팅).</summary>
|
||||
public event Action<CoreWebView2, CoreWebView2DownloadStartingEventArgs>? DownloadStarting;
|
||||
|
||||
/// <summary>URL 확정(SourceChanged). 인자: (탭, url).</summary>
|
||||
public event Action<TabItem, string>? UrlChanged;
|
||||
|
||||
/// <summary>문서 타이틀 확정(DocumentTitleChanged). 인자: (탭, title).</summary>
|
||||
public event Action<TabItem, string>? TitleChanged;
|
||||
|
||||
/// <summary>탭 뒤로/앞으로 가능 상태 변화(네비 버튼 갱신용).</summary>
|
||||
public event Action? HistoryChanged;
|
||||
|
||||
/// <summary>우클릭 메뉴 요청 — 호스트가 WPF ContextMenu 로 재구성해 표시(Handled=true).</summary>
|
||||
public event Action<CoreWebView2, CoreWebView2ContextMenuRequestedEventArgs>? ContextMenuBuild;
|
||||
|
||||
/// <summary>T2-7 부스트: URL → 주입할 JS(강제 다크/커스텀 CSS). 빈 문자열이면 미적용.</summary>
|
||||
public Func<string?, string>? BoostScriptProvider { get; set; }
|
||||
|
||||
/// <summary>T2-6 Glance: Alt+클릭한 링크 URL 요청.</summary>
|
||||
public event Action<CoreWebView2, string>? GlanceRequested;
|
||||
|
||||
/// <summary>T2-4 리더 내 낭독(TTS) 요청 — 리더 본문 텍스트.</summary>
|
||||
public event Action<CoreWebView2, string>? SpeakRequested;
|
||||
|
||||
/// <summary>T3-15 주소창 진행 바: 네비게이션 시작(전 탭 집계).</summary>
|
||||
public event Action? NavigationStarted;
|
||||
|
||||
/// <summary>T3-15 주소창 진행 바: 네비게이션 완료(전 탭 집계).</summary>
|
||||
public event Action? NavigationCompleted;
|
||||
|
||||
/// <summary>T3-11 사이트별 권한 정책(출처×종류 기억).</summary>
|
||||
public PermissionGate Permissions { get; set; } = new();
|
||||
|
||||
/// <summary>T3-11 Ask 판정 시 호스트 다이얼로그 — State/Handled/SavesInProfile 설정은 호스트 책임.</summary>
|
||||
public event Action<CoreWebView2, CoreWebView2PermissionRequestedEventArgs>? PermissionPrompt;
|
||||
|
||||
/// <summary>T3-14 추적 방지 블랙리스트(교체 주입 가능).</summary>
|
||||
public TrackerBlocklist TrackerBlocklist { get; set; } = new();
|
||||
|
||||
/// <summary>T3-14 차단 카운터(실드 배지·팝업 데이터).</summary>
|
||||
public TrackerCounter TrackerCounter { get; } = new();
|
||||
|
||||
/// <summary>T3-14 차단 발생 알림(배지 갱신).</summary>
|
||||
public event Action? TrackerBlocked;
|
||||
|
||||
/// <summary>T3-3 InPrivate 탭: 다음에 만들어지는 탭에 적용(1회성).</summary>
|
||||
public bool NextTabIsPrivate { get; set; }
|
||||
|
||||
public TabViewService(CoreWebView2Environment env, AppConfig config, Panel host,
|
||||
SiteZoomStore zoomStore, Dispatcher dispatcher)
|
||||
{
|
||||
_env = env;
|
||||
_config = config;
|
||||
_host = host;
|
||||
_zoomStore = zoomStore;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public TabItem CreateTabItem() => new();
|
||||
|
||||
/// <summary>탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드)</summary>
|
||||
public void AttachView(TabItem tab)
|
||||
{
|
||||
var view = new Wv2.WebView2 { DefaultBackgroundColor = System.Drawing.Color.Black };
|
||||
tab.View = view;
|
||||
_host.Children.Add(view);
|
||||
view.Visibility = tab.IsActive ? Visibility.Visible : Visibility.Collapsed;
|
||||
_init[tab] = InitCoreAsync(tab);
|
||||
}
|
||||
|
||||
/// <summary>부스트 즉시 재적용(토글 직후). 비동기 fire-and-forget.</summary>
|
||||
public void ApplyBoostNow(TabItem tab)
|
||||
{
|
||||
var cv = tab.Core;
|
||||
if (cv == null) return;
|
||||
var js = BoostScriptProvider?.Invoke(cv.Source) ?? "";
|
||||
if (js.Length > 0 && !tab.IsReader) _ = cv.ExecuteScriptAsync(js);
|
||||
else if (js.Length == 0) _ = cv.ExecuteScriptAsync("(function(){try{document.getElementById('vd-boost')?.remove();}catch(e){}})();");
|
||||
}
|
||||
|
||||
/// <summary>탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용).</summary>
|
||||
public async Task WaitTabReady(TabItem tab)
|
||||
{
|
||||
if (tab.Core != null) return;
|
||||
if (_init.TryGetValue(tab, out var t)) { try { await t; } catch { } return; }
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (tab.Core == null && sw.ElapsedMilliseconds < 15000)
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
/// <summary>탭 제거 시 뷰를 호스트에서 떼고 재오픈 가능 상태로(null) 만든다.</summary>
|
||||
public void RemoveView(TabItem tab)
|
||||
{
|
||||
if (tab.View != null)
|
||||
{
|
||||
_host.Children.Remove(tab.View);
|
||||
tab.View = null; // 재오픈 시 새 뷰로 재생성(ReopenClosedTab → AttachView)
|
||||
}
|
||||
_init.Remove(tab);
|
||||
}
|
||||
|
||||
private async Task InitCoreAsync(TabItem tab)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = _env.CreateCoreWebView2ControllerOptions();
|
||||
if (_config.PrivateMode || tab.IsPrivate || NextTabIsPrivate) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
|
||||
if (!tab.IsPrivate && NextTabIsPrivate) tab.IsPrivate = true;
|
||||
NextTabIsPrivate = false;
|
||||
await tab.View!.EnsureCoreWebView2Async(_env, options);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return; // 초기화 실패 탭 — 호스트가 로그 표시는 TitleChanged 로 알림 없이 둔다(기존 동작)
|
||||
}
|
||||
|
||||
var cv = tab.Core!;
|
||||
cv.Settings.AreDevToolsEnabled = true;
|
||||
|
||||
// T2-6 Glance: Alt+클릭 → 링크를 postMessage 로 전달 (캡처 단계에서 preventDefault)
|
||||
const string glanceScript = """
|
||||
(function(){ try { document.addEventListener('click', function(e){
|
||||
if (e.altKey) { var a = e.target && e.target.closest ? e.target.closest('a') : null;
|
||||
if (a && a.href) { e.preventDefault(); e.stopPropagation(); window.chrome.webview.postMessage({ vdGlance: a.href }); } }
|
||||
}, true); } catch(err){} })();
|
||||
""";
|
||||
try { await cv.AddScriptToExecuteOnDocumentCreatedAsync(glanceScript); } catch { }
|
||||
|
||||
// WebMessage 라우팅 (Glance / 리더 낭독)
|
||||
try
|
||||
{
|
||||
cv.WebMessageReceived += (_, we) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = we.WebMessageAsJson;
|
||||
if (string.IsNullOrEmpty(msg)) return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(msg);
|
||||
if (doc.RootElement.TryGetProperty("vdGlance", out var g) && g.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
GlanceRequested?.Invoke(cv, g.GetString() ?? "");
|
||||
if (doc.RootElement.TryGetProperty("vdSpeak", out var sp) && sp.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
SpeakRequested?.Invoke(cv, sp.GetString() ?? "");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
}
|
||||
catch { }
|
||||
|
||||
// 탭별 검색 세션 (네이티브 Find API)
|
||||
tab.FindSession = new FindSession(new WebView2FindApi(cv, _env));
|
||||
tab.FindSession.StateChanged += () => _dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
if (tab.IsActive && tab.FindSession != null) FindStateChanged?.Invoke(tab);
|
||||
}));
|
||||
|
||||
// 네비게이션 상태
|
||||
cv.NavigationStarting += (_, _) => _dispatcher.BeginInvoke(new Action(() => { tab.IsLoading = true; NavigationStarted?.Invoke(); }));
|
||||
cv.NavigationCompleted += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
tab.IsLoading = false;
|
||||
NavigationCompleted?.Invoke();
|
||||
try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7 사이트별 줌
|
||||
try // T2-7 부스트(강제 다크/커스텀 CSS) 적용
|
||||
{
|
||||
var boostJs = BoostScriptProvider?.Invoke(cv.Source) ?? "";
|
||||
if (boostJs.Length > 0 && !tab.IsReader) _ = cv.ExecuteScriptAsync(boostJs);
|
||||
}
|
||||
catch { }
|
||||
}));
|
||||
cv.HistoryChanged += (_, _) => HistoryChanged?.Invoke();
|
||||
|
||||
// T3-11 사이트별 권한: 정책 Ask 만 프롬프트, Allow/Deny 는 즉시 응답
|
||||
cv.PermissionRequested += (_, e) =>
|
||||
{
|
||||
var kind = e.PermissionKind.ToString();
|
||||
var origin = OriginOf(e.Uri);
|
||||
switch (Permissions.Decide(kind, origin))
|
||||
{
|
||||
case PermissionVerdict.Allow:
|
||||
e.State = CoreWebView2PermissionState.Allow; e.Handled = true;
|
||||
break;
|
||||
case PermissionVerdict.Deny:
|
||||
e.State = CoreWebView2PermissionState.Deny; e.Handled = true;
|
||||
break;
|
||||
default:
|
||||
PermissionPrompt?.Invoke(cv, e); // 호스트 다이얼로그가 State/Handled 설정
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// T3-14 추적 방지: 블랙리스트 요청 차단 + 카운터
|
||||
cv.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
|
||||
cv.WebResourceRequested += (_, e) =>
|
||||
{
|
||||
Uri u;
|
||||
try { u = new Uri(e.Request.Uri); } catch { return; }
|
||||
if (!TrackerBlocklist.Matches(u)) return;
|
||||
try { e.Response = _env.CreateWebResourceResponse(null, 204, "Blocked", ""); } catch { }
|
||||
TrackerCounter.Record(e.Request.Uri);
|
||||
_dispatcher.BeginInvoke(new Action(() => TrackerBlocked?.Invoke()));
|
||||
};
|
||||
|
||||
cv.SourceChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
var url = cv.Source ?? "";
|
||||
tab.Url = url;
|
||||
UrlChanged?.Invoke(tab, url);
|
||||
}));
|
||||
cv.DocumentTitleChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
TitleChanged?.Invoke(tab, cv.DocumentTitle ?? "");
|
||||
}));
|
||||
|
||||
// T1-8 오디오 표시/뮤트
|
||||
try
|
||||
{
|
||||
cv.IsDocumentPlayingAudioChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
try { tab.IsAudioPlaying = cv.IsDocumentPlayingAudio; } catch { }
|
||||
}));
|
||||
cv.IsMutedChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
try { tab.IsMuted = cv.IsMuted; } catch { }
|
||||
}));
|
||||
}
|
||||
catch { }
|
||||
|
||||
// 뉴윈도우(팝업) → 새 탭 (deferral 필수 — 탭 생성이 비동기)
|
||||
cv.NewWindowRequested += async (_, ne) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var deferral = ne.GetDeferral();
|
||||
try
|
||||
{
|
||||
var newTab = NewTabProvider != null ? await NewTabProvider() : null;
|
||||
if (newTab != null)
|
||||
{
|
||||
await WaitTabReady(newTab);
|
||||
ne.NewWindow = newTab.Core;
|
||||
}
|
||||
ne.Handled = true;
|
||||
}
|
||||
finally { deferral.Complete(); }
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { ne.Handled = true; if (ne.Uri != null) cv.Navigate(ne.Uri); } catch { }
|
||||
}
|
||||
};
|
||||
|
||||
// 페이지 내 다운로드 → 호스트 훅
|
||||
cv.DownloadStarting += (_, de) => DownloadStarting?.Invoke(cv, de);
|
||||
|
||||
// 우클릭 메뉴 → 호스트가 커스텀 메뉴 구성(기본 메뉴 대체, T2-12/T3-5)
|
||||
cv.ContextMenuRequested += (_, ce) =>
|
||||
{
|
||||
ce.Handled = true;
|
||||
ContextMenuBuild?.Invoke(cv, ce);
|
||||
};
|
||||
|
||||
// 초기 URL (세션 복원)
|
||||
if (!string.IsNullOrEmpty(tab.Url) && string.IsNullOrEmpty(cv.Source))
|
||||
{
|
||||
try { cv.Navigate(tab.Url); } catch { }
|
||||
}
|
||||
|
||||
TabCoreReady?.Invoke(tab, cv);
|
||||
}
|
||||
|
||||
/// <summary>활성 탭 검색 상태 표시 요청(FindStatus 텍스트 갱신 — 호스트가 구독).</summary>
|
||||
public event Action<TabItem>? FindStateChanged;
|
||||
|
||||
/// <summary>권한 기준 출처(scheme://host:port) 추출.</summary>
|
||||
public static string OriginOf(string? uri)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uri)) return "";
|
||||
try { var u = new Uri(uri); return u.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped); }
|
||||
catch { return uri; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
<Window x:Class="VideoDownloader.App.SettingsWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="설정" Height="680" Width="860"
|
||||
FontFamily="{DynamicResource UiFont}"
|
||||
Background="{DynamicResource WindowBgGradient}"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="CanResize" ShowInTaskbar="False"
|
||||
UseLayoutRounding="True"
|
||||
KeyboardNavigation.TabNavigation="Cycle"
|
||||
WindowStyle="None"
|
||||
AutomationProperties.Name="설정 창">
|
||||
|
||||
<WindowChrome.WindowChrome>
|
||||
<WindowChrome CaptionHeight="0"
|
||||
ResizeBorderThickness="6"
|
||||
GlassFrameThickness="1"
|
||||
CornerRadius="0"
|
||||
UseAeroCaptionButtons="False"
|
||||
NonClientFrameEdges="None"/>
|
||||
</WindowChrome.WindowChrome>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 캡션 (드래그 핸들 + 검색 + 닫기) -->
|
||||
<Border Grid.Row="0" Background="{DynamicResource PanelBg}"
|
||||
BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||
Padding="22,11,0,11" MouseLeftButtonDown="TitleBar_MouseDown">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource CloseButton}"
|
||||
Content="" ToolTip="닫기" Click="Close_Click"
|
||||
AutomationProperties.Name="설정 창 닫기"/>
|
||||
<!-- UC-S01 설정 검색 -->
|
||||
<TextBox x:Name="SettingsSearchBox" DockPanel.Dock="Right" Style="{StaticResource DarkTextBox}"
|
||||
Width="220" FontSize="12" Padding="10,5" Margin="12,0,8,0"
|
||||
TextChanged="SettingsSearchBox_TextChanged" ToolTip="설정 항목 검색"
|
||||
AutomationProperties.Name="설정 검색"/>
|
||||
<TextBlock Text="설정" Foreground="{DynamicResource TextPrimary}"
|
||||
FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" Margin="18,14,18,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="170"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- UC-S02 좌측 카테고리 내비게이션 -->
|
||||
<ListBox x:Name="SettingsNavList" Grid.Column="0" Background="Transparent" BorderThickness="0"
|
||||
SelectionChanged="SettingsNav_SelectionChanged"
|
||||
AutomationProperties.Name="설정 카테고리">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" FontSize="12.5" Padding="10,7" Foreground="{DynamicResource TextSecondary}"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="7" Padding="10,7" Margin="0,1">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
</ListBox>
|
||||
|
||||
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto" Padding="14,0,4,0">
|
||||
<StackPanel>
|
||||
|
||||
<!-- ==================== 외관 ==================== -->
|
||||
<StackPanel x:Name="SectionAppearance" Margin="0,0,0,20" Tag="외관">
|
||||
<TextBlock Text="외관" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
|
||||
|
||||
<!-- UC-T01/T02/T06 테마: 다크/라이트/시스템/고대비 콤보박스 — 즉시 적용 -->
|
||||
<TextBlock Text="테마" Style="{StaticResource Type.Caption}"/>
|
||||
<ComboBox x:Name="ThemeModeBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
|
||||
Margin="0,4,0,8" SelectionChanged="ThemeModeBox_SelectionChanged"
|
||||
AutomationProperties.Name="테마 선택" AutomationProperties.HelpText="선택 즉시 적용됩니다">
|
||||
<ComboBoxItem Content="다크" Tag="dark"/>
|
||||
<ComboBoxItem Content="라이트" Tag="light"/>
|
||||
<ComboBoxItem Content="시스템 설정 따르기" Tag="system"/>
|
||||
<ComboBoxItem Content="고대비" Tag="highcontrast"/>
|
||||
</ComboBox>
|
||||
|
||||
<!-- UC-T07 강조색 프리셋 스와치 + 사용자 지정 -->
|
||||
<TextBlock Text="강조색" Style="{StaticResource Type.Caption}"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4,0,4">
|
||||
<RadioButton x:Name="AccentDefault" Style="{StaticResource DarkRadioButton}" Content="기본(보라)" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
|
||||
<RadioButton x:Name="AccentBlue" Style="{StaticResource DarkRadioButton}" Content="파랑" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
|
||||
<RadioButton x:Name="AccentGreen" Style="{StaticResource DarkRadioButton}" Content="초록" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
|
||||
<RadioButton x:Name="AccentPink" Style="{StaticResource DarkRadioButton}" Content="핑크" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
|
||||
</StackPanel>
|
||||
<DockPanel Margin="0,2,0,8">
|
||||
<TextBlock DockPanel.Dock="Right" Text="#RRGGBB" Style="{StaticResource Type.Caption}" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
<TextBox x:Name="AccentColorBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,5"
|
||||
ToolTip="사용자 지정 강조색(#6366F1 등)" AutomationProperties.Name="사용자 지정 강조색"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- UC-T12 사용자 테마 파일 -->
|
||||
<TextBlock Text="사용자 테마 파일(.xaml)" Style="{StaticResource Type.Caption}"/>
|
||||
<DockPanel Margin="0,4,0,0">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,0,4" Click="BrowseThemeFile"
|
||||
AutomationProperties.Name="테마 파일 찾아보기"/>
|
||||
<TextBox x:Name="CustomThemeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- UC-L02 밀도 -->
|
||||
<TextBlock Text="밀도" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
|
||||
<ComboBox x:Name="UiDensityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
|
||||
AutomationProperties.Name="정보 밀도 선택">
|
||||
<ComboBoxItem Content="컴팩트" Tag="compact"/>
|
||||
<ComboBoxItem Content="보통" Tag="normal"/>
|
||||
<ComboBoxItem Content="여유" Tag="comfortable"/>
|
||||
</ComboBox>
|
||||
|
||||
<!-- UC-L25/A13 배율 -->
|
||||
<TextBlock Text="UI 크기 · 글자 확대 (100~200%)" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Slider x:Name="UiScaleSlider" Minimum="1.0" Maximum="2.0" Width="180" ValueChanged="UiScaleSlider_ValueChanged"
|
||||
IsSnapToTickEnabled="True" TickFrequency="0.05"
|
||||
AutomationProperties.Name="UI 크기 배율"/>
|
||||
<TextBlock x:Name="UiScaleLabel" Text="100%" Style="{StaticResource Type.Mono}" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== 글꼴 (UC-F13) ==================== -->
|
||||
<StackPanel x:Name="SectionFont" Margin="0,0,0,20" Tag="글꼴">
|
||||
<TextBlock Text="글꼴" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
|
||||
<TextBlock Text="기본 폰트" Style="{StaticResource Type.Caption}"/>
|
||||
<TextBox x:Name="UiFontFamilyBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,8"
|
||||
ToolTip="예: Pretendard, 나눔고딕" AutomationProperties.Name="기본 폰트"/>
|
||||
<TextBlock Text="한국어 폴백 폰트" Style="{StaticResource Type.Caption}"/>
|
||||
<TextBox x:Name="UiFontKoreanFallbackBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,8"
|
||||
ToolTip="예: Malgun Gothic" AutomationProperties.Name="한국어 폴백 폰트"/>
|
||||
|
||||
<!-- UC-F15 폰트 크기 프리셋 -->
|
||||
<TextBlock Text="폰트 크기" Style="{StaticResource Type.Caption}"/>
|
||||
<ComboBox x:Name="FontSizeBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
|
||||
AutomationProperties.Name="폰트 크기 프리셋">
|
||||
<ComboBoxItem Content="작게 (12)" Tag="12"/>
|
||||
<ComboBoxItem Content="보통 (13)" Tag="13"/>
|
||||
<ComboBoxItem Content="크게 (14)" Tag="14"/>
|
||||
<ComboBoxItem Content="더 크게 (15)" Tag="15"/>
|
||||
</ComboBox>
|
||||
|
||||
<!-- UC-F14 미리보기 -->
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="12" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="미리보기" Style="{StaticResource Type.Caption}"/>
|
||||
<TextBlock x:Name="FontPreviewText" Text="다운로드 완료 — 영상 123개 · 속도 4.5 MB/s"
|
||||
FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== 일반 ==================== -->
|
||||
<StackPanel x:Name="SectionGeneral" Margin="0,0,0,20" Tag="일반">
|
||||
<TextBlock Text="일반" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
|
||||
<TextBlock Text="저장 폴더" Style="{StaticResource Type.Caption}"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,10">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,4,4" Click="BrowseFolder"/>
|
||||
<TextBox Name="FolderBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="홈 페이지 (홈 버튼이 이동할 주소)" Style="{StaticResource Type.Caption}"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,10">
|
||||
<TextBox Name="HomeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</Border>
|
||||
|
||||
<!-- UC-S14 UI 언어 -->
|
||||
<TextBlock Text="UI 언어" Style="{StaticResource Type.Caption}"/>
|
||||
<ComboBox x:Name="LanguageBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,4,0,10"
|
||||
AutomationProperties.Name="UI 언어 선택">
|
||||
<ComboBoxItem Content="한국어" Tag="ko"/>
|
||||
<ComboBoxItem Content="English" Tag="en"/>
|
||||
</ComboBox>
|
||||
|
||||
<StackPanel Margin="0,0,0,10">
|
||||
<TextBlock Text="기본 검색 엔진 (주소창에 검색어 입력 시)" Style="{StaticResource Type.Caption}"/>
|
||||
<ComboBox Name="SearchEngineBox" Style="{StaticResource DarkComboBox}" Margin="0,4,0,4"
|
||||
SelectionChanged="SearchEngineBox_SelectionChanged"/>
|
||||
<TextBlock x:Name="SearchEngineHint" Style="{StaticResource Type.Caption}"
|
||||
Text="주소창에 `g 검색어`, `yt 검색어` 등 키워드를 입력하면 검색 엔진을 즉시 전환할 수 있어요"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- UC-S15 Windows 시작 시 자동 실행 / UC-S16 트레이 최소화 -->
|
||||
<WrapPanel Orientation="Horizontal" Margin="0,2,0,0">
|
||||
<CheckBox Name="AutoStartBox" Style="{StaticResource DarkCheckBox}" Content="Windows 시작 시 자동 실행" Margin="4,4"/>
|
||||
<CheckBox Name="MinimizeToTrayBox" Style="{StaticResource DarkCheckBox}" Content="닫기 버튼으로 트레이로 최소화" Margin="4,4"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== 다운로드 ==================== -->
|
||||
<StackPanel x:Name="SectionDownload" Margin="0,0,0,20" Tag="다운로드">
|
||||
<TextBlock Text="다운로드" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="세그먼트 동시 수" Style="{StaticResource Type.Caption}"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
|
||||
<TextBox Name="ConcurrencyBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Text="최대 동시 항목" Style="{StaticResource Type.Caption}"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
|
||||
<TextBox Name="MaxConcurrentBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- UC-S11 재시도 횟수 -->
|
||||
<StackPanel Grid.Column="4">
|
||||
<TextBlock Text="재시도 횟수" Style="{StaticResource Type.Caption}"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
|
||||
<TextBox Name="RetriesBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="최소 크기 (MB)" Style="{StaticResource Type.Caption}"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
|
||||
<TextBox Name="MinSizeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- UC-D22 속도 상한 -->
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Text="속도 상한 (KB/s, 0=무제한)" Style="{StaticResource Type.Caption}"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
|
||||
<TextBox Name="MaxSpeedBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<WrapPanel Orientation="Horizontal" Margin="0,2,0,10">
|
||||
<CheckBox Name="AutoClearCompletedBox" Style="{StaticResource DarkCheckBox}" Content="완료 항목 자동 치우기" Margin="4,4"/>
|
||||
<CheckBox Name="OpenFolderOnCompleteBox" Style="{StaticResource DarkCheckBox}" Content="완료 시 폴더 자동 열기" Margin="4,4"/>
|
||||
</WrapPanel>
|
||||
|
||||
<!-- UC-D21 다운로드 예약 창 -->
|
||||
<TextBlock Text="다운로드 예약 시간대 (HH:mm-HH:mm, 비우면 항상)" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
|
||||
<TextBox x:Name="ScheduleBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,5" Width="200" HorizontalAlignment="Left"
|
||||
ToolTip="예: 02:00-08:00 — 이 시간대에만 다운로드가 실행됩니다" AutomationProperties.Name="다운로드 예약 시간대"/>
|
||||
|
||||
<!-- UC-D20 자막 언어 -->
|
||||
<TextBlock Text="자막 언어 (ko/en/…, 비우면 자막 안 받음)" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
|
||||
<ComboBox x:Name="SubtitleLangBox" Style="{StaticResource DarkComboBox}" IsEditable="True" Margin="0,4,0,0"
|
||||
AutomationProperties.Name="자막 언어 선택">
|
||||
<ComboBoxItem Content="(자막 없음)" Tag=""/>
|
||||
<ComboBoxItem Content="한국어 (ko)" Tag="ko"/>
|
||||
<ComboBoxItem Content="English (en)" Tag="en"/>
|
||||
<ComboBoxItem Content="日本語 (ja)" Tag="ja"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== 알림 (UC-S17) ==================== -->
|
||||
<StackPanel x:Name="SectionNotify" Margin="0,0,0,20" Tag="알림">
|
||||
<TextBlock Text="알림" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
|
||||
<WrapPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<CheckBox Name="NotifySoundBox" Style="{StaticResource DarkCheckBox}" Content="완료 알림 소리" Margin="4,4"/>
|
||||
<CheckBox Name="DoNotDisturbBox" Style="{StaticResource DarkCheckBox}" Content="방해 금지 (토스트 숨김)" Margin="4,4"/>
|
||||
<CheckBox Name="PauseOnPowerSaverBox" Style="{StaticResource DarkCheckBox}" Content="절전 모드 진입 시 일시정지" Margin="4,4"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="토스트 표시 시간 (초)" Style="{StaticResource Type.Caption}"/>
|
||||
<TextBox x:Name="ToastDurationBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,5" Width="120" HorizontalAlignment="Left"
|
||||
AutomationProperties.Name="토스트 표시 시간"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== 접근성 (UC-A12) ==================== -->
|
||||
<StackPanel x:Name="SectionAccessibility" Margin="0,0,0,20" Tag="접근성">
|
||||
<TextBlock Text="접근성" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
|
||||
<WrapPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<CheckBox Name="ReduceMotionBox" Style="{StaticResource DarkCheckBox}" Content="애니메이션 줄이기" Margin="4,4"/>
|
||||
<CheckBox Name="ShowCaptionsBox" Style="{StaticResource DarkCheckBox}" Content="영상 자막 표시" Margin="4,4"/>
|
||||
<CheckBox Name="FocusSelectsAddressBox" Style="{StaticResource DarkCheckBox}" Content="주소창 포커스 시 전체 선택" Margin="4,4"/>
|
||||
<CheckBox Name="PasteAndGoBox" Style="{StaticResource DarkCheckBox}" Content="붙여넣으면 즉시 이동" Margin="4,4"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="글자 확대 배율 (저시력 지원, 외관 섹션 슬라이더와 연동)" Style="{StaticResource Type.Caption}"/>
|
||||
<Slider x:Name="UiFontScaleSlider" Minimum="1.0" Maximum="2.0" Width="220" HorizontalAlignment="Left"
|
||||
IsSnapToTickEnabled="True" TickFrequency="0.1" ValueChanged="UiFontScaleSlider_ValueChanged"
|
||||
AutomationProperties.Name="글자 확대 배율"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== 브라우저 ==================== -->
|
||||
<StackPanel x:Name="SectionBrowser" Margin="0,0,0,20" Tag="브라우저">
|
||||
<TextBlock Text="브라우저" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
|
||||
<WrapPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<CheckBox Name="TabWheelSwitchBox" Style="{StaticResource DarkCheckBox}" Content="휠로 탭 전환" Margin="4,4"/>
|
||||
<CheckBox Name="ConfirmCloseMultipleTabsBox" Style="{StaticResource DarkCheckBox}" Content="여러 탭 닫기 전 확인" Margin="4,4"/>
|
||||
<CheckBox Name="BookmarkBarVisibleBox" Style="{StaticResource DarkCheckBox}" Content="북마크바 표시" Margin="4,4"/>
|
||||
</WrapPanel>
|
||||
<!-- UC-L18 탭 스트립 위치 -->
|
||||
<TextBlock Text="탭 스트립 위치" Style="{StaticResource Type.Caption}"/>
|
||||
<ComboBox x:Name="TabStripPositionBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,4,0,8"
|
||||
AutomationProperties.Name="탭 스트립 위치 선택">
|
||||
<ComboBoxItem Content="위 (기본)" Tag="top"/>
|
||||
<ComboBoxItem Content="아래" Tag="bottom"/>
|
||||
<ComboBoxItem Content="왼쪽 (세로 탭)" Tag="left"/>
|
||||
</ComboBox>
|
||||
<TextBlock Text="반응할 형식" Style="{StaticResource Type.Caption}" Margin="0,4,0,6"/>
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<CheckBox Name="FmtHls" Style="{StaticResource DarkCheckBox}" Content="HLS (m3u8)" Margin="4,5"/>
|
||||
<CheckBox Name="FmtDash" Style="{StaticResource DarkCheckBox}" Content="DASH (.mpd)" Margin="4,5"/>
|
||||
<CheckBox Name="FmtMp4" Style="{StaticResource DarkCheckBox}" Content="MP4" Margin="4,5"/>
|
||||
<CheckBox Name="FmtWebM" Style="{StaticResource DarkCheckBox}" Content="WebM" Margin="4,5"/>
|
||||
<CheckBox Name="FmtMkv" Style="{StaticResource DarkCheckBox}" Content="MKV" Margin="4,5"/>
|
||||
<CheckBox Name="FmtM4v" Style="{StaticResource DarkCheckBox}" Content="M4V" Margin="4,5"/>
|
||||
<CheckBox Name="FmtMov" Style="{StaticResource DarkCheckBox}" Content="MOV" Margin="4,5"/>
|
||||
<CheckBox Name="FmtAvi" Style="{StaticResource DarkCheckBox}" Content="AVI" Margin="4,5"/>
|
||||
<CheckBox Name="FmtAudio" Style="{StaticResource DarkCheckBox}" Content="오디오 (m4a/mp3/ogg...)" Margin="4,5"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="브라우저 데이터" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
|
||||
<TextBlock x:Name="ProfileModeText" Text="" Style="{StaticResource Type.Caption}" Margin="0,0,0,6"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,0">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="삭제" Padding="12,4" Margin="8,4,4,4"
|
||||
ToolTip="쿠키·localStorage·캐시 삭제 (현재 프로필)" Click="ClearData_Click"/>
|
||||
<TextBox x:Name="ProfilePathBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent" IsReadOnly="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== 고급 (UC-S07 접힘) ==================== -->
|
||||
<Expander x:Name="AdvancedSection" Header="고급" FontSize="14" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,20"
|
||||
ExpandDirection="Down" IsExpanded="False" Tag="고급">
|
||||
<StackPanel Margin="0,10,0,0">
|
||||
<TextBlock Text="ffmpeg 경로" Style="{StaticResource Type.Caption}"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,4">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,4,4" Click="BrowseFfmpeg"/>
|
||||
<TextBox Name="FfmpegBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<TextBlock Text="비워두면 시스템 PATH 의 ffmpeg 사용 (HLS→MP4 리먹스)" Style="{StaticResource Type.Caption}" Margin="0,0,0,10"/>
|
||||
|
||||
<!-- UC-S12 User-Agent / UC-S13 프록시 -->
|
||||
<TextBlock Text="User-Agent" Style="{StaticResource Type.Caption}"/>
|
||||
<TextBox x:Name="UserAgentBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,10"
|
||||
AutomationProperties.Name="User-Agent"/>
|
||||
<TextBlock Text="프록시 서버 (http://host:port)" Style="{StaticResource Type.Caption}"/>
|
||||
<TextBox x:Name="ProxyBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,4"
|
||||
AutomationProperties.Name="프록시 서버"/>
|
||||
<TextBlock Text="프록시·개인정보 보호 모드 변경은 앱 다시 시작 후 적용됩니다" Style="{StaticResource Type.Caption}"/>
|
||||
|
||||
<TextBlock Text="소셜 영상 (YouTube·Instagram·X)" Style="{StaticResource Type.Caption}" Margin="0,10,0,6"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,4">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="재설치" Padding="12,4" Margin="4,4,8,4" ToolTip="yt-dlp 최신 버전으로 다시 다운로드" Click="ReinstallYtDlp_Click"/>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="4,4,4,4" Click="BrowseYtDlp"/>
|
||||
<TextBox Name="YtDlpBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<TextBlock Text="비워두면 자동/PATH 의 yt-dlp 사용" Style="{StaticResource Type.Caption}" Margin="0,0,0,8"/>
|
||||
|
||||
<WrapPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<CheckBox Name="UseCookiesBox" Style="{StaticResource DarkCheckBox}" Content="로그인 필요 영상을 위해 브라우저 쿠키 사용" Margin="4,4"/>
|
||||
<CheckBox Name="AutoInstallBox" Style="{StaticResource DarkCheckBox}" Content="yt-dlp 가 없으면 자동 다운로드" Margin="4,4"/>
|
||||
<CheckBox Name="AutoUpdateBox" Style="{StaticResource DarkCheckBox}" Content="YouTube 차단 시 yt-dlp 자동 갱신 후 재시도" Margin="4,4"/>
|
||||
<CheckBox Name="AutoInstallDenoBox" Style="{StaticResource DarkCheckBox}" Content="Deno(JS 런타임) 자동 설치 — YouTube 차단 해제용" Margin="4,4"/>
|
||||
</WrapPanel>
|
||||
|
||||
<TextBlock Text="기본 화질" Style="{StaticResource Type.Caption}"/>
|
||||
<ComboBox Name="DefaultQualityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,4,0,10">
|
||||
<ComboBoxItem Content="최고 화질 (MP4)" Tag="best"/>
|
||||
<ComboBoxItem Content="1080p" Tag="1080"/>
|
||||
<ComboBoxItem Content="720p" Tag="720"/>
|
||||
<ComboBoxItem Content="480p" Tag="480"/>
|
||||
<ComboBoxItem Content="360p" Tag="360"/>
|
||||
<ComboBoxItem Content="오디오만 (MP3)" Tag="audio"/>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Text="YouTube 클라이언트 (고급)" Style="{StaticResource Type.Caption}"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
|
||||
<TextBox Name="YoutubeClientsBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</Border>
|
||||
<TextBlock Text="비워두면 yt-dlp 기본값 사용(권장). 봇 차단/PO 토큰 오류 시 예: tv,android_vr,web" Style="{StaticResource Type.Caption}" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
|
||||
<!-- ==================== 단축키 (UC-K01) ==================== -->
|
||||
<StackPanel x:Name="SectionShortcuts" Margin="0,0,0,20" Tag="단축키">
|
||||
<TextBlock Text="단축키" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,6"/>
|
||||
<TextBlock Text="주요 단축키는 앱 안 Ctrl+K 명령 팔레트와 상태바 ? 버튼(단축키 목록)에서 확인하고, 충돌 없이 직접 바꾸려면 JSON 파일로 관리합니다." Style="{StaticResource Type.Caption}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== 실험적 (UC-S18) ==================== -->
|
||||
<StackPanel x:Name="SectionExperimental" Margin="0,0,0,20" Tag="실험적">
|
||||
<TextBlock Text="실험적" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,6"/>
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<CheckBox Name="RestoreTabsSuspendedBox" Style="{StaticResource DarkCheckBox}" Content="세션 복원을 잠자기 상태로(빠른 시작)" Margin="4,4"/>
|
||||
<CheckBox Name="TabTitleMultilineBox" Style="{StaticResource DarkCheckBox}" Content="탭 제목 두 줄 표시" Margin="4,4"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== 업데이트·정보 ==================== -->
|
||||
<StackPanel x:Name="SectionAbout" Margin="0,0,0,10" Tag="정보">
|
||||
<TextBlock Text="앱 업데이트" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,6"/>
|
||||
<TextBlock Text="GitHub Releases 최신 태그와 비교합니다 (owner/repo 형식)" Style="{StaticResource Type.Caption}" Margin="0,0,0,6"/>
|
||||
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,4">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="지금 확인" Padding="12,4" Margin="8,4,4,4"
|
||||
ToolTip="GitHub Releases 에서 새 버전 확인" Click="CheckUpdate_Click"/>
|
||||
<TextBox Name="UpdateRepoBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<TextBlock x:Name="UpdateResultText" Text="" Style="{StaticResource Type.Caption}" Margin="0,0,0,14"/>
|
||||
|
||||
<!-- UC-S20 버전 정보 -->
|
||||
<TextBlock Text="버전" Style="{StaticResource Type.Caption}"/>
|
||||
<TextBlock x:Name="VersionText" Text="-" FontSize="12" Foreground="{DynamicResource TextSecondary}" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- UC-S03 다시 시작 필요 배너 -->
|
||||
<Border x:Name="RestartBanner" Grid.Row="1" VerticalAlignment="Bottom" Margin="18,0,18,8"
|
||||
Background="{DynamicResource AccentFadeBrush}" BorderBrush="{DynamicResource AccentBrush}" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12,7" Visibility="Collapsed">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}" Content="지금 다시 시작" Click="RestartNow_Click"/>
|
||||
<TextBlock Text="일부 변경(프록시·개인정보 보호 모드)은 앱을 다시 시작하면 적용됩니다."
|
||||
FontSize="11.5" Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 하단 액션 바 -->
|
||||
<Border Grid.Row="2" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="22,12">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal">
|
||||
<!-- UC-S04 기본값 복원 · UC-S05/S06 설정 내보내기/가져오기 -->
|
||||
<Button x:Name="ResetDefaultsButton" Style="{StaticResource GhostButton}" Content="기본값으로 되돌리기" Click="ResetDefaults_Click" Margin="0,0,8,0"
|
||||
AutomationProperties.Name="기본값 복원"/>
|
||||
<Button x:Name="ExportSettingsButton" Style="{StaticResource GhostButton}" Content="내보내기" Click="ExportSettings_Click" Margin="0,0,8,0"
|
||||
AutomationProperties.Name="설정 내보내기"/>
|
||||
<Button x:Name="ImportSettingsButton" Style="{StaticResource GhostButton}" Content="가져오기" Click="ImportSettings_Click" Margin="0,0,8,0"
|
||||
AutomationProperties.Name="설정 가져오기"/>
|
||||
</StackPanel>
|
||||
<!-- UC-S19 검증 힌트 -->
|
||||
<TextBlock x:Name="ValidationHint" Text="" Foreground="{DynamicResource ErrorBrush}" FontSize="11.5"
|
||||
VerticalAlignment="Center" Margin="12,0,12,0" TextTrimming="CharacterEllipsis"
|
||||
AutomationProperties.LiveSetting="Assertive"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Style="{StaticResource GhostButton}" Content="취소" Width="90" Margin="0,0,8,0" IsCancel="True" Click="Cancel_Click"/>
|
||||
<Button Style="{StaticResource PrimaryButton}" Content="확인" Width="90" Click="Ok_Click" IsDefault="True"
|
||||
AutomationProperties.Name="설정 저장"/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
|
@ -1,580 +0,0 @@
|
|||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sys="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!-- 폰트 (테마 무관) — 한국어 가독성 프리셋 Pretendard(UC-F12) 우선 + 폴백 체인.
|
||||
브러시/폰트/토큰 참조는 DynamicResource 로 연결해 테마·폰트·설정 변경이 즉시 반영된다(UC-T04). -->
|
||||
<FontFamily x:Key="UiFont">Pretendard, Segoe UI Variable Text, Segoe UI, Malgun Gothic</FontFamily>
|
||||
<FontFamily x:Key="IconFont">Segoe Fluent Icons</FontFamily>
|
||||
<FontFamily x:Key="MonoFont">Cascadia Mono, Consolas, Courier New</FontFamily>
|
||||
|
||||
<!-- ===== 디자인 토큰 (§B 타이포그래피 · §C 레이아웃 · UC-L22 지속시간) ===== -->
|
||||
<sys:Double x:Key="FontSize.Base">13</sys:Double>
|
||||
<sys:Double x:Key="FontSize.Small">11.5</sys:Double>
|
||||
<sys:Double x:Key="FontSize.Large">16</sys:Double>
|
||||
<sys:Double x:Key="FontSize.XLarge">22</sys:Double>
|
||||
<sys:Double x:Key="IconSize.Small">14</sys:Double>
|
||||
<sys:Double x:Key="Type.LineHeight">1.5</sys:Double>
|
||||
|
||||
<Thickness x:Key="Spacing.S">4,4,4,4</Thickness>
|
||||
<Thickness x:Key="Spacing.M">8,8,8,8</Thickness>
|
||||
<Thickness x:Key="Spacing.L">16,16,16,16</Thickness>
|
||||
<CornerRadius x:Key="CornerRadius.Card">12</CornerRadius>
|
||||
|
||||
<Duration x:Key="Duration.Fast">0:0:0.15</Duration>
|
||||
<Duration x:Key="Duration.Normal">0:0:0.3</Duration>
|
||||
<!-- UC-A16/L21: ReduceMotion 켜면 App 이 Duration 토큰들을 0으로 교체해 애니메이션을 즉시 완결시킨다. -->
|
||||
<sys:Boolean x:Key="ReduceMotion">False</sys:Boolean>
|
||||
|
||||
<!-- ===== 그림자 토큰 (UC-T23/T24) ===== -->
|
||||
<DropShadowEffect x:Key="TooltipShadow" Color="#000000" Opacity="0.45" BlurRadius="14" ShadowDepth="3" Direction="270"/>
|
||||
<DropShadowEffect x:Key="PopupShadow" Color="#000000" Opacity="0.4" BlurRadius="20" ShadowDepth="5" Direction="270"/>
|
||||
|
||||
<!-- ===== 스크롤바 썸 전용 색 (UC-T22) ===== -->
|
||||
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#555C70"/>
|
||||
|
||||
<!-- ===== 탭 그룹 색 팔레트 (UC-B17) — MainWindow 탭 컨텍스트 메뉴가 사용 ===== -->
|
||||
<x:Array x:Key="GroupPalette" Type="SolidColorBrush">
|
||||
<SolidColorBrush Color="#E74C3C"/>
|
||||
<SolidColorBrush Color="#3498DB"/>
|
||||
<SolidColorBrush Color="#2ECC71"/>
|
||||
<SolidColorBrush Color="#F39C12"/>
|
||||
<SolidColorBrush Color="#9B59B6"/>
|
||||
<SolidColorBrush Color="#E91E8C"/>
|
||||
</x:Array>
|
||||
|
||||
<!-- 암시 스타일: FontFamily 를 여기서 강제하면 버튼 ContentPresenter 의 자동 TextBlock 이
|
||||
IconFont 상속을 잃어 모든 아이콘 글리프가 tofu(□)가 된다(2026-08-18 실결함).
|
||||
UI 폰트 통일은 각 창 루트의 FontFamily="{DynamicResource UiFont}" 상속으로 해결한다. -->
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
</Style>
|
||||
<Style TargetType="ToolTip">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="Background" Value="{DynamicResource CardBg}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Padding" Value="10,6"/>
|
||||
<!-- UC-T23 툴팁 그림자 -->
|
||||
<Setter Property="Effect" Value="{StaticResource TooltipShadow}"/>
|
||||
</Style>
|
||||
|
||||
<!-- UC-A05 키보드 포커스 링 — 모든 인터랙티브 스타일이 참조 -->
|
||||
<Style x:Key="FocusVisual">
|
||||
<Setter Property="Control.Margin" Value="2"/>
|
||||
<Setter Property="Control.Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate>
|
||||
<Rectangle Margin="2" StrokeThickness="1.5" Stroke="{DynamicResource FocusAdornerBrush}"
|
||||
StrokeDashArray="4 2" SnapsToDevicePixels="True"/>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 공용 텍스트 스타일 (§B) ===== -->
|
||||
<Style x:Key="Type.H1" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSize.XLarge}"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="LineHeight" Value="{DynamicResource Type.LineHeight}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
</Style>
|
||||
<Style x:Key="Type.Body" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSize.Base}"/>
|
||||
<Setter Property="LineHeight" Value="{DynamicResource Type.LineHeight}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
</Style>
|
||||
<Style x:Key="Type.Caption" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSize.Small}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextMuted}"/>
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
</Style>
|
||||
<!-- UC-F11 등폭 숫자 — 진행률·속도 표기에 사용 -->
|
||||
<Style x:Key="Type.Mono" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSize.Small}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
|
||||
</Style>
|
||||
<!-- UC-F19 탭 제목 -->
|
||||
<Style x:Key="TabTitleStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||
</Style>
|
||||
<!-- UC-F20 상태바 텍스트 -->
|
||||
<Style x:Key="StatusTextStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="FontSize" Value="11.5"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||
</Style>
|
||||
<!-- UC-N17 빈 상태 안내 -->
|
||||
<Style x:Key="EmptyStateStyle" TargetType="Border">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="10"/>
|
||||
<Setter Property="Padding" Value="16"/>
|
||||
<Setter Property="Margin" Value="8"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Primary 버튼 ===== -->
|
||||
<Style x:Key="PrimaryButton" TargetType="Button">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Padding" Value="16,8"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
|
||||
<Setter Property="RenderTransformOrigin" Value="0.5,0.5"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="{DynamicResource AccentGradient}" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Opacity" Value="0.9"/></Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="bd" Property="RenderTransform"><Setter.Value><ScaleTransform ScaleX="0.97" ScaleY="0.97"/></Setter.Value></Setter>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="bd" Property="Opacity" Value="0.35"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Ghost 버튼 ===== -->
|
||||
<Style x:Key="GhostButton" TargetType="Button">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Padding" Value="12,7"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
|
||||
<Setter Property="RenderTransformOrigin" Value="0.5,0.5"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="bd" Property="RenderTransform"><Setter.Value><ScaleTransform ScaleX="0.97" ScaleY="0.97"/></Setter.Value></Setter>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="bd" Property="Opacity" Value="0.4"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 아이콘 전용 버튼 (Fluent) ===== -->
|
||||
<Style x:Key="IconButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="Padding" Value="10,0"/>
|
||||
<Setter Property="Height" Value="32"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Fluent 아이콘 TextBlock ===== -->
|
||||
<Style x:Key="FluentIcon" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 윈도우 캡션 버튼 (최소화/최대화/복원) — 고정폭, 서로 밀착(네이티브 Fluent) ===== -->
|
||||
<Style x:Key="CaptionButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Width" Value="46"/>
|
||||
<Setter Property="Height" Value="32"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
|
||||
<Setter Property="Cursor" Value="Arrow"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="Transparent">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource BorderBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 닫기 버튼 — 빨간 호버 (Fluent 표준) ===== -->
|
||||
<Style x:Key="CloseButton" TargetType="Button" BasedOn="{StaticResource CaptionButton}">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="Transparent">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="#E81123"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 작은 카드 액션 버튼 ===== -->
|
||||
<Style x:Key="CardActionButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
|
||||
<Setter Property="Padding" Value="10,5"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 투명 행 버튼 ===== -->
|
||||
<Style x:Key="FlatButton" TargetType="Button">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="Padding" Value="10,7"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="7" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/></Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 주소창 자동완성 제안 목록 ===== -->
|
||||
<Style x:Key="SuggestList" TargetType="ListBox">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
|
||||
<Setter Property="MaxHeight" Value="360"/>
|
||||
</Style>
|
||||
<Style x:Key="SuggestItem" TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="12,7"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="7" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/></Trigger>
|
||||
<Trigger Property="IsSelected" Value="True"><Setter TargetName="bd" Property="Background" Value="{DynamicResource GlassBg}"/></Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== TextBox ===== -->
|
||||
<Style x:Key="DarkTextBox" TargetType="TextBox">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="Background" Value="{DynamicResource GlassBg}"/>
|
||||
<Setter Property="CaretBrush" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="SelectionBrush" Value="{DynamicResource SelectionBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="10,8"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1" CornerRadius="8">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
|
||||
Focusable="False" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/></Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False"><Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/></Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== ProgressBar (UC-T10: 하드코딩 색 제거 — 테마 강조 리소스만 참조) ===== -->
|
||||
<Style x:Key="AccentProgressBar" TargetType="ProgressBar">
|
||||
<Setter Property="Height" Value="8"/>
|
||||
<Setter Property="Focusable" Value="False"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ProgressBar">
|
||||
<Grid>
|
||||
<!-- PART_Track 이 있어야 ProgressBar 가 PART_Indicator 폭을(Value/Maximum 비율) 계산한다.
|
||||
없으면 바가 절대 채워지지 않는 치명 결함이었음. -->
|
||||
<Border x:Name="PART_Track" CornerRadius="4" Background="{DynamicResource TrackBrush}"/>
|
||||
<Border x:Name="PART_Indicator" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Background="{DynamicResource AccentGradient}"/>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 부정확(길이 미상) 진행률 바 ===== -->
|
||||
<Style x:Key="IndeterminateBar" TargetType="ProgressBar">
|
||||
<Setter Property="Height" Value="8"/>
|
||||
<Setter Property="Focusable" Value="False"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ProgressBar">
|
||||
<Grid ClipToBounds="True">
|
||||
<Border CornerRadius="4" Background="{DynamicResource TrackBrush}"/>
|
||||
<Border x:Name="shimmer" Width="48" CornerRadius="4" HorizontalAlignment="Left" RenderTransformOrigin="0,0"
|
||||
Background="{DynamicResource AccentBrush}" Opacity="0.85">
|
||||
<Border.RenderTransform><TranslateTransform X="-50"/></Border.RenderTransform>
|
||||
</Border>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<EventTrigger RoutedEvent="FrameworkElement.Loaded">
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetName="shimmer"
|
||||
Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.X)"
|
||||
From="-50" To="470" Duration="{DynamicResource Duration.Normal}" RepeatBehavior="Forever"/>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</EventTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== CheckBox ===== -->
|
||||
<Style x:Key="DarkCheckBox" TargetType="CheckBox">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<StackPanel Orientation="Horizontal" Background="Transparent">
|
||||
<Border x:Name="box" Width="18" Height="18" CornerRadius="5" Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1.5">
|
||||
<Path x:Name="check" Width="10" Height="10" Stretch="Uniform" Data="M 0 5 L 4 9 L 10 0" Stroke="White" StrokeThickness="2" HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed"/>
|
||||
</Border>
|
||||
<ContentPresenter VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="box" Property="Background" Value="{DynamicResource AccentGradient}"/>
|
||||
<Setter TargetName="box" Property="BorderBrush" Value="Transparent"/>
|
||||
<Setter TargetName="check" Property="Visibility" Value="Visible"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/></Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False"><Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/></Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== RadioButton(DarkCheckBox 와 같은 모양, 라디오 그룹용) ===== -->
|
||||
<Style x:Key="DarkRadioButton" TargetType="RadioButton">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RadioButton">
|
||||
<StackPanel Orientation="Horizontal" Background="Transparent">
|
||||
<Border x:Name="box" Width="18" Height="18" CornerRadius="9" Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1.5">
|
||||
<Ellipse x:Name="dot" Width="8" Height="8" Fill="White" HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed"/>
|
||||
</Border>
|
||||
<ContentPresenter VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="box" Property="Background" Value="{DynamicResource AccentGradient}"/>
|
||||
<Setter TargetName="box" Property="BorderBrush" Value="Transparent"/>
|
||||
<Setter TargetName="dot" Property="Visibility" Value="Visible"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/></Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False"><Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/></Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== ComboBox ===== -->
|
||||
<Style x:Key="DarkComboBox" TargetType="ComboBox">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="Background" Value="{DynamicResource GlassBg}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="10,6"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="ItemContainerStyle">
|
||||
<Setter.Value>
|
||||
<Style TargetType="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
|
||||
<Setter Property="Padding" Value="10,5"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="ibd" Background="Transparent" CornerRadius="5" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="ibd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="ibd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<Border x:Name="MainBorder" Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8"/>
|
||||
<Grid Margin="{TemplateBinding Padding}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<ToggleButton x:Name="ToggleButton" Grid.ColumnSpan="2" Focusable="False" ClickMode="Press"
|
||||
Background="Transparent"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border Background="{TemplateBinding Background}"/>
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
<ContentPresenter x:Name="ContentSite" Grid.Column="0" IsHitTestVisible="False"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Stretch"
|
||||
TextBlock.Foreground="{TemplateBinding Foreground}"
|
||||
TextBlock.FontSize="{TemplateBinding FontSize}"/>
|
||||
<TextBlock x:Name="arrow" Grid.Column="1" Text="" Style="{StaticResource FluentIcon}"
|
||||
FontSize="9" Foreground="{DynamicResource TextMuted}"
|
||||
VerticalAlignment="Center" Margin="10,0,0,0" IsHitTestVisible="False"/>
|
||||
</Grid>
|
||||
<!-- UC-A14 팝업 내 방향키/Tab 탐색 -->
|
||||
<Popup x:Name="Popup" Placement="Bottom" IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||
AllowsTransparency="True" Focusable="False" PopupAnimation="Slide">
|
||||
<Border x:Name="DropDown" Background="{DynamicResource CardBg}"
|
||||
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="4"
|
||||
MinWidth="{TemplateBinding ActualWidth}" MaxHeight="340"
|
||||
Effect="{StaticResource PopupShadow}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsDropDownOpen" Value="True">
|
||||
<Setter TargetName="MainBorder" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 얇은 스크롤바 (UC-T22 썸 색 토큰) ===== -->
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Width" Value="8"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="Orientation" Value="Vertical">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Grid>
|
||||
<Track Name="PART_Track" IsDirectionReversed="True">
|
||||
<Track.Thumb>
|
||||
<Thumb>
|
||||
<Thumb.Template>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border CornerRadius="4" Background="{DynamicResource ScrollBarThumbBrush}"/>
|
||||
</ControlTemplate>
|
||||
</Thumb.Template>
|
||||
</Thumb>
|
||||
</Track.Thumb>
|
||||
</Track>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
x:CompileBindings="False"
|
||||
mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="520"
|
||||
x:Class="VideoDownloader.Avalonia.MainWindow"
|
||||
Title="VideoDownloader (Avalonia · 크로스플랫폼 셸)"
|
||||
Width="640" Height="520"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<!-- DataContext 는 MainWindow 생성자에서 코드비하인드로 설정(MainViewModel). -->
|
||||
|
||||
<Border Background="#1B1C24" Padding="24">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*">
|
||||
|
||||
<!-- 헤더 -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,16">
|
||||
<TextBlock Text="VideoDownloader" FontSize="22" FontWeight="SemiBold" Foreground="#E6E6EC"/>
|
||||
<TextBlock Text="Avalonia 크로스플랫폼 셸 — Core 엔진 재사용 (HLS · DASH · yt-dlp 소셜 · 직링크)"
|
||||
FontSize="12" Foreground="#8A8B95" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- URL 입력 -->
|
||||
<TextBox Grid.Row="1" x:Name="UrlBox"
|
||||
Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}"
|
||||
PlaceholderText="영상 URL 붙여넣기 (YouTube/Instagram/X · m3u8 · mpd · mp4)"
|
||||
Background="#26272F" Foreground="#E6E6EC"
|
||||
BorderBrush="#3A3B45" BorderThickness="1" Padding="10,8" CornerRadius="8"/>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,12,0,12" Spacing="8">
|
||||
<Button Content="받기" Command="{Binding DownloadCommand}"
|
||||
Background="#5B8DEF" Foreground="White" Padding="24,8" CornerRadius="8" FontWeight="SemiBold"/>
|
||||
<Button Content="취소" Command="{Binding CancelCommand}"
|
||||
Background="#3A3B45" Foreground="#E6E6EC" Padding="18,8" CornerRadius="8"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 진행률 -->
|
||||
<ProgressBar Grid.Row="3" Value="{Binding Progress}" Maximum="100"
|
||||
MinHeight="6" CornerRadius="3" Foreground="#5B8DEF" Background="#26272F" Margin="0,0,0,8"/>
|
||||
|
||||
<!-- 상태/결과 -->
|
||||
<StackPanel Grid.Row="4" Margin="0,0,0,12">
|
||||
<TextBlock Text="{Binding Status}" Foreground="#B9BAC4" FontSize="13"/>
|
||||
<TextBlock Text="{Binding Result}" Foreground="#7FD1A0" FontSize="13" TextWrapping="Wrap" Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 로그 -->
|
||||
<Border Grid.Row="5" Background="#202127" CornerRadius="8" Padding="12">
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Log}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" Foreground="#8A8B95" FontSize="11" Margin="0,2"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
namespace VideoDownloader.Browser;
|
||||
|
||||
public enum PermissionVerdict { Ask, Allow, Deny }
|
||||
|
||||
/// <summary>
|
||||
/// T3-11 사이트별 권한 정책 — 출처×종류 단위 기억. WebView2 PermissionRequested 에서
|
||||
/// Ask 만 프롬프트로 올리고 Allow/Deny 는 즉시 응답. "항상" 선택은 네이티브
|
||||
/// Profile.SetPermissionStateAsync 로도 영속(세션 밖 저장).
|
||||
/// </summary>
|
||||
public sealed class PermissionGate
|
||||
{
|
||||
/// <summary>프롬프트 없이 거절: 센서류(피싱 표면 축소).</summary>
|
||||
private static readonly HashSet<string> AutoDeny = new(StringComparer.OrdinalIgnoreCase) { "OtherSensors" };
|
||||
/// <summary>프롬프트 없이 허용: 연속 다운로드(다운로더 앱 본업).</summary>
|
||||
private static readonly HashSet<string> AutoAllow = new(StringComparer.OrdinalIgnoreCase) { "MultipleAutomaticDownloads" };
|
||||
|
||||
private readonly Dictionary<(string origin, string kind), PermissionVerdict> _saved = new();
|
||||
|
||||
public PermissionVerdict Decide(string kind, string origin)
|
||||
{
|
||||
if (AutoDeny.Contains(kind)) return PermissionVerdict.Deny;
|
||||
if (AutoAllow.Contains(kind)) return PermissionVerdict.Allow;
|
||||
return _saved.GetValueOrDefault((origin, kind), PermissionVerdict.Ask);
|
||||
}
|
||||
|
||||
public void Remember(string kind, string origin, PermissionVerdict verdict)
|
||||
=> _saved[(origin, kind)] = verdict;
|
||||
}
|
||||
|
||||
/// <summary>T3-14: 추적 스크립트 블랙리스트 — 호스트 또는 서브도메인 매칭.</summary>
|
||||
public sealed class TrackerBlocklist
|
||||
{
|
||||
/// <summary>주요 광고/분석 도메인(EasyList 공통 항목 발췌). 생성자 주입으로 교체 가능(테스트).</summary>
|
||||
public static readonly string[] DefaultDomains =
|
||||
{
|
||||
"google-analytics.com", "googletagmanager.com", "doubleclick.net", "googlesyndication.com",
|
||||
"googleadservices.com", "scorecardresearch.com", "hotjar.com", "mixpanel.com", "segment.io",
|
||||
"facebook.net", "connect.facebook.net", "amplitude.com", "branch.io", "appsflyer.com",
|
||||
"criteo.com", "taboola.com", "outbrain.com", "adnxs.com", "pubmatic.com", "rubiconproject.com",
|
||||
};
|
||||
|
||||
private readonly HashSet<string> _domains;
|
||||
|
||||
public TrackerBlocklist() : this(DefaultDomains) { }
|
||||
public TrackerBlocklist(IEnumerable<string> domains) =>
|
||||
_domains = new HashSet<string>(domains, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool Matches(Uri url)
|
||||
{
|
||||
var host = url.Host;
|
||||
foreach (var d in _domains)
|
||||
if (host.Equals(d, StringComparison.OrdinalIgnoreCase) ||
|
||||
host.EndsWith("." + d, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T3-14: 차단 카운터 — 총계/도메인별 집계, 변경 통지(실드 배지 갱신용).</summary>
|
||||
public sealed class TrackerCounter
|
||||
{
|
||||
private int _total;
|
||||
public int Total => Volatile.Read(ref _total);
|
||||
public Dictionary<string, int> ByDomain { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public event Action? Changed;
|
||||
|
||||
public void Record(string url)
|
||||
{
|
||||
var host = new Uri(url).Host;
|
||||
var baseDomain = host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host;
|
||||
Interlocked.Increment(ref _total);
|
||||
lock (ByDomain)
|
||||
{
|
||||
ByDomain[baseDomain] = ByDomain.GetValueOrDefault(baseDomain) + 1;
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace VideoDownloader.Browser.Tabs;
|
||||
|
||||
/// <summary>
|
||||
/// 탭 관리 순수 로직(Phase A). WebView2 생성은 주입된 팩토리/어태처가 담당해
|
||||
/// 단위 테스트에서 UI 없이 검증 가능. 닫힌 탭 스택(25) · 핀 좌측 정렬 · 세션 캡처/복원 포함.
|
||||
/// </summary>
|
||||
public sealed class TabManager
|
||||
{
|
||||
private const int MaxClosed = 25;
|
||||
|
||||
private readonly Func<TabItem> _createTab;
|
||||
private readonly Action<TabItem>? _attachView;
|
||||
private readonly List<(TabItem Tab, int Index)> _closed = new();
|
||||
|
||||
public ObservableCollection<TabItem> Tabs { get; } = new();
|
||||
public TabItem? ActiveTab { get; private set; }
|
||||
|
||||
/// <summary>UC-B11 탭 최대 개수(0=무제한). 도달 시 새 탭 대신 마지막 탭을 활성화해 반환한다.</summary>
|
||||
public int MaxTabs { get; set; }
|
||||
|
||||
public event Action<TabItem>? ActiveChanged;
|
||||
public event Action<TabItem>? TabAdded;
|
||||
public event Action<TabItem>? TabRemoved;
|
||||
|
||||
/// <summary>닫힌 탭(LIFO — 최신 우선). Ctrl+Shift+T 복원 대상.</summary>
|
||||
public IReadOnlyList<TabItem> ClosedStack => _closed.Select(e => e.Tab).ToList();
|
||||
|
||||
public TabManager(Func<TabItem> createTab, Action<TabItem>? attachView = null)
|
||||
{
|
||||
_createTab = createTab;
|
||||
_attachView = attachView;
|
||||
}
|
||||
|
||||
public TabItem NewTab(string? url = null, bool activate = true)
|
||||
{
|
||||
// UC-B11 최대 탭 수 제한 — 꽉 차면 마지막 탭 활성화로 대체(새 탭 무한 증식 방지)
|
||||
if (MaxTabs > 0 && Tabs.Count >= MaxTabs)
|
||||
{
|
||||
var last = Tabs[^1];
|
||||
Activate(last);
|
||||
return last;
|
||||
}
|
||||
var tab = _createTab();
|
||||
if (!string.IsNullOrEmpty(url)) tab.Url = url;
|
||||
_attachView?.Invoke(tab);
|
||||
Tabs.Add(tab); // 신규 탭은 핀 여부와 무관하게 끝에(핀 상태로 만들면 SetPinned 이동)
|
||||
TabAdded?.Invoke(tab);
|
||||
if (activate) Activate(tab);
|
||||
else tab.IsActive = false;
|
||||
return tab;
|
||||
}
|
||||
|
||||
public void Activate(TabItem tab)
|
||||
{
|
||||
if (!Tabs.Contains(tab)) return;
|
||||
foreach (var t in Tabs) t.IsActive = ReferenceEquals(t, tab);
|
||||
ActiveTab = tab;
|
||||
tab.LastActivated = DateTime.UtcNow;
|
||||
ActiveChanged?.Invoke(tab);
|
||||
}
|
||||
|
||||
/// <summary>탭 닫기. 활성 탭이면 오른쪽 이웃(없으면 왼쪽) 활성화. 마지막 탭이면 새 빈 탭으로 대체.
|
||||
/// UC-B01: 잠긴 탭(IsLocked)은 닫기 요청을 거부한다.</summary>
|
||||
public bool CloseTab(TabItem tab)
|
||||
{
|
||||
if (tab.IsLocked) return false;
|
||||
var index = Tabs.IndexOf(tab);
|
||||
if (index < 0) return false;
|
||||
|
||||
_closed.Insert(0, (tab, index));
|
||||
while (_closed.Count > MaxClosed) _closed.RemoveAt(_closed.Count - 1);
|
||||
tab.ClosedIndex = index;
|
||||
|
||||
var wasActive = ReferenceEquals(ActiveTab, tab);
|
||||
Tabs.RemoveAt(index);
|
||||
TabRemoved?.Invoke(tab);
|
||||
|
||||
if (Tabs.Count == 0)
|
||||
{
|
||||
NewTab(); // 창 유지 — 마지막 탭은 새 빈 탭으로 대체
|
||||
}
|
||||
else if (wasActive)
|
||||
{
|
||||
var next = Tabs[Math.Min(index, Tabs.Count - 1)]; // 오른쪽 우선, 끝이면 왼쪽
|
||||
Activate(next);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Ctrl+Shift+T: 닫힌 탭을 닫힌 위치에 복원.</summary>
|
||||
public TabItem? ReopenClosedTab()
|
||||
{
|
||||
if (_closed.Count == 0) return null;
|
||||
var (tab, index) = _closed[0];
|
||||
_closed.RemoveAt(0);
|
||||
|
||||
if (tab.View == null) _attachView?.Invoke(tab); // 뷰가 정리된 경우 재생성
|
||||
Tabs.Insert(Math.Min(index, Tabs.Count), tab);
|
||||
TabAdded?.Invoke(tab);
|
||||
Activate(tab);
|
||||
return tab;
|
||||
}
|
||||
|
||||
public void ActivateNext()
|
||||
{
|
||||
if (ActiveTab == null || Tabs.Count < 2) return;
|
||||
var i = Tabs.IndexOf(ActiveTab);
|
||||
Activate(Tabs[(i + 1) % Tabs.Count]);
|
||||
}
|
||||
|
||||
public void ActivatePrevious()
|
||||
{
|
||||
if (ActiveTab == null || Tabs.Count < 2) return;
|
||||
var i = Tabs.IndexOf(ActiveTab);
|
||||
Activate(Tabs[(i - 1 + Tabs.Count) % Tabs.Count]);
|
||||
}
|
||||
|
||||
public void ActivateAt(int index)
|
||||
{
|
||||
if (index >= 0 && index < Tabs.Count) Activate(Tabs[index]);
|
||||
}
|
||||
|
||||
public void ActivateLast() => ActivateAt(Tabs.Count - 1);
|
||||
|
||||
public void Move(TabItem tab, int newIndex)
|
||||
{
|
||||
var old = Tabs.IndexOf(tab);
|
||||
if (old < 0) return;
|
||||
newIndex = Math.Clamp(newIndex, 0, Tabs.Count - 1);
|
||||
if (newIndex == old) return;
|
||||
Tabs.Move(old, newIndex);
|
||||
}
|
||||
|
||||
/// <summary>핀 설정. 핀 탭은 좌측 끝에 정렬(Chrome 동작).</summary>
|
||||
public void SetPinned(TabItem tab, bool pinned)
|
||||
{
|
||||
if (!Tabs.Contains(tab)) return;
|
||||
tab.IsPinned = pinned;
|
||||
if (pinned)
|
||||
{
|
||||
var target = Tabs.Count(t => t.IsPinned) - 1; // 자신 포함 핀 탭 수 - 1
|
||||
Move(tab, Math.Max(0, target));
|
||||
}
|
||||
else
|
||||
{
|
||||
var target = Tabs.Count(t => t.IsPinned); // 핀 탭 블록 바로 뒤
|
||||
Move(tab, target);
|
||||
}
|
||||
}
|
||||
|
||||
public SessionSnapshot CaptureSession()
|
||||
{
|
||||
var active = ActiveTab == null ? 0 : Math.Max(0, Tabs.IndexOf(ActiveTab));
|
||||
return new SessionSnapshot(
|
||||
Tabs.Select(t => t.Url).ToList(),
|
||||
active,
|
||||
Tabs.Select(t => t.IsPinned).ToList());
|
||||
}
|
||||
|
||||
public void RestoreSession(SessionSnapshot snap)
|
||||
{
|
||||
foreach (var t in Tabs.ToList())
|
||||
{
|
||||
Tabs.Remove(t);
|
||||
TabRemoved?.Invoke(t);
|
||||
}
|
||||
for (var i = 0; i < snap.Urls.Count; i++)
|
||||
{
|
||||
var tab = NewTab(snap.Urls[i], activate: false);
|
||||
if (i < snap.Pinned.Count && snap.Pinned[i]) tab.IsPinned = true; // 순서 유지(핀 우선 저장됨)
|
||||
}
|
||||
ActivateAt(Math.Clamp(snap.ActiveIndex, 0, Math.Max(0, Tabs.Count - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── T2-11 탭 그룹 확장 ──
|
||||
public static class TabManagerGroupExtensions
|
||||
{
|
||||
/// <summary>그룹 지정(null 이면 해제). 같은 그룹 탭은 인접하도록 이동.</summary>
|
||||
public static void SetGroup(this TabManager m, TabItem tab, string? name, string? color)
|
||||
{
|
||||
if (!m.Tabs.Contains(tab)) return;
|
||||
tab.GroupName = name;
|
||||
tab.GroupColor = color;
|
||||
if (name == null) return;
|
||||
// 같은 그룹 마지막 탭 뒤로 이동
|
||||
var lastIdx = -1;
|
||||
for (var i = 0; i < m.Tabs.Count; i++)
|
||||
if (string.Equals(m.Tabs[i].GroupName, name, StringComparison.Ordinal) && !ReferenceEquals(m.Tabs[i], tab))
|
||||
lastIdx = i;
|
||||
if (lastIdx >= 0) m.Move(tab, lastIdx + 1);
|
||||
}
|
||||
|
||||
/// <summary>그룹 전체 닫기. 반환: 닫은 탭 수(잠긴 탭은 건너뛴다 — UC-B01).</summary>
|
||||
public static int CloseGroup(this TabManager m, string groupName)
|
||||
{
|
||||
var group = m.Tabs.Where(t => string.Equals(t.GroupName, groupName, StringComparison.Ordinal) && !t.IsLocked).ToList();
|
||||
foreach (var t in group) m.CloseTab(t);
|
||||
return group.Count;
|
||||
}
|
||||
|
||||
/// <summary>UC-B04 그룹 이름 변경 — 같은 GroupName 을 가진 모든 탭의 라벨을 바꾼다.</summary>
|
||||
public static bool RenameGroup(this TabManager m, string oldName, string newName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newName) || oldName == newName) return false;
|
||||
var changed = 0;
|
||||
foreach (var t in m.Tabs.Where(t => string.Equals(t.GroupName, oldName, StringComparison.Ordinal)).ToList())
|
||||
{
|
||||
t.GroupName = newName;
|
||||
changed++;
|
||||
}
|
||||
return changed > 0;
|
||||
}
|
||||
|
||||
/// <summary>UC-B03 그룹 접기/펼치기 — 그룹 소속 탭 전체에 접음 상태 반영.</summary>
|
||||
public static int ToggleGroupCollapsed(this TabManager m, string groupName)
|
||||
{
|
||||
var changed = 0;
|
||||
foreach (var t in m.Tabs.Where(t => string.Equals(t.GroupName, groupName, StringComparison.Ordinal)).ToList())
|
||||
{
|
||||
t.IsGroupCollapsed = !t.IsGroupCollapsed;
|
||||
changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>워크스페이스 스냅샷(T3-1, Zen Workspaces 패턴).</summary>
|
||||
public sealed record WorkspaceSnapshot(string Name, List<string> Urls);
|
||||
|
||||
public static class TabManagerWorkspaceExtensions
|
||||
{
|
||||
/// <summary>현재 탭 세트를 워크스페이스로 캡처(현재 상태는 유지).</summary>
|
||||
public static WorkspaceSnapshot CaptureWorkspace(this TabManager m, string name)
|
||||
=> new(name, m.Tabs.Select(t => t.Url).ToList());
|
||||
|
||||
/// <summary>워크스페이스로 전환: 기존 탭 제거 후 스냅샷 복원.</summary>
|
||||
public static void SwitchWorkspace(this TabManager m, WorkspaceSnapshot ws)
|
||||
=> m.RestoreSession(new SessionSnapshot(ws.Urls, 0, Enumerable.Repeat(false, ws.Urls.Count).ToList()));
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
namespace VideoDownloader.Browser.Tabs;
|
||||
|
||||
/// <summary>UC-B05 탭 호버 미리보기 모델 — 제목·URL·상태를 한 눈에 보여주는 툴팁 페이로드.</summary>
|
||||
public sealed record TabPreview(string Title, string Url, bool IsSuspended, bool IsAudioPlaying, bool IsLocked, bool IsReader)
|
||||
{
|
||||
/// <summary>탭 상태에서 미리보기 생성.</summary>
|
||||
public static TabPreview From(TabItem tab) => new(
|
||||
tab.Title,
|
||||
tab.Url,
|
||||
tab.IsSuspended,
|
||||
tab.IsAudioPlaying,
|
||||
tab.IsLocked,
|
||||
tab.IsReader);
|
||||
|
||||
/// <summary>낭독기·툴팁용 한 줄 요약(상태 배지). 예: "제목 — zzz 잠자기 · 오디오 재생 중".</summary>
|
||||
public string BuildTooltipText()
|
||||
{
|
||||
var badges = new List<string>();
|
||||
if (IsSuspended) badges.Add("잠자기");
|
||||
if (IsAudioPlaying) badges.Add("오디오 재생 중");
|
||||
if (IsLocked) badges.Add("잠금");
|
||||
if (IsReader) badges.Add("리더 모드");
|
||||
return badges.Count == 0 ? $"{Title}\n{Url}" : $"{Title} ({string.Join(" · ", badges)})\n{Url}";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VideoDownloader.Browser.Tabs;
|
||||
|
||||
/// <summary>세션 JSON 저장소(T1-10). 종료 시 탭 URL 저장, 시작 시 복원.</summary>
|
||||
public static class TabSessionStore
|
||||
{
|
||||
public static void Save(string path, SessionSnapshot snap)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
var tmp = path + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(snap));
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static SessionSnapshot? Load(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
var snap = JsonSerializer.Deserialize<SessionSnapshot>(File.ReadAllText(path));
|
||||
if (snap == null || snap.Urls == null || snap.Urls.Count == 0) return null;
|
||||
var pinned = snap.Pinned ?? new List<bool>();
|
||||
while (pinned.Count < snap.Urls.Count) pinned.Add(false);
|
||||
return snap with { Pinned = pinned };
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace VideoDownloader.Browser;
|
||||
|
||||
/// <summary>T3-15: "파라미터 제거 복사" — 추적용 쿼리·프래그먼트를 뺀 URL 반환.</summary>
|
||||
public static class UrlCleaner
|
||||
{
|
||||
public static string? StripQuery(string? url)
|
||||
{
|
||||
if (url == null) return null;
|
||||
var q = url.IndexOf('?');
|
||||
if (q >= 0) url = url[..q];
|
||||
var f = url.IndexOf('#');
|
||||
if (f >= 0) url = url[..f];
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T3-15: 주소창 진행 바 상태 — 진행 중 네비게이션 개수 집계(탭·리다이렉트 중복 안전).</summary>
|
||||
public sealed class NavProgressTracker : INotifyPropertyChanged
|
||||
{
|
||||
private int _count;
|
||||
|
||||
public bool IsNavigating => Volatile.Read(ref _count) > 0;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public void Started() => Update(1);
|
||||
public void Completed() => Update(-1);
|
||||
|
||||
private void Update(int delta)
|
||||
{
|
||||
var before = IsNavigating;
|
||||
var c = _count + delta;
|
||||
Volatile.Write(ref _count, Math.Max(0, c)); // 과잉 완료 방지
|
||||
if (before != IsNavigating)
|
||||
OnPropertyChanged(nameof(IsNavigating));
|
||||
}
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
namespace VideoDownloader.Core.Detection;
|
||||
|
||||
public enum MediaKind { Hls, Dash, Direct, Social }
|
||||
|
||||
/// <summary>감지된 미디어. HLS(m3u8) / DASH(.mpd) / 직링크(Direct mp4/webm/...) / Social(yt-dlp).</summary>
|
||||
public record DetectionContext(string Url, string PageUrl, MediaKind Kind, string? ContentType = null, string? Title = null, string? Tag = null);
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
using VideoDownloader.Core.Config;
|
||||
|
||||
namespace VideoDownloader.Core.Detection;
|
||||
|
||||
/// <summary>요청(URL + content-type + 길이)과 설정으로 미디어 종류를 판정.</summary>
|
||||
public static class MediaDetector
|
||||
{
|
||||
/// <returns>HLS 또는 Direct, 감지 대상이 아니면 null</returns>
|
||||
public static MediaKind? Classify(string? uri, string? contentType, long len, AppConfig config)
|
||||
{
|
||||
var l = (uri ?? "").ToLowerInvariant();
|
||||
var c = (contentType ?? "").ToLowerInvariant();
|
||||
|
||||
bool isHls = c.Contains("mpegurl") || l.Contains(".m3u8") || l.Contains(".m3u");
|
||||
if (isHls)
|
||||
return Enabled(config, "hls") ? MediaKind.Hls : null;
|
||||
|
||||
bool isDash = c.Contains("dash+xml") || l.Contains(".mpd");
|
||||
if (isDash)
|
||||
return Enabled(config, "dash") ? MediaKind.Dash : null;
|
||||
|
||||
long min = config.MinSizeMB * 1024L * 1024L;
|
||||
if (len >= 0 && len <= min) return null; // 너무 작음
|
||||
|
||||
var fmt = MatchFormat(l, c);
|
||||
if (fmt == null) return null;
|
||||
return Enabled(config, fmt) ? MediaKind.Direct : null;
|
||||
}
|
||||
|
||||
private static bool Enabled(AppConfig config, string key) => config.EnabledFormats.Contains(key);
|
||||
|
||||
/// <summary>형식 키 반환: mp4/webm/mkv/m4v/mov/avi/audio (미매칭 null)</summary>
|
||||
public static string? MatchFormat(string l, string c)
|
||||
{
|
||||
if (l.EndsWith(".mp4") || c.Contains("mp4")) return "mp4";
|
||||
if (l.EndsWith(".webm") || c.Contains("webm")) return "webm";
|
||||
if (l.EndsWith(".mkv") || c.Contains("matroska")) return "mkv";
|
||||
if (l.EndsWith(".m4v")) return "m4v";
|
||||
if (l.EndsWith(".mov") || c.Contains("quicktime")) return "mov";
|
||||
if (l.EndsWith(".avi") || c.Contains("x-msvideo")) return "avi";
|
||||
bool audio = c.StartsWith("audio/") || l.EndsWith(".m4a") || l.EndsWith(".mp3")
|
||||
|| l.EndsWith(".ogg") || l.EndsWith(".opus") || l.EndsWith(".flac")
|
||||
|| l.EndsWith(".wav") || l.EndsWith(".aac");
|
||||
if (audio) return "audio";
|
||||
if (c.StartsWith("video/")) return "mp4"; // 기타 video 는 mp4 취급
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>감지 후보(사람에게 보여줄 만한) 판정 - DASH/MSE/세그먼트 등</summary>
|
||||
public static bool LooksLikeCandidate(string l, string c) =>
|
||||
l.Contains(".m4s") || l.Contains("segment") || l.Contains("/init") || l.Contains(".ts?")
|
||||
|| l.Contains(".mpd") || c.Contains("dash+xml") || c.Contains("mp2t") || c.Contains("octet-stream");
|
||||
|
||||
/// <summary>#15 본문 선두 magic byte 로 실제 포맷 판별 (위장 image/* 등 검증용).
|
||||
/// 이미지 포맷(GIF/PNG/JPEG/WebP)은 null 반환 — 위장 트래킹 픽셀이 미디어로 오탐되는 것 방지.
|
||||
/// (GIF 'G'=0x47 가 MPEG-TS sync byte 와 겹쳐 ts 로 오탐되던 문제 수정)</summary>
|
||||
public static string? ProbeFormat(ReadOnlySpan<byte> h)
|
||||
{
|
||||
if (h.Length < 4) return null;
|
||||
|
||||
// 이미지 포맷 → 미디어 아님
|
||||
if (h.Length >= 3 && h[0] == 'G' && h[1] == 'I' && h[2] == 'F') return null; // GIF
|
||||
if (h.Length >= 8 && h[0] == 0x89 && h[1] == 0x50 && h[2] == 0x4E && h[3] == 0x47) return null; // PNG
|
||||
if (h.Length >= 3 && h[0] == 0xFF && h[1] == 0xD8 && h[2] == 0xFF) return null; // JPEG
|
||||
if (h.Length >= 12 && h[0] == 0x52 && h[1] == 0x49 && h[2] == 0x46 && h[3] == 0x46) // RIFF
|
||||
{
|
||||
if (h[8] == 'W' && h[9] == 'E' && h[10] == 'B' && h[11] == 'P') return null; // WebP(이미지)
|
||||
if (h[8] == 'W' && h[9] == 'A' && h[10] == 'V' && h[11] == 'E') return "wav"; // WAV(오디오)
|
||||
return null;
|
||||
}
|
||||
|
||||
if (h.Length >= 12 && h[4] == 'f' && h[5] == 't' && h[6] == 'y' && h[7] == 'p') return "mp4"; // ftyp box
|
||||
if (h[0] == 0x47) return "ts"; // MPEG-TS sync (GIF 는 위에서 걸러짐)
|
||||
if (h[0] == 0x1A && h[1] == 0x45 && h[2] == 0xDF && h[3] == 0xA3) return "webm"; // EBML
|
||||
if (h[0] == 'I' && h[1] == 'D' && h[2] == '3') return "mp3"; // ID3
|
||||
if (h[0] == 0xFF && (h[1] & 0xE0) == 0xE0) return "mp3"; // MP3 frame
|
||||
if (h[0] == 0x66 && h[1] == 0x4C && h[2] == 0x61 && h[3] == 0x43) return "flac"; // fLaC
|
||||
if (h[0] == 0x4F && h[1] == 0x67 && h[2] == 0x67 && h[3] == 0x53) return "ogg"; // OggS
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Core.Models;
|
||||
|
||||
namespace VideoDownloader.Core.Hls;
|
||||
|
||||
/// <summary>
|
||||
/// 직링크 미디어(mp4/webm/mkv/m4v/mov/m4a/mp3/ogg...) 다운로드.
|
||||
/// - .part 임시파일에 쓰고 완료 시 이동
|
||||
/// - Range 헤더로 이어받기 (서버 지원 시)
|
||||
/// - 일시 오류 재시도 + 백오프 (영구 4xx 제외)
|
||||
/// </summary>
|
||||
public class DirectDownloader
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly AppConfig _config;
|
||||
|
||||
public DirectDownloader(HttpClient http, AppConfig config)
|
||||
{
|
||||
_http = http;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task<string> DownloadAsync(
|
||||
Uri url, string outputBase,
|
||||
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
|
||||
{
|
||||
string? dir = Path.GetDirectoryName(outputBase);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
var part = outputBase + ".part";
|
||||
Exception? last = null;
|
||||
string ext = GuessExt("", url);
|
||||
long total = -1;
|
||||
|
||||
for (int attempt = 1; attempt <= _config.Retries; attempt++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
long written = File.Exists(part) ? new FileInfo(part).Length : 0;
|
||||
TimeSpan? retryAfter = null; // 서버가 Retry-After 를 주면 그만큼 대기(#12)
|
||||
|
||||
try
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
if (written > 0) req.Headers.Range = new RangeHeaderValue(written, null);
|
||||
|
||||
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
|
||||
// 이미 완전히 받은 경우
|
||||
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
|
||||
return Finalize(part, outputBase, ext);
|
||||
|
||||
// 영구 4xx (408/429 제외) → 재시도 의미 없음
|
||||
var code = (int)resp.StatusCode;
|
||||
bool permanent = code >= 400 && code < 500
|
||||
&& resp.StatusCode != HttpStatusCode.RequestTimeout
|
||||
&& resp.StatusCode != HttpStatusCode.TooManyRequests;
|
||||
// #12 429/503 은 서버가 정한 쿨다운(Retry-After) 존중 — resp 가 dispose 되기 전에 캡처
|
||||
if ((code == 429 || code == 503) && resp.Headers.RetryAfter is { } ra)
|
||||
retryAfter = ra.Delta ?? (ra.Date is { } d ? d - DateTimeOffset.Now : null);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
bool partial = resp.StatusCode == HttpStatusCode.PartialContent;
|
||||
if (!partial) written = 0; // 서버가 range 무시 → 처음부터
|
||||
|
||||
var mediaType = resp.Content.Headers.ContentType?.MediaType ?? "";
|
||||
ext = GuessExt(mediaType, url);
|
||||
if (resp.Content.Headers.ContentLength is long cl)
|
||||
total = partial ? written + cl : cl;
|
||||
|
||||
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
|
||||
using var fs = new FileStream(part, partial ? FileMode.Append : FileMode.Create,
|
||||
FileAccess.Write, FileShare.None, bufferSize: 65536, useAsync: true);
|
||||
var buf = new byte[65536];
|
||||
int n;
|
||||
while ((n = await stream.ReadAsync(buf, ct)) > 0)
|
||||
{
|
||||
fs.Write(buf, 0, n);
|
||||
written += n;
|
||||
double pct = total > 0 ? written * 100.0 / total : -1;
|
||||
string detail = total > 0
|
||||
? $"{written / 1048576.0:F1}/{total / 1048576.0:F1} MB"
|
||||
: $"{written / 1048576.0:F1} MB";
|
||||
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, detail,
|
||||
ReceivedBytes: written, TotalBytes: total));
|
||||
}
|
||||
await fs.FlushAsync(ct);
|
||||
return Finalize(part, outputBase, ext);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (HttpRequestException hre)
|
||||
{
|
||||
last = hre;
|
||||
var c = hre.StatusCode is { } s ? (int)s : 0;
|
||||
bool perm = c >= 400 && c < 500 && c != 408 && c != 429;
|
||||
if (perm || attempt == _config.Retries) break;
|
||||
await DelayAsync(retryAfter, attempt, ct);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
last = e;
|
||||
if (attempt == _config.Retries) break;
|
||||
await DelayAsync(retryAfter, attempt, ct);
|
||||
}
|
||||
}
|
||||
throw last!;
|
||||
}
|
||||
|
||||
private static string Finalize(string part, string outputBase, string ext)
|
||||
{
|
||||
var final = outputBase + ext;
|
||||
// 대상 파일이 탐색기(썸네일)/백신에 의해 잠깐 잠겨있으면 File.Move 가
|
||||
// "being used by another process" 로 실패하므로 잠시 대기하며 재시도.
|
||||
for (int i = 0; ; i++)
|
||||
{
|
||||
try { File.Move(part, final, overwrite: true); return final; }
|
||||
catch (IOException) when (i < 10) { Thread.Sleep(250); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#12 재시도 대기: 서버 Retry-After(있으면, 최대 60s 캡) 를 우선, 없으면 지수 백오프.</summary>
|
||||
private static async Task DelayAsync(TimeSpan? retryAfter, int attempt, CancellationToken ct)
|
||||
{
|
||||
var delay = retryAfter ?? TimeSpan.FromMilliseconds(Math.Min(1500 * attempt, 10000));
|
||||
if (delay > TimeSpan.FromSeconds(60)) delay = TimeSpan.FromSeconds(60);
|
||||
if (delay < TimeSpan.Zero) delay = TimeSpan.Zero;
|
||||
try { await Task.Delay(delay, ct); } catch { }
|
||||
}
|
||||
|
||||
private static readonly string[] KnownExts =
|
||||
{ ".mp4", ".webm", ".mkv", ".m4v", ".mov", ".avi", ".m4a", ".mp3", ".ogg", ".opus", ".flac", ".wav", ".ts" };
|
||||
|
||||
private static string GuessExt(string mediaType, Uri url)
|
||||
{
|
||||
var path = url.AbsolutePath.ToLowerInvariant();
|
||||
foreach (var k in KnownExts)
|
||||
if (path.EndsWith(k)) return k;
|
||||
|
||||
return mediaType.ToLowerInvariant() switch
|
||||
{
|
||||
"video/mp4" or "video/mp4v-es" => ".mp4",
|
||||
"video/webm" => ".webm",
|
||||
"video/x-matroska" => ".mkv",
|
||||
"video/quicktime" => ".mov",
|
||||
"video/x-msvideo" => ".avi",
|
||||
"audio/mp4" or "audio/x-m4a" => ".m4a",
|
||||
"audio/mpeg" => ".mp3",
|
||||
"audio/ogg" or "application/ogg" => ".ogg",
|
||||
"audio/webm" => ".webm",
|
||||
"audio/wav" or "audio/x-wav" => ".wav",
|
||||
"audio/flac" => ".flac",
|
||||
_ => ".mp4",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
namespace VideoDownloader.Core.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// 앱 데이터 디렉터리 결정(Core 레벨). 기본 %AppData%/VideoDownloader.
|
||||
/// VD_DATA_DIR 환경변수로 오버라이드 — E2E 테스트 격리 공식 후크.
|
||||
/// </summary>
|
||||
public static class CorePaths
|
||||
{
|
||||
public static string DataDir =>
|
||||
Environment.GetEnvironmentVariable("VD_DATA_DIR") is { Length: > 0 } env
|
||||
? env
|
||||
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader");
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
using System.Text;
|
||||
|
||||
namespace VideoDownloader.Core.Util;
|
||||
|
||||
public static class FileNameUtil
|
||||
{
|
||||
private static readonly char[] Invalid = Path.GetInvalidFileNameChars();
|
||||
|
||||
// 무의미한 제네릭 이름 (이름 추출 시 건너뜀)
|
||||
private static readonly HashSet<string> GenericNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"index", "playlist", "playlists", "master", "chunklist", "main", "play",
|
||||
"media", "video", "stream", "streaming", "vod", "output", "file", "hls",
|
||||
"dash", "manifest", "seg", "segment", "test", "default"
|
||||
};
|
||||
|
||||
/// <summary>파일명으로 사용 불가능한 문자를 '_'로 치환하고 길이를 제한한다.</summary>
|
||||
public static string Sanitize(string? name, int maxLen = 120)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "video";
|
||||
var extra = new[] { '"', '<', '>', '|', '*', '?' };
|
||||
var sb = new StringBuilder();
|
||||
foreach (var c in name.Trim())
|
||||
{
|
||||
if (Array.IndexOf(Invalid, c) >= 0 || Array.IndexOf(extra, c) >= 0) sb.Append('_');
|
||||
else sb.Append(c);
|
||||
}
|
||||
var result = sb.ToString().Trim().Trim('.');
|
||||
if (string.IsNullOrWhiteSpace(result)) result = "video";
|
||||
return result.Length > maxLen ? result[..maxLen].Trim() : result;
|
||||
}
|
||||
|
||||
/// <summary>미디어 URL 에서 '진짜 파일명'을 추출 (페이지 타이틀 사용 X).</summary>
|
||||
public static string DeriveName(Uri url)
|
||||
{
|
||||
var segs = url.Segments
|
||||
.Select(s => Uri.UnescapeDataString(s).Trim('/'))
|
||||
.Where(s => s.Length > 0)
|
||||
.ToArray();
|
||||
|
||||
for (int i = segs.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var withoutExt = Path.GetFileNameWithoutExtension(segs[i]);
|
||||
if (string.IsNullOrWhiteSpace(withoutExt)) continue;
|
||||
if (GenericNames.Contains(withoutExt)) continue; // index.m3u8 등은 건너뜀
|
||||
return Sanitize(withoutExt);
|
||||
}
|
||||
return Sanitize(url.Host); // fallback: 호스트명
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>
|
||||
/// 외부망/네트워크 불통 안내(MOBILE_PLAN §7 성공 기준) —
|
||||
/// "같은 네트워크 필요 / 외부망에서는 Tailscale 등 VPN 안내".
|
||||
/// </summary>
|
||||
public static class ConnectionTroubleshoot
|
||||
{
|
||||
public const string NotConfigured = "먼저 연결 탭에서 PC 와 페어링하세요";
|
||||
|
||||
public const string Unreachable =
|
||||
"PC 에 연결할 수 없습니다. 폰과 PC 가 같은 네트워크(Wi-Fi)에 있는지 확인하세요. " +
|
||||
"외부망에서 접속하려면 Tailscale 등 VPN 으로 PC 의 사설망에 접근해야 합니다.";
|
||||
|
||||
public static string Message(bool isConfigured, string? lastError)
|
||||
{
|
||||
if (!isConfigured) return NotConfigured;
|
||||
if (!string.IsNullOrEmpty(lastError)) return Unreachable;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
public sealed record OfflineItem(string Id, string FileName, long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// 폰 오프라인 저장(U4): 라이브러리 항목을 앱 전용 폴더에 저장·목록·삭제.
|
||||
/// manifest.json(id→파일명)으로 서버 항목과 매핑한다.
|
||||
/// </summary>
|
||||
public sealed class OfflineStore
|
||||
{
|
||||
private readonly string _dir;
|
||||
private readonly string _manifestPath;
|
||||
private readonly object _gate = new();
|
||||
private Dictionary<string, string> _manifest;
|
||||
|
||||
/// <summary>baseDir 아래 offline/ 폴더 사용(호스트가 앱 데이터 디렉터리 주입).</summary>
|
||||
public OfflineStore(string baseDir)
|
||||
{
|
||||
_dir = Path.Combine(baseDir, "offline");
|
||||
_manifestPath = Path.Combine(_dir, "manifest.json");
|
||||
try
|
||||
{
|
||||
if (File.Exists(_manifestPath))
|
||||
_manifest = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(_manifestPath)) ?? new();
|
||||
}
|
||||
catch { }
|
||||
_manifest ??= new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public string Directory => _dir;
|
||||
|
||||
public string PathFor(string id) => Path.Combine(_dir, id + Path.GetExtension(_manifest.GetValueOrDefault(id, ".mp4")));
|
||||
|
||||
public bool Exists(string id) { lock (_gate) { var p = PathFor(id); return File.Exists(p) && _manifest.ContainsKey(id); } }
|
||||
|
||||
/// <summary>다운로드 완료 후 등록.</summary>
|
||||
public void Record(string id, string fileName)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(_dir);
|
||||
_manifest[id] = fileName;
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
public void Delete(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
try { if (File.Exists(PathFor(id))) File.Delete(PathFor(id)); } catch { }
|
||||
_manifest.Remove(id);
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
public List<OfflineItem> List()
|
||||
{
|
||||
lock (_gate)
|
||||
return _manifest.Select(kv =>
|
||||
{
|
||||
var p = PathFor(kv.Key);
|
||||
return new OfflineItem(kv.Key, kv.Value, File.Exists(p) ? new FileInfo(p).Length : 0);
|
||||
}).Where(i => i.SizeBytes > 0).ToList();
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try { System.IO.Directory.CreateDirectory(_dir); File.WriteAllText(_manifestPath, JsonSerializer.Serialize(_manifest)); } catch { }
|
||||
}}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>U5 완료 알림: 이전/다음 큐 스냅샷에서 새로 완료("완료")된 항목 ID 감지.
|
||||
/// 이전 스냅샷이 비면(앱 시작 직후 첫 폴링) 무시 — 과거 완료 알림 스팸 방지.</summary>
|
||||
public sealed class QueueCompletionDetector
|
||||
{
|
||||
public IReadOnlyList<string> Detect(
|
||||
IReadOnlyList<(string Id, string Status)> previous,
|
||||
IReadOnlyList<(string Id, string Status)> next)
|
||||
{
|
||||
if (previous.Count == 0) return Array.Empty<string>();
|
||||
var prev = previous.ToDictionary(x => x.Id, x => x.Status);
|
||||
return next
|
||||
.Where(n => n.Status == "완료" && prev.TryGetValue(n.Id, out var s) && s != "완료")
|
||||
.Select(n => n.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>M1 공유 타겟(U1): 다른 앱에서 공유받은 텍스트에서 전송할 URL 추출.
|
||||
/// 유튜브 공유 시트는 "제목 + https://youtu.be/..." 형식 — 텍스트 중 첫 http(s) URL.</summary>
|
||||
public static partial class SharePayload
|
||||
{
|
||||
[GeneratedRegex(@"https?://[^\s]+")]
|
||||
private static partial Regex UrlRegex();
|
||||
|
||||
public static string? ExtractUrl(string? sharedText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sharedText)) return null;
|
||||
var m = UrlRegex().Match(sharedText);
|
||||
return m.Success ? m.Value.TrimEnd(')', ']', '}', ',', '.', ';') : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>데스크톱 원격 서버 API 클라이언트(M1). 토큰은 QR(#t=)/수동 입력에서 획득.</summary>
|
||||
public sealed class ApiClient
|
||||
{
|
||||
public static readonly ApiClient Instance = new();
|
||||
|
||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) };
|
||||
|
||||
public string? BaseUrl { get; private set; }
|
||||
public string? Token { get; private set; }
|
||||
public string? LastError { get; private set; }
|
||||
public bool IsConfigured => BaseUrl is { Length: > 0 } && Token is { Length: > 0 };
|
||||
|
||||
public event Action? ConnectionChanged;
|
||||
|
||||
/// <summary>QR 문자열(http://ip:port/#t=TOKEN) 또는 수동 입력(host:port + token) 구성.</summary>
|
||||
public bool Configure(string? qrOrUrl, string? manualToken = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(manualToken))
|
||||
{
|
||||
var host = qrOrUrl!.Trim().TrimEnd('/');
|
||||
if (!host.StartsWith("http", StringComparison.OrdinalIgnoreCase)) host = "http://" + host;
|
||||
BaseUrl = host;
|
||||
Token = manualToken.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
var u = new Uri(qrOrUrl!.Trim());
|
||||
var frag = u.Fragment.TrimStart('#');
|
||||
var token = System.Web.HttpUtility.ParseQueryString(frag)["t"];
|
||||
if (string.IsNullOrEmpty(token)) return false;
|
||||
BaseUrl = $"http://{u.Authority}";
|
||||
Token = token;
|
||||
}
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token);
|
||||
Preferences.Set("vd_base", BaseUrl);
|
||||
Preferences.Set("vd_token", Token);
|
||||
ConnectionChanged?.Invoke();
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public void Restore()
|
||||
{
|
||||
var b = Preferences.Get("vd_base", "");
|
||||
var t = Preferences.Get("vd_token", "");
|
||||
if (b.Length > 0 && t.Length > 0)
|
||||
{
|
||||
BaseUrl = b;
|
||||
Token = t;
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", t);
|
||||
}
|
||||
}
|
||||
|
||||
public void Forget()
|
||||
{
|
||||
BaseUrl = null; Token = null;
|
||||
Preferences.Remove("vd_base"); Preferences.Remove("vd_token");
|
||||
ConnectionChanged?.Invoke();
|
||||
}
|
||||
|
||||
public async Task<List<QueueItem>> GetQueueAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
LastError = null;
|
||||
var r = await _http.GetAsync($"{BaseUrl}/api/queue");
|
||||
if (!r.IsSuccessStatusCode) { LastError = $"HTTP {(int)r.StatusCode}"; return new(); }
|
||||
return await r.Content.ReadFromJsonAsync<List<QueueItem>>() ?? new();
|
||||
}
|
||||
catch (Exception e) { LastError = e.Message; return new(); }
|
||||
}
|
||||
|
||||
public async Task<bool> AddAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var r = await _http.PostAsJsonAsync($"{BaseUrl}/api/queue", new { url });
|
||||
return r.IsSuccessStatusCode;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public Task<bool> ControlAsync(string id, string action) =>
|
||||
PostAsync($"/api/queue/{id}/{action}");
|
||||
|
||||
public async Task<bool> PairAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var r = await _http.PostAsJsonAsync($"{BaseUrl}/api/pair", new { token = Token, device = "Android" });
|
||||
return r.IsSuccessStatusCode;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public async Task<List<LibraryItem>> GetLibraryAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var r = await _http.GetAsync($"{BaseUrl}/api/library");
|
||||
if (!r.IsSuccessStatusCode) return new();
|
||||
return await r.Content.ReadFromJsonAsync<List<LibraryItem>>() ?? new();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
public string StreamUrl(LibraryItem item) => $"{BaseUrl}/stream/{item.Id}?t={Token}";
|
||||
public string FileUrl(LibraryItem item) => $"{BaseUrl}/file/{item.Id}?t={Token}";
|
||||
public string ThumbUrl(LibraryItem item) => $"{BaseUrl}/api/library/{item.Id}/thumb?t={Token}";
|
||||
|
||||
/// <summary>이어보기 위치 조회(U3). 실패/미저장 시 0.</summary>
|
||||
public async Task<double> GetPositionAsync(string id)
|
||||
{
|
||||
var dto = await GetAsync<PositionDto>($"/api/position/{id}");
|
||||
return dto?.PositionSec ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>이어보기 위치 저장(U3).</summary>
|
||||
public Task<bool> PostPositionAsync(string id, double positionSec) =>
|
||||
PostJsonAsync($"/api/position/{id}", new PositionDto(positionSec));
|
||||
|
||||
/// <summary>라이브러리 항목을 폰에 저장(U4). 진행률 0~1, 취소 지원. 임시 .part 로 받아 완료 시 rename.</summary>
|
||||
public async Task DownloadFileAsync(LibraryItem item, string destPath, IProgress<double>? progress, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
|
||||
var tmp = destPath + ".part";
|
||||
try
|
||||
{
|
||||
using var resp = await _http.GetAsync(FileUrl(item), HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var total = resp.Content.Headers.ContentLength ?? item.SizeBytes;
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var dst = File.Create(tmp);
|
||||
var buf = new byte[64 * 1024];
|
||||
long read = 0;
|
||||
int n;
|
||||
while ((n = await src.ReadAsync(buf, ct)) > 0)
|
||||
{
|
||||
await dst.WriteAsync(buf.AsMemory(0, n), ct);
|
||||
read += n;
|
||||
if (total > 0) progress?.Report((double)read / total);
|
||||
}
|
||||
File.Move(tmp, destPath, overwrite: true);
|
||||
progress?.Report(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> PostJsonAsync(string path, object body)
|
||||
{
|
||||
try
|
||||
{
|
||||
var r = await _http.PostAsJsonAsync($"{BaseUrl}{path}", body);
|
||||
return r.IsSuccessStatusCode;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private Task<bool> PostAsync(string path)
|
||||
{
|
||||
try { var t = _http.PostAsync($"{BaseUrl}{path}", null); return Continue(t); }
|
||||
catch { return Task.FromResult(false); }
|
||||
static async Task<bool> Continue(Task<HttpResponseMessage> t) { try { return (await t).IsSuccessStatusCode; } catch { return false; } }
|
||||
}
|
||||
|
||||
private async Task<T?> GetAsync<T>(string path) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
var r = await _http.GetAsync($"{BaseUrl}{path}");
|
||||
return r.IsSuccessStatusCode ? await r.Content.ReadFromJsonAsync<T>() : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record QueueItem(string Id, string Title, string Url, string Status, double Percent);
|
||||
|
||||
public sealed record LibraryItem(string Id, string FileName, long SizeBytes, DateTime Modified, string? Resolution = null);
|
||||
|
||||
public sealed record PositionDto(double PositionSec);
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<?xml version = "1.0" encoding = "UTF-8" ?>
|
||||
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:local="clr-namespace:VideoDownloader.Mobile"
|
||||
x:Class="VideoDownloader.Mobile.App">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
|
||||
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override Window CreateWindow(IActivationState? activationState)
|
||||
{
|
||||
return new Window(new TabbedRootPage());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>앱 데이터 디렉터리를 주입하는 오프라인 저장소 기본 인스턴스(Core 는 MAUI 비의존).</summary>
|
||||
public static class AppOfflineStore
|
||||
{
|
||||
public static OfflineStore Default { get; } = new(FileSystem.AppDataDirectory);
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using CommunityToolkit.Maui;
|
||||
using ZXing.Net.Maui.Controls;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
public static class MauiProgram
|
||||
{
|
||||
public static MauiApp CreateMauiApp()
|
||||
{
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder
|
||||
.UseMauiApp<App>()
|
||||
.UseBarcodeReader()
|
||||
.UseMauiCommunityToolkitMediaElement()
|
||||
.ConfigureFonts(fonts =>
|
||||
{
|
||||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
|
||||
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
|
||||
});
|
||||
|
||||
#if DEBUG
|
||||
builder.Logging.AddDebug();
|
||||
#endif
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,486 +0,0 @@
|
|||
using Microsoft.Maui.Controls;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>M1 연결 페이지: QR 스캔(ZXing) + 수동 입력 폴백.</summary>
|
||||
public sealed class ConnectPage : ContentPage
|
||||
{
|
||||
private readonly Entry _manual = new() { Placeholder = "PC 주소 (예: 192.168.0.223:52100)", Keyboard = Keyboard.Url };
|
||||
private readonly Entry _manualToken = new() { Placeholder = "토큰 (QR 못 쓸 때)", IsPassword = true };
|
||||
private readonly Label _status = new() { Text = "PC 앱에서 '기기 연결'을 누르고 QR 을 스캔하세요", HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 12) };
|
||||
|
||||
public ConnectPage()
|
||||
{
|
||||
Title = "연결";
|
||||
|
||||
// 시뮬레이터는 카메라 하드웨어가 없어 세션 시작이 첫 프레임을 막음(UIApplication hang) → 실기기에서만 스캐너
|
||||
View scannerArea = new Label
|
||||
{
|
||||
Text = "📷 시뮬레이터 — 수동 입력을 이용하세요",
|
||||
HorizontalOptions = LayoutOptions.Fill, VerticalOptions = LayoutOptions.Center,
|
||||
HorizontalTextAlignment = TextAlignment.Center, HeightRequest = 120,
|
||||
BackgroundColor = Colors.Black, TextColor = Colors.LightGray,
|
||||
};
|
||||
// 게이트 대상은 "iOS 시뮬레이터" 뿐: Android 는 실기기·에뮬레이터 모두 스캐너 표시
|
||||
// (DeviceType 휴리스틱이 실기기에서 가상으로 오판하면 QR 이 아예 안 뜨는 결함이 됨)
|
||||
var isIosSimulator = DeviceInfo.Current.Platform == DevicePlatform.iOS
|
||||
&& DeviceInfo.Current.DeviceType == DeviceType.Virtual;
|
||||
if (!isIosSimulator)
|
||||
{
|
||||
var scanner = new ZXing.Net.Maui.Controls.CameraBarcodeReaderView
|
||||
{
|
||||
HorizontalOptions = LayoutOptions.Fill,
|
||||
HeightRequest = 320,
|
||||
Options = new ZXing.Net.Maui.BarcodeReaderOptions
|
||||
{
|
||||
Formats = ZXing.Net.Maui.BarcodeFormats.TwoDimensional,
|
||||
AutoRotate = true,
|
||||
},
|
||||
};
|
||||
scanner.BarcodesDetected += (s, e) =>
|
||||
{
|
||||
var text = e.Results?.FirstOrDefault()?.Value;
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
if (ApiClient.Instance.Configure(text))
|
||||
{
|
||||
_status.Text = "✅ 연결됨";
|
||||
scanner.IsDetecting = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
scannerArea = scanner;
|
||||
}
|
||||
|
||||
var connectBtn = new Button { Text = "수동 연결" };
|
||||
connectBtn.Clicked += (_, _) =>
|
||||
{
|
||||
if (ApiClient.Instance.Configure(_manual.Text, _manualToken.Text)) _status.Text = "✅ 연결됨";
|
||||
else _status.Text = "❌ 연결 실패 — 주소/토큰 확인";
|
||||
};
|
||||
var forgetBtn = new Button { Text = "연결 해제", BackgroundColor = Colors.Gray };
|
||||
forgetBtn.Clicked += (_, _) => { ApiClient.Instance.Forget(); _status.Text = "해제됨"; };
|
||||
|
||||
Content = new ScrollView
|
||||
{
|
||||
Content = new VerticalStackLayout
|
||||
{
|
||||
Padding = 20, Spacing = 10,
|
||||
Children = { _status, scannerArea, _manual, _manualToken, connectBtn, forgetBtn }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어 + U5 완료 알림 + 외부망 안내.</summary>
|
||||
public sealed class QueuePage : ContentPage
|
||||
{
|
||||
private readonly Entry _url = new() { Placeholder = "YouTube·영상 URL 붙여넣기", Keyboard = Keyboard.Url };
|
||||
private readonly Label _summary = new();
|
||||
private readonly Label _trouble = new() { FontSize = 12, TextColor = Colors.OrangeRed, IsVisible = false };
|
||||
private readonly CollectionView _list = new();
|
||||
private readonly IDispatcherTimer _timer;
|
||||
private readonly QueueCompletionDetector _completions = new();
|
||||
private List<QueueItem> _last = new();
|
||||
|
||||
public QueuePage()
|
||||
{
|
||||
Title = "다운로드 큐";
|
||||
var addBtn = new Button { Text = "PC에서 받기" };
|
||||
addBtn.Clicked += async (_, _) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_url.Text)) return;
|
||||
_summary.Text = "전송 중…";
|
||||
var ok = await ApiClient.Instance.AddAsync(_url.Text.Trim());
|
||||
_summary.Text = ok ? "✅ PC 대기열에 추가됨" : "❌ 실패 (연결 확인)";
|
||||
if (ok) _url.Text = "";
|
||||
await Refresh();
|
||||
};
|
||||
|
||||
_list.ItemTemplate = new DataTemplate(() =>
|
||||
{
|
||||
var title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
|
||||
title.SetBinding(Label.TextProperty, "Title");
|
||||
var status = new Label { FontSize = 12, TextColor = Colors.Gray };
|
||||
status.SetBinding(Label.TextProperty, new Binding("Percent", stringFormat: "{0:0}%"));
|
||||
var bar = new ProgressBar { HeightRequest = 6 };
|
||||
bar.SetBinding(ProgressBar.ProgressProperty, new Binding("Percent", converter: new PercentConverter()));
|
||||
var state = new Label { FontSize = 11, TextColor = Colors.Gray };
|
||||
state.SetBinding(Label.TextProperty, "Status");
|
||||
|
||||
var pause = new Button { Text = "⏸" };
|
||||
var resume = new Button { Text = "▶" };
|
||||
var cancel = new Button { Text = "✕", BackgroundColor = Colors.Gray };
|
||||
pause.Clicked += async (_, _) => { if (pause.BindingContext is QueueItem qi) { await ApiClient.Instance.ControlAsync(qi.Id, "pause"); await Refresh(); } };
|
||||
resume.Clicked += async (_, _) => { if (resume.BindingContext is QueueItem qi) { await ApiClient.Instance.ControlAsync(qi.Id, "resume"); await Refresh(); } };
|
||||
cancel.Clicked += async (_, _) => { if (cancel.BindingContext is QueueItem qi) { await ApiClient.Instance.ControlAsync(qi.Id, "cancel"); await Refresh(); } };
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
|
||||
Content = new VerticalStackLayout
|
||||
{
|
||||
Spacing = 6,
|
||||
Children = { title, bar, new HorizontalStackLayout { Children = { state, new Label { Text = " " } } },
|
||||
new HorizontalStackLayout { Spacing = 8, Children = { pause, resume, cancel } } }
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
_timer = Dispatcher.CreateTimer();
|
||||
_timer.Interval = TimeSpan.FromSeconds(2);
|
||||
_timer.Tick += async (_, _) => await Refresh();
|
||||
Appearing += (_, _) => _timer.Start();
|
||||
Disappearing += (_, _) => _timer.Stop();
|
||||
|
||||
Content = new VerticalStackLayout
|
||||
{
|
||||
Padding = 16, Spacing = 10,
|
||||
Children = { _url, addBtn, _summary, _trouble, _list }
|
||||
};
|
||||
}
|
||||
|
||||
private async Task Refresh()
|
||||
{
|
||||
var trouble = ConnectionTroubleshoot.Message(ApiClient.Instance.IsConfigured, ApiClient.Instance.LastError);
|
||||
_trouble.Text = trouble;
|
||||
_trouble.IsVisible = trouble.Length > 0;
|
||||
if (!ApiClient.Instance.IsConfigured) { _summary.Text = "⚠️ 먼저 연결하세요"; _last = new(); return; }
|
||||
var items = await ApiClient.Instance.GetQueueAsync();
|
||||
_list.ItemsSource = items;
|
||||
|
||||
// U5: 이전 스냅샷 대비 새로 완료된 항목 → 로컬 알림
|
||||
var done = _completions.Detect(
|
||||
_last.Select(i => (i.Id, i.Status)).ToList(),
|
||||
items.Select(i => (i.Id, i.Status)).ToList());
|
||||
foreach (var id in done)
|
||||
{
|
||||
var item = items.First(i => i.Id == id);
|
||||
_ = NotifyCompletionAsync(item.Title);
|
||||
_summary.Text = $"🔔 완료: {item.Title}";
|
||||
}
|
||||
_last = items;
|
||||
|
||||
var err = ApiClient.Instance.LastError;
|
||||
_summary.Text = err != null ? $"❌ {err}" : items.Count == 0 ? "대기 중인 항목이 없습니다" : $"{items.Count}건";
|
||||
}
|
||||
|
||||
/// <summary>U1: 공유받은 링크 처리 결과 표시(루트 타이머가 호출).</summary>
|
||||
public void ShowSharedResult(string message) => _summary.Text = message;
|
||||
|
||||
/// <summary>U1: 공유 타겟으로 받은 텍스트에서 URL 을 추출해 PC 대기열에 추가.</summary>
|
||||
private async Task HandleSharedUrlAsync()
|
||||
{
|
||||
var shared = ShareTarget.Consume();
|
||||
if (shared == null || !ApiClient.Instance.IsConfigured) return;
|
||||
var url = SharePayload.ExtractUrl(shared);
|
||||
if (url == null) { _summary.Text = "🔗 공유받은 텍스트에 URL 이 없습니다"; return; }
|
||||
_summary.Text = "🔗 공유 링크 전송 중…";
|
||||
var ok = await ApiClient.Instance.AddAsync(url);
|
||||
_summary.Text = ok ? $"🔗 공유 링크 전송됨: {url}" : "❌ 공유 링크 전송 실패";
|
||||
}
|
||||
|
||||
/// <summary>U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청).</summary>
|
||||
private static async Task NotifyCompletionAsync(string title)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsAndroidVersionAtLeast(33))
|
||||
await Plugin.LocalNotification.LocalNotificationCenter.Current.RequestNotificationPermission();
|
||||
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
|
||||
{
|
||||
Title = "다운로드 완료",
|
||||
Description = title,
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private sealed class PercentConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c) => Math.Clamp(value is double d ? d / 100.0 : 0, 0, 1);
|
||||
public object ConvertBack(object? v, Type t, object? p, System.Globalization.CultureInfo c) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>M2 라이브러리 페이지: 목록 → 탭하여 스트리밍 재생.</summary>
|
||||
public sealed class LibraryPage : ContentPage
|
||||
{
|
||||
private readonly CollectionView _list = new();
|
||||
|
||||
public LibraryPage()
|
||||
{
|
||||
Title = "라이브러리";
|
||||
_list.ItemsLayout = new GridItemsLayout(2, ItemsLayoutOrientation.Vertical); // M2 썸네일 그리드
|
||||
_list.ItemTemplate = new DataTemplate(() =>
|
||||
{
|
||||
var thumb = new Image { HeightRequest = 96, Aspect = Aspect.AspectFill, BackgroundColor = Colors.Black };
|
||||
thumb.SetBinding(Image.SourceProperty, new Binding(".", converter: new ThumbConverter()));
|
||||
var name = new Label { FontSize = 13, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation, LineHeight = 1.1 };
|
||||
name.SetBinding(Label.TextProperty, "FileName");
|
||||
var size = new Label { FontSize = 10, TextColor = Colors.Gray };
|
||||
size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes"));
|
||||
var date = new Label { FontSize = 9, TextColor = Colors.Gray }; // M2: 추가일(수정일)
|
||||
date.SetBinding(Label.TextProperty, new Binding("Modified", stringFormat: "{0:yy.MM.dd}"));
|
||||
|
||||
// U4 폰 저장: 카드 우측 버튼, 진행률을 버튼 텍스트로 표시
|
||||
var save = new Button { Text = "💾 저장", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center };
|
||||
save.Clicked += async (_, _) => await SaveOffline(save, save.BindingContext as LibraryItem);
|
||||
|
||||
var card = new Frame
|
||||
{
|
||||
Margin = new Thickness(6, 4), Padding = 8, CornerRadius = 10,
|
||||
Content = new VerticalStackLayout { Spacing = 4, Children = { thumb, name, size, date, save } }
|
||||
};
|
||||
// CollectionView.SelectionChanged 는 Android 에서 발화 불안정 → 템플릿 카드에 탭 제스처
|
||||
var tap = new TapGestureRecognizer();
|
||||
tap.Tapped += (_, _) => { if (card.BindingContext is LibraryItem item) OpenPlayer(item); };
|
||||
card.GestureRecognizers.Add(tap);
|
||||
return card;
|
||||
});
|
||||
_list.SelectionMode = SelectionMode.None;
|
||||
|
||||
var refresh = new Button { Text = "새로고침" };
|
||||
refresh.Clicked += async (_, _) => await Reload();
|
||||
|
||||
Content = new VerticalStackLayout { Padding = 16, Children = { refresh, _list } };
|
||||
Appearing += async (_, _) => await Reload();
|
||||
}
|
||||
|
||||
/// <summary>U4: 항목을 폰 저장 폴더로 스트리밍 다운로드(진행률 % → 완료 ✓).</summary>
|
||||
private static async Task SaveOffline(Button button, LibraryItem? item)
|
||||
{
|
||||
if (item == null) return;
|
||||
if (AppOfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
|
||||
button.Text = "0%";
|
||||
var notifId = 5000 + Math.Abs(item.Id.GetHashCode() % 1000); // M2 진행 알림: 항목별 고정 id 로 갱신
|
||||
try
|
||||
{
|
||||
var dest = AppOfflineStore.Default.PathFor(item.Id);
|
||||
var progress = new Progress<double>(p => MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%";
|
||||
if (p < 1) ShowSaveProgress(notifId, item.FileName, p); // 100% 는 완료 노티가 대체(되덮어쓰기 방지)
|
||||
}));
|
||||
await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None);
|
||||
AppOfflineStore.Default.Record(item.Id, item.FileName);
|
||||
button.Text = "✓ 저장됨";
|
||||
ShowSaveDone(notifId, item.FileName);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() => button.Text = $"❌ {e.Message[..Math.Min(20, e.Message.Length)]}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>M2: 오프라인 저장 진행 알림(같은 id 갱신) — 5% 단위 스로틀.</summary>
|
||||
private static int _lastNotifiedPercent = -1;
|
||||
|
||||
private static void ShowSaveProgress(int notifId, string fileName, double p)
|
||||
{
|
||||
var percent = (int)(p * 100);
|
||||
if (p < 1 && percent / 5 == _lastNotifiedPercent) return; // 알림 스팸 방지
|
||||
_lastNotifiedPercent = percent / 5;
|
||||
try
|
||||
{
|
||||
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
|
||||
{
|
||||
NotificationId = notifId,
|
||||
Title = "폰에 저장 중",
|
||||
Description = $"{fileName} — {percent}%",
|
||||
Android =
|
||||
{
|
||||
ChannelId = "vd_save",
|
||||
Ongoing = true,
|
||||
AutoCancel = false,
|
||||
ProgressBar = new Plugin.LocalNotification.AndroidOption.AndroidProgressBar
|
||||
{
|
||||
Max = 100,
|
||||
Progress = percent,
|
||||
IsIndeterminate = false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void ShowSaveDone(int notifId, string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
|
||||
{
|
||||
NotificationId = notifId,
|
||||
Title = "폰 저장 완료",
|
||||
Description = fileName,
|
||||
Android = { ChannelId = "vd_save", Ongoing = false, AutoCancel = true },
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async void OpenPlayer(LibraryItem item)
|
||||
{
|
||||
// 탭 자식엔 NavigationPage 가 없어 PushAsync 불가 → 루트에서 모달 push (닫기는 상단바 뒤로)
|
||||
await Application.Current!.MainPage!.Navigation.PushModalAsync(new NavigationPage(new PlayerPage(item)));
|
||||
}
|
||||
|
||||
private async Task Reload()
|
||||
{
|
||||
if (!ApiClient.Instance.IsConfigured) { _list.ItemsSource = new[] { "먼저 연결하세요" }; return; }
|
||||
_list.ItemsSource = await ApiClient.Instance.GetLibraryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>M2-U4 저장 탭: 폰에 저장된 항목 목록 — 오프라인 재생/삭제.</summary>
|
||||
public sealed class DownloadsPage : ContentPage
|
||||
{
|
||||
private readonly CollectionView _list = new();
|
||||
private readonly Label _empty = new()
|
||||
{
|
||||
Text = "저장된 항목이 없습니다 — 라이브러리에서 💾 저장",
|
||||
TextColor = Colors.Gray, HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 24),
|
||||
};
|
||||
|
||||
public DownloadsPage()
|
||||
{
|
||||
Title = "저장";
|
||||
_list.ItemTemplate = new DataTemplate(() =>
|
||||
{
|
||||
var name = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
|
||||
name.SetBinding(Label.TextProperty, "FileName");
|
||||
var size = new Label { FontSize = 11, TextColor = Colors.Gray };
|
||||
size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes — 오프라인"));
|
||||
|
||||
var del = new Button { Text = "🗑", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center };
|
||||
del.Clicked += (_, _) =>
|
||||
{
|
||||
if ((del.BindingContext as OfflineItem) is { } it) { AppOfflineStore.Default.Delete(it.Id); Reload(); }
|
||||
};
|
||||
|
||||
var card = new Frame
|
||||
{
|
||||
Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
|
||||
Content = new HorizontalStackLayout { Spacing = 8, Children = { new VerticalStackLayout { Children = { name, size } }, del } }
|
||||
};
|
||||
var tap = new TapGestureRecognizer();
|
||||
tap.Tapped += (_, _) =>
|
||||
{
|
||||
if ((card.BindingContext as OfflineItem) is { } it)
|
||||
_ = Application.Current!.MainPage!.Navigation.PushModalAsync(
|
||||
new NavigationPage(new PlayerPage(new LibraryItem(it.Id, it.FileName, it.SizeBytes, DateTime.MinValue), AppOfflineStore.Default.PathFor(it.Id))));
|
||||
};
|
||||
card.GestureRecognizers.Add(tap);
|
||||
return card;
|
||||
});
|
||||
_list.SelectionMode = SelectionMode.None;
|
||||
|
||||
var refresh = new Button { Text = "새로고침" };
|
||||
refresh.Clicked += (_, _) => Reload();
|
||||
|
||||
Content = new VerticalStackLayout { Padding = 16, Children = { refresh, _empty, _list } };
|
||||
_empty.IsVisible = false;
|
||||
Appearing += (_, _) => Reload();
|
||||
}
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
var items = AppOfflineStore.Default.List();
|
||||
_list.ItemsSource = items.Count > 0 ? items : null;
|
||||
_empty.IsVisible = items.Count == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>M2 재생 페이지: Range 스트리밍 재생(MediaElement) + 이어보기 위치 동기화(U3). localPath 있으면 오프라인 재생(U4).</summary>
|
||||
public sealed class PlayerPage : ContentPage
|
||||
{
|
||||
private readonly CommunityToolkit.Maui.Views.MediaElement _media = new()
|
||||
{
|
||||
Aspect = Aspect.AspectFit,
|
||||
ShouldAutoPlay = true,
|
||||
ShouldShowPlaybackControls = true,
|
||||
};
|
||||
private readonly string? _itemId; // 스트리밍 재생 시 위치 동기화 대상
|
||||
private bool _sync; // 위치 저장 활성(오프라인 재생은 서버 없이도 동작)
|
||||
|
||||
public PlayerPage(LibraryItem item, string? localPath = null)
|
||||
{
|
||||
Title = item.FileName;
|
||||
if (localPath != null)
|
||||
{
|
||||
_media.Source = CommunityToolkit.Maui.Views.MediaSource.FromFile(localPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_itemId = item.Id;
|
||||
_media.Source = CommunityToolkit.Maui.Views.MediaSource.FromUri(ApiClient.Instance.StreamUrl(item));
|
||||
// 열림 시 저장된 위치로 시크 → 5초 주기 동기화(U3)
|
||||
_media.MediaOpened += async (_, _) =>
|
||||
{
|
||||
var saved = await ApiClient.Instance.GetPositionAsync(item.Id);
|
||||
if (saved > 1) await _media.SeekTo(TimeSpan.FromSeconds(saved));
|
||||
_sync = true;
|
||||
};
|
||||
Device.StartTimer(TimeSpan.FromSeconds(5), () =>
|
||||
{
|
||||
if (!_sync || _media.Position <= TimeSpan.Zero) return true;
|
||||
_ = ApiClient.Instance.PostPositionAsync(item.Id, _media.Position.TotalSeconds);
|
||||
return true; // 페이지가 닫혀도 무해(타이머는 프로세스 수명)
|
||||
});
|
||||
}
|
||||
Content = _media;
|
||||
// M2: 재생 중 미디어 알림(ongoing 노티) — 재생은 앱 내(포그라운드) 한정
|
||||
StartPlaybackNotification(item.FileName);
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
if (_sync && _itemId != null && _media.Position > TimeSpan.Zero)
|
||||
_ = ApiClient.Instance.PostPositionAsync(_itemId, _media.Position.TotalSeconds);
|
||||
StopPlaybackNotification();
|
||||
_media.Stop();
|
||||
_media.Handler?.DisconnectHandler(); // 재생 리소스 해제
|
||||
base.OnDisappearing();
|
||||
}
|
||||
|
||||
private const int PlaybackNotificationId = 4711;
|
||||
|
||||
/// <summary>M2: 재생 중 미디어 알림 — ongoing 노티(닫으면 자동 소멸).</summary>
|
||||
private static void StartPlaybackNotification(string title)
|
||||
{
|
||||
try
|
||||
{
|
||||
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
|
||||
{
|
||||
NotificationId = PlaybackNotificationId,
|
||||
Title = "재생 중",
|
||||
Description = title,
|
||||
Android =
|
||||
{
|
||||
ChannelId = "vd_playback",
|
||||
Ongoing = true,
|
||||
AutoCancel = false,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void StopPlaybackNotification()
|
||||
{
|
||||
try { Plugin.LocalNotification.LocalNotificationCenter.Current.Cancel(PlaybackNotificationId); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>M2: LibraryItem → 썸네일 URL 변환(바인딩 컨버터).</summary>
|
||||
public sealed class ThumbConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c)
|
||||
=> value is LibraryItem it && ApiClient.Instance.IsConfigured
|
||||
? Microsoft.Maui.Controls.ImageSource.FromUri(new Uri(ApiClient.Instance.ThumbUrl(it)))
|
||||
: null;
|
||||
public object ConvertBack(object? v, Type t, object? p, System.Globalization.CultureInfo c) => throw new NotSupportedException();
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true" android:usesCleartextTraffic="true"></application>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- U5 완료 알림(Android 13+) -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- M2 재생중 미디어 알림(포그라운드 서비스 mediaPlayback) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
</manifest>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||
// M1 공유 타겟(U1): 다른 앱의 공유 시트에 "PC에서 받기" 로 등록
|
||||
[IntentFilter(new[] { Intent.ActionSend }, Categories = new[] { Intent.CategoryDefault }, DataMimeType = "text/plain", Label = "PC에서 받기")]
|
||||
public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
ReceiveShare(Intent);
|
||||
}
|
||||
|
||||
protected override void OnNewIntent(Intent? intent)
|
||||
{
|
||||
base.OnNewIntent(intent);
|
||||
ReceiveShare(intent);
|
||||
}
|
||||
|
||||
private static void ReceiveShare(Intent? intent)
|
||||
{
|
||||
if (intent?.Action != Intent.ActionSend) return;
|
||||
var text = intent.GetStringExtra(Intent.ExtraText);
|
||||
ShareTarget.Deliver(text);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
using Android.App;
|
||||
using Android.Runtime;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
[Application]
|
||||
public class MainApplication : MauiApplication
|
||||
{
|
||||
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
|
||||
: base(handle, ownership)
|
||||
{
|
||||
}
|
||||
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#512BD4</color>
|
||||
<color name="colorPrimaryDark">#2B0B98</color>
|
||||
<color name="colorAccent">#2B0B98</color>
|
||||
</resources>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
using Foundation;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
|
||||
<dict>
|
||||
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>PC 와 페어링하기 위해 QR 코드를 스캔합니다.</string>
|
||||
<!-- The Mac App Store requires you specify if the app uses encryption. -->
|
||||
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
|
||||
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
|
||||
<!-- Please indicate <true/> or <false/> here. -->
|
||||
|
||||
<!-- Specify the category for your app here. -->
|
||||
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
|
||||
<!-- <key>LSApplicationCategoryType</key> -->
|
||||
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.lifestyle</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>XSAppIconAssets</key>
|
||||
<string>Assets.xcassets/appicon.appiconset</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
using ObjCRuntime;
|
||||
using UIKit;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
public class Program
|
||||
{
|
||||
// This is the main entry point of the application.
|
||||
static void Main(string[] args)
|
||||
{
|
||||
// if you want to use a different Application Delegate class from "AppDelegate"
|
||||
// you can specify it here.
|
||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<maui:MauiWinUIApplication
|
||||
x:Class="VideoDownloader.Mobile.WinUI.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:maui="using:Microsoft.Maui"
|
||||
xmlns:local="using:VideoDownloader.Mobile.WinUI">
|
||||
|
||||
</maui:MauiWinUIApplication>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
using Microsoft.UI.Xaml;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace VideoDownloader.Mobile.WinUI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : MauiWinUIApplication
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the singleton application object. This is the first line of authored code
|
||||
/// executed, and as such is the logical equivalent of main() or WinMain().
|
||||
/// </summary>
|
||||
public App()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
}
|
||||
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap rescap">
|
||||
|
||||
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="BF83F8D1-2F12-4749-A41C-B693AFE9B129" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
<Properties>
|
||||
<DisplayName>$placeholder$</DisplayName>
|
||||
<PublisherDisplayName>User Name</PublisherDisplayName>
|
||||
<Logo>$placeholder$.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="x-generate" />
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
|
||||
<uap:VisualElements
|
||||
DisplayName="$placeholder$"
|
||||
Description="$placeholder$"
|
||||
Square150x150Logo="$placeholder$.png"
|
||||
Square44x44Logo="$placeholder$.png"
|
||||
BackgroundColor="transparent">
|
||||
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
|
||||
<uap:SplashScreen Image="$placeholder$.png" />
|
||||
</uap:VisualElements>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
</Capabilities>
|
||||
|
||||
</Package>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="VideoDownloader.Mobile.WinUI.app"/>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<!-- The combination of below two tags have the following effect:
|
||||
1) Per-Monitor for >= Windows 10 Anniversary Update
|
||||
2) System < Windows 10 Anniversary Update
|
||||
-->
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
|
||||
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
using Foundation;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<!-- M3 키 3종: LAN http 통신 허용(페어링·스트리밍) + 로컬 네트워크 사유 설명 -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>PC 와 같은 네트워크에서 페어링하고 라이브러리를 스트리밍합니다.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>PC 와 페어링하기 위해 QR 코드를 스캔합니다.</string>
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>XSAppIconAssets</key>
|
||||
<string>Assets.xcassets/appicon.appiconset</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
using ObjCRuntime;
|
||||
using UIKit;
|
||||
|
||||
namespace VideoDownloader.Mobile;
|
||||
|
||||
public class Program
|
||||
{
|
||||
// This is the main entry point of the application.
|
||||
static void Main(string[] args)
|
||||
{
|
||||
// if you want to use a different Application Delegate class from "AppDelegate"
|
||||
// you can specify it here.
|
||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
|
||||
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
|
||||
|
||||
You are responsible for adding extra entries as needed for your application.
|
||||
|
||||
More information: https://aka.ms/maui-privacy-manifest
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!--
|
||||
The entry below is only needed when you're using the Preferences API in your app.
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict> -->
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"profiles": {
|
||||
"Windows Machine": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 228 B |
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB |
|
|
@ -1,15 +0,0 @@
|
|||
Any raw assets you want to be deployed with your application can be placed in
|
||||
this directory (and child directories). Deployment of the asset to your application
|
||||
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
|
||||
|
||||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
|
||||
These files will be deployed with your package and will be accessible using Essentials:
|
||||
|
||||
async Task LoadMauiAsset()
|
||||
{
|
||||
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var contents = reader.ReadToEnd();
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
|
@ -1,44 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml -->
|
||||
|
||||
<Color x:Key="Primary">#512BD4</Color>
|
||||
<Color x:Key="PrimaryDark">#ac99ea</Color>
|
||||
<Color x:Key="PrimaryDarkText">#242424</Color>
|
||||
<Color x:Key="Secondary">#DFD8F7</Color>
|
||||
<Color x:Key="SecondaryDarkText">#9880e5</Color>
|
||||
<Color x:Key="Tertiary">#2B0B98</Color>
|
||||
|
||||
<Color x:Key="White">White</Color>
|
||||
<Color x:Key="Black">Black</Color>
|
||||
<Color x:Key="Magenta">#D600AA</Color>
|
||||
<Color x:Key="MidnightBlue">#190649</Color>
|
||||
<Color x:Key="OffBlack">#1f1f1f</Color>
|
||||
|
||||
<Color x:Key="Gray100">#E1E1E1</Color>
|
||||
<Color x:Key="Gray200">#C8C8C8</Color>
|
||||
<Color x:Key="Gray300">#ACACAC</Color>
|
||||
<Color x:Key="Gray400">#919191</Color>
|
||||
<Color x:Key="Gray500">#6E6E6E</Color>
|
||||
<Color x:Key="Gray600">#404040</Color>
|
||||
<Color x:Key="Gray900">#212121</Color>
|
||||
<Color x:Key="Gray950">#141414</Color>
|
||||
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
|
||||
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
|
||||
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
|
||||
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
|
||||
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
|
||||
|
||||
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
|
||||
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
|
||||
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
|
||||
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
|
||||
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
|
||||
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
|
||||
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
|
||||
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
|
||||
</ResourceDictionary>
|
||||
|
|
@ -1,434 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<Style TargetType="ActivityIndicator">
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="IndicatorView">
|
||||
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
|
||||
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="StrokeShape" Value="Rectangle"/>
|
||||
<Setter Property="StrokeThickness" Value="1"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="BoxView">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="BorderWidth" Value="0"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Padding" Value="14,10"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="PointerOver" />
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="DatePicker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Editor">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Entry">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ImageButton">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
<Setter Property="BorderColor" Value="Transparent"/>
|
||||
<Setter Property="BorderWidth" Value="0"/>
|
||||
<Setter Property="CornerRadius" Value="0"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="PointerOver" />
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label" x:Key="Headline">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontSize" Value="32" />
|
||||
<Setter Property="HorizontalOptions" Value="Center" />
|
||||
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label" x:Key="SubHeadline">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="HorizontalOptions" Value="Center" />
|
||||
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Picker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="RadioButton">
|
||||
<Setter Property="BackgroundColor" Value="Transparent"/>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="RefreshView">
|
||||
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SearchBar">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SearchHandler">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Shadow">
|
||||
<Setter Property="Radius" Value="15" />
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Offset" Value="10,10" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Slider">
|
||||
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SwipeItem">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Switch">
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="ThumbColor" Value="{StaticResource White}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="On">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Off">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TimePicker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent"/>
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
<Style TargetType="TitleBar">
|
||||
<Setter Property="MinimumHeightRequest" Value="32"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="TitleActiveStates">
|
||||
<VisualState x:Name="TitleBarTitleActive">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="TitleBarTitleInactive">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
-->
|
||||
|
||||
<Style TargetType="Page" ApplyToDerivedTypes="True">
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Shell" ApplyToDerivedTypes="True">
|
||||
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
|
||||
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
|
||||
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="Shell.NavBarHasShadow" Value="False" />
|
||||
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="NavigationPage">
|
||||
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
|
||||
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TabbedPage">
|
||||
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>M1 공유 타겟(U1): 플랫폼(안드로이드 인텐트)이 전달한 공유 텍스트 큐.</summary>
|
||||
public static class ShareTarget
|
||||
{
|
||||
private static string? _pending;
|
||||
|
||||
public static void Deliver(string? sharedText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sharedText)) return;
|
||||
Volatile.Write(ref _pending, sharedText);
|
||||
}
|
||||
|
||||
/// <summary>대기 중 공유 텍스트를 꺼낸다(없으면 null).</summary>
|
||||
public static string? Consume() => Interlocked.Exchange(ref _pending, null);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>컴팠니언 루트: 큐 / 라이브러리 / 저장 / 연결 탭 + 공유 타겟 처리.</summary>
|
||||
public sealed class TabbedRootPage : TabbedPage
|
||||
{
|
||||
public TabbedRootPage()
|
||||
{
|
||||
ApiClient.Instance.Restore();
|
||||
var queuePage = new QueuePage();
|
||||
Children.Add(queuePage);
|
||||
Children.Add(new LibraryPage());
|
||||
Children.Add(new DownloadsPage());
|
||||
Children.Add(new ConnectPage());
|
||||
Title = "VD 원격";
|
||||
|
||||
// M1 공유 타겟(U1): 어떤 탭에 있든/다른 앱에서 공유받으면 URL 을 PC 큐로 전송 후 큐 탭 표시
|
||||
Device.StartTimer(TimeSpan.FromSeconds(2), () =>
|
||||
{
|
||||
_ = HandleShareAsync(queuePage);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task HandleShareAsync(QueuePage queuePage)
|
||||
{
|
||||
var shared = ShareTarget.Consume();
|
||||
if (shared == null || !ApiClient.Instance.IsConfigured) return;
|
||||
var url = SharePayload.ExtractUrl(shared);
|
||||
if (url == null) { queuePage.ShowSharedResult("🔗 공유받은 텍스트에 URL 이 없습니다"); return; }
|
||||
var ok = await ApiClient.Instance.AddAsync(url);
|
||||
queuePage.ShowSharedResult(ok ? $"🔗 공유 링크 전송됨: {url}" : "❌ 공유 링크 전송 실패");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Windows 에선 ios 워크로드가 없으므로 android 만; macOS 빌드머신에서 ios TFM 추가 (M3) -->
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('OSX'))">net10.0-android;net10.0-ios</TargetFrameworks>
|
||||
<TargetFrameworks Condition="!$([MSBuild]::IsOSPlatform('OSX'))">net10.0-android</TargetFrameworks>
|
||||
|
||||
<!-- CI 배포 APK 는 arm64 단독(실기기 표준). CLI 전역 -p:RuntimeIdentifiers 는 참조 프로젝트
|
||||
(net8.0 Mobile.Core)까지 전파돼 MSB3030 으로 깨지므로, 게이트 속성을 받아 여기서만 좁힌다. -->
|
||||
<RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' and '$(CiPackArm64Only)' == 'true'">android-arm64</RuntimeIdentifiers>
|
||||
|
||||
|
||||
<!-- Note for MacCatalyst:
|
||||
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
|
||||
When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
|
||||
The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
|
||||
either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
|
||||
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
|
||||
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>VideoDownloader.Mobile</RootNamespace>
|
||||
<UseMaui>true</UseMaui>
|
||||
<SingleProject>true</SingleProject>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<!-- Enable XAML source generation for faster build times and improved performance.
|
||||
This generates C# code from XAML at compile time instead of runtime inflation.
|
||||
To disable, remove this line.
|
||||
For individual files, you can override by setting Inflator metadata:
|
||||
<MauiXaml Update="MyPage.xaml" Inflator="Default" /> (reverts to defaults: Runtime for Debug, XamlC for Release)
|
||||
<MauiXaml Update="MyPage.xaml" Inflator="Runtime" /> (force runtime inflation) -->
|
||||
<MauiXamlInflator>SourceGen</MauiXamlInflator>
|
||||
|
||||
<!-- Display name -->
|
||||
<ApplicationTitle>MediaBrowser</ApplicationTitle>
|
||||
|
||||
<!-- App Identifier -->
|
||||
<ApplicationId>com.companyname.videodownloader.mobile</ApplicationId>
|
||||
|
||||
<!-- Versions -->
|
||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
|
||||
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- App Icon -->
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
|
||||
|
||||
<!-- Splash Screen -->
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
|
||||
|
||||
<!-- Custom Fonts -->
|
||||
<MauiFont Include="Resources\Fonts\*" />
|
||||
|
||||
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
|
||||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VideoDownloader.Mobile.Core\VideoDownloader.Mobile.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
|
||||
<!-- U5 완료 알림 -->
|
||||
<PackageReference Include="Plugin.LocalNotification" Version="11.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
|
||||
<PackageReference Include="ZXing.Net.Maui.Controls" Version="0.4.0" />
|
||||
<PackageReference Include="CommunityToolkit.Maui.MediaElement" Version="3.2.0" />
|
||||
<!-- 모달 NavigationPage 를 링커가 싣지 않아 FileNotFoundException 으로 크래시 → 명시 참조 (Android 전용) -->
|
||||
<PackageReference Include="Xamarin.AndroidX.LocalBroadcastManager" Version="1.1.0.24" Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
namespace VideoDownloader.Server;
|
||||
|
||||
/// <summary>
|
||||
/// M0 원격 접근 서버: 데스크톱 앱에 내장되는 Kestrel.
|
||||
/// QR 페어링 토큰(Bearer) 인증 + 큐 API + 라이브러리/Range 스트리밍 + 제로 설치 웹 컴패니언.
|
||||
/// LAN 직결(계정/클라우드 없음) — MOBILE_PLAN §3 아키텍처.
|
||||
/// </summary>
|
||||
public sealed class ServerHost : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
private readonly string _token;
|
||||
private readonly ServerOptions _options;
|
||||
private readonly Action? _unsubscribeQueueChanged;
|
||||
private readonly MdnsAdvertiser? _mdns;
|
||||
|
||||
public int Port { get; }
|
||||
/// <summary>QR 에 담을 페어링 URL 형식: http://<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,
|
||||
Action? unsubscribeQueueChanged, MdnsAdvertiser? mdns, Exception? mdnsError)
|
||||
{
|
||||
_app = app;
|
||||
Port = port;
|
||||
_token = token;
|
||||
_options = options;
|
||||
_unsubscribeQueueChanged = unsubscribeQueueChanged;
|
||||
_mdns = mdns;
|
||||
MdnsError = mdnsError;
|
||||
}
|
||||
|
||||
public static async Task<ServerHost> StartAsync(ServerOptions options)
|
||||
{
|
||||
var token = string.IsNullOrEmpty(options.Token)
|
||||
? WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32))
|
||||
: options.Token;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = new[] { "--urls", $"http://0.0.0.0:{(options.Port == 0 ? GetFreePort() : options.Port)}" } });
|
||||
foreach (var prov in builder.Services.Where(sd => sd.ServiceType.FullName!.Contains("LoggerProvider")).ToList())
|
||||
builder.Services.Remove(prov);
|
||||
builder.Services.AddSingleton<IQueueAdapter>(options.Queue);
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseWebSockets(); // SignalR 웹소켓 전송용 (없으면 롱폴링만 됨)
|
||||
|
||||
// Bearer 토큰 인증(상수시간 비교) — / 와 /api/* 전체 적용
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (!ctx.Request.Path.StartsWithSegments("/api/pair") &&
|
||||
(ctx.Request.Path.StartsWithSegments("/api") || ctx.Request.Path.StartsWithSegments("/stream") ||
|
||||
ctx.Request.Path.StartsWithSegments("/file") || ctx.Request.Path.StartsWithSegments("/hubs")))
|
||||
{
|
||||
var auth = ctx.Request.Headers.Authorization.ToString();
|
||||
var provided = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth[7..].Trim() : "";
|
||||
// <video>/<a> 태그는 헤더를 못 실음 → 쿼리 토큰도 허용(LAN 한정)
|
||||
if (provided.Length == 0 && ctx.Request.Query.TryGetValue("t", out var q)) provided = q.ToString();
|
||||
// SignalR 클라이언트 관례(AccessTokenProvider)는 access_token 쿼리로 실어 보냄
|
||||
if (provided.Length == 0 && ctx.Request.Query.TryGetValue("access_token", out var at)) provided = at.ToString();
|
||||
if (!FixedTimeEquals(provided, token))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// ── 페어링 ──
|
||||
app.MapPost("/api/pair", async (HttpContext ctx) =>
|
||||
{
|
||||
var body = await ctx.Request.ReadFromJsonAsync<PairRequest>();
|
||||
if (body == null || !FixedTimeEquals(body.Token ?? "", token))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
await ctx.Response.WriteAsJsonAsync(new { ok = true, device = options.DeviceName });
|
||||
});
|
||||
|
||||
// ── 큐 ──
|
||||
var queue = options.Queue;
|
||||
app.MapGet("/api/queue", async (HttpContext ctx) =>
|
||||
await ctx.Response.WriteAsJsonAsync(await queue.GetItemsAsync()));
|
||||
|
||||
app.MapPost("/api/queue", async (HttpContext ctx) =>
|
||||
{
|
||||
var body = await ctx.Request.ReadFromJsonAsync<QueueAddRequest>();
|
||||
if (body == null || !Uri.TryCreate(body.Url, UriKind.Absolute, out _))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
var id = await queue.AddAsync(body.Url, body.Title);
|
||||
await ctx.Response.WriteAsJsonAsync(new { id });
|
||||
});
|
||||
|
||||
app.MapPost("/api/queue/{id}/pause", async (string id) => { await queue.PauseAsync(id); Results.Ok(); });
|
||||
app.MapPost("/api/queue/{id}/resume", async (string id) => { await queue.ResumeAsync(id); Results.Ok(); });
|
||||
app.MapPost("/api/queue/{id}/cancel", async (string id) => { await queue.CancelAsync(id); Results.Ok(); });
|
||||
|
||||
// ── 라이브러리 / 스트리밍 ──
|
||||
// ── 라이브러리 + 썸네일/해상도(M2) ──
|
||||
var thumbnailer = options.Thumbnailer ?? ServerOptionsDefaults.DefaultThumbnailer();
|
||||
var ffmpeg = options.FfmpegPath ?? VideoDownloader.Core.Platform.DesktopMediaMuxer.Default.ResolveFfmpeg(new VideoDownloader.Core.Config.AppConfig());
|
||||
app.MapGet("/api/library", async (HttpContext ctx) =>
|
||||
{
|
||||
var items = MediaLibrary.List(options.LibraryFolder);
|
||||
if (ffmpeg != null)
|
||||
{
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var res = await thumbnailer.GetResolutionAsync(ffmpeg, Path.Combine(options.LibraryFolder, items[i].FileName));
|
||||
items[i] = items[i] with { Resolution = res };
|
||||
}
|
||||
}
|
||||
await ctx.Response.WriteAsJsonAsync(items);
|
||||
});
|
||||
|
||||
app.MapGet("/api/library/{id}/thumb", async (string id) =>
|
||||
{
|
||||
var file = MediaLibrary.Find(options.LibraryFolder, id);
|
||||
if (file == null || ffmpeg == null) return Results.NotFound();
|
||||
var jpg = await thumbnailer.GetThumbnailAsync(ffmpeg, file.Value.FullPath);
|
||||
return jpg == null ? Results.NotFound() : Results.File(jpg, "image/jpeg");
|
||||
});
|
||||
|
||||
app.MapGet("/stream/{id}", (string id) =>
|
||||
{
|
||||
var file = MediaLibrary.Find(options.LibraryFolder, id);
|
||||
return file == null
|
||||
? Results.NotFound()
|
||||
: Results.File(file.Value.FullPath, "video/mp4", enableRangeProcessing: true); // 206 Partial Content → 시크 지원
|
||||
});
|
||||
|
||||
app.MapGet("/file/{id}", (string id) =>
|
||||
{
|
||||
var file = MediaLibrary.Find(options.LibraryFolder, id);
|
||||
return file == null ? Results.NotFound() : Results.File(file.Value.FullPath, "application/octet-stream", file.Value.FileName);
|
||||
});
|
||||
|
||||
// ── 이어보기 위치 동기화 (U3) ──
|
||||
var positions = new PositionStore(options.LibraryFolder);
|
||||
app.MapGet("/api/position/{id}", (string id) => Results.Json(new PositionDto(positions.Get(id))));
|
||||
app.MapPost("/api/position/{id}", async (string id, HttpContext ctx) =>
|
||||
{
|
||||
var body = await ctx.Request.ReadFromJsonAsync<PositionDto>();
|
||||
if (body == null) return Results.BadRequest();
|
||||
positions.Set(id, body.PositionSec);
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
// ── 실시간 큐 허브 (MOBILE_PLAN §3 /hubs/queue) ──
|
||||
app.MapHub<QueueHub>("/hubs/queue");
|
||||
var hubContext = app.Services.GetRequiredService<Microsoft.AspNetCore.SignalR.IHubContext<QueueHub>>();
|
||||
Action onQueueChanged = () => _ = BroadcastQueueAsync(hubContext, queue);
|
||||
queue.Changed += onQueueChanged;
|
||||
|
||||
// ── 웹 컴패니언 (제로 설치 클라이언트) ──
|
||||
app.MapGet("/", () => Results.Text(WebCompanion.Html, "text/html; charset=utf-8"));
|
||||
|
||||
await app.StartAsync();
|
||||
var actualPort = options.Port;
|
||||
if (actualPort == 0)
|
||||
{
|
||||
var m = app.Urls.SelectMany(u => System.Text.RegularExpressions.Regex.Matches(u, @":(\d+)"))
|
||||
.Select(m2 => int.Parse(m2.Groups[1].Value)).FirstOrDefault();
|
||||
actualPort = m;
|
||||
}
|
||||
|
||||
// ── mDNS 광고 (_videodl._tcp) — 발견 보조. 실패는 MdnsError 로 드러내되 서버는 유지 ──
|
||||
MdnsAdvertiser? mdns = null;
|
||||
Exception? mdnsError = null;
|
||||
if (options.EnableMdns)
|
||||
{
|
||||
try
|
||||
{
|
||||
mdns = new MdnsAdvertiser(options.DeviceName, actualPort);
|
||||
mdns.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
mdnsError = ex;
|
||||
mdns?.Dispose();
|
||||
mdns = null;
|
||||
}
|
||||
}
|
||||
|
||||
return new ServerHost(app, actualPort, token, options, () => queue.Changed -= onQueueChanged, mdns, mdnsError);
|
||||
}
|
||||
|
||||
/// <summary>큐 변경 → 전 클라이언트 푸시. 종료 경합만 무시(그 외 실패는 UnobservedTaskException 로 관찰됨).</summary>
|
||||
private static async Task BroadcastQueueAsync(Microsoft.AspNetCore.SignalR.IHubContext<QueueHub> hub, IQueueAdapter queue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var items = await queue.GetItemsAsync();
|
||||
await hub.Clients.All.SendAsync("queueUpdated", items);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// 서버 종료와 경합한 마지막 푸시 — 클라이언트는 재접속/스냅샷으로 보정
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_unsubscribeQueueChanged?.Invoke();
|
||||
_mdns?.Dispose();
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
using var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string a, string b)
|
||||
{
|
||||
var ha = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(a));
|
||||
var hb = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(b));
|
||||
return CryptographicOperations.FixedTimeEquals(ha, hb);
|
||||
}
|
||||
|
||||
private sealed record PairRequest(string? Token, string? Device);
|
||||
private sealed record QueueAddRequest(string Url, string? Title);
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
namespace VideoDownloader.Server;
|
||||
|
||||
/// <summary>
|
||||
/// 제로 설치 웹 컴패니언(PairDrop/Jellyfin 패턴): 폰 카메라로 QR 스캔 →
|
||||
/// 브라우저에서 바로 큐 조회/URL 추가. 토큰은 URL 프래그먼트(#t=)로 전달되어
|
||||
/// 서버 로그/리퍼러에 남지 않는다.
|
||||
/// </summary>
|
||||
public static class WebCompanion
|
||||
{
|
||||
public const string Html = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VideoDownloader 원격</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { background:#14151d; color:#e6e6ec; font-family:'Segoe UI',system-ui,sans-serif; margin:0; padding:20px 16px 40px; max-width:680px; margin-inline:auto; }
|
||||
h1 { font-size:1.25rem; display:flex; align-items:center; gap:10px; }
|
||||
.badge { background:#5b8def; color:#fff; border-radius:8px; padding:2px 10px; font-size:.75rem; }
|
||||
.card { background:#1e2030; border:1px solid #2a2c42; border-radius:14px; padding:14px; margin:14px 0; }
|
||||
input[type=text] { width:100%; box-sizing:border-box; padding:12px 14px; border-radius:10px; border:1px solid #3a3c55; background:#14151d; color:#e6e6ec; font-size:15px; }
|
||||
button { background:#5b8def; color:#fff; border:none; border-radius:10px; padding:11px 18px; font-size:14px; font-weight:600; margin-top:10px; cursor:pointer; }
|
||||
button.sec { background:#2a2c42; }
|
||||
.item { background:#1e2030; border:1px solid #2a2c42; border-radius:12px; padding:12px; margin:8px 0; display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.item .t { font-weight:600; font-size:.95rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.item .s { color:#9a9ab0; font-size:.8rem; }
|
||||
.pbar { height:6px; background:#2a2c42; border-radius:4px; overflow:hidden; flex:1; }
|
||||
.pbar>div { height:100%; background:#5b8def; }
|
||||
.row { display:flex; gap:8px; align-items:center; }
|
||||
.muted { color:#9a9ab0; font-size:.85rem; }
|
||||
a { color:#5b8def; }
|
||||
video { width:100%; border-radius:12px; background:#000; margin-top:8px; }
|
||||
.err { color:#ff6b6b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📺 VideoDownloader <span class="badge" id="dev">연결중</span></h1>
|
||||
|
||||
<div class="card">
|
||||
<b>링크 보내 PC에서 받기</b>
|
||||
<div class="muted">YouTube·웹페이지 URL 을 붙여넣으세요</div>
|
||||
<input type="text" id="url" placeholder="https://... " autocomplete="off">
|
||||
<button onclick="addUrl()">받기 요청</button>
|
||||
<span id="addMsg" class="muted"></span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<b>다운로드 큐</b> <span class="muted" id="qcount"></span>
|
||||
<div id="queue"><div class="muted">불러오는 중…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<b>PC 라이브러리</b> <span class="muted">탭하여 스트리밍 재생</span>
|
||||
<div id="lib"><div class="muted">불러오는 중…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="muted" style="margin-top:24px">같은 Wi-Fi 에서만 접속됩니다 · LAN 직결(계정 없음)</div>
|
||||
|
||||
<script>
|
||||
const token = (location.hash.match(/t=([^&]+)/) || [])[1] || '';
|
||||
if (!token) document.body.innerHTML = '<div class="card err">토큰이 없습니다. PC 앱의 QR 코드로 다시 접속하세요.</div>';
|
||||
const H = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
|
||||
async function api(path, opts) {
|
||||
const r = await fetch(path, Object.assign({ headers: H }, opts));
|
||||
if (r.status === 401) { document.getElementById('dev').textContent = '인증 실패'; throw new Error('401'); }
|
||||
return r;
|
||||
}
|
||||
|
||||
async function addUrl() {
|
||||
const url = document.getElementById('url').value.trim();
|
||||
if (!url) return;
|
||||
document.getElementById('addMsg').textContent = '전송 중…';
|
||||
try {
|
||||
const r = await api('/api/queue', { method: 'POST', body: JSON.stringify({ url, title: url }) });
|
||||
document.getElementById('addMsg').textContent = r.ok ? '✅ PC 대기열에 추가됨' : '❌ 실패';
|
||||
document.getElementById('url').value = '';
|
||||
refreshQueue();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function refreshQueue() {
|
||||
try {
|
||||
const items = await (await api('/api/queue')).json();
|
||||
const el = document.getElementById('queue');
|
||||
document.getElementById('qcount').textContent = items.length + '건';
|
||||
if (!items.length) { el.innerHTML = '<div class="muted">대기 중인 항목이 없습니다</div>'; return; }
|
||||
el.innerHTML = items.map(i => `
|
||||
<div class="item">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="t">${esc(i.title)}</div>
|
||||
<div class="row" style="margin-top:6px">
|
||||
<div class="pbar"><div style="width:${Math.round(i.percent)}%"></div></div>
|
||||
<span class="s">${esc(i.status)} ${Math.round(i.percent)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="sec" onclick="ctl('${i.id}','pause')">⏸</button>
|
||||
<button class="sec" onclick="ctl('${i.id}','resume')">▶</button>
|
||||
<button class="sec" onclick="ctl('${i.id}','cancel')">✕</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function ctl(id, action) { try { await api('/api/queue/' + id + '/' + action, { method: 'POST' }); refreshQueue(); } catch (e) {} }
|
||||
|
||||
async function refreshLib() {
|
||||
try {
|
||||
const items = await (await api('/api/library')).json();
|
||||
const el = document.getElementById('lib');
|
||||
if (!items.length) { el.innerHTML = '<div class="muted">저장 폴더가 비었습니다</div>'; return; }
|
||||
el.innerHTML = items.map(i => `
|
||||
<div class="item">
|
||||
<div class="t" style="flex:1">${esc(i.fileName)}</div>
|
||||
<span class="s">${fmtSize(i.sizeBytes)}</span>
|
||||
<button class="sec" onclick="play('${i.id}')">▶ 재생</button>
|
||||
</div>`).join('');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function play(id) {
|
||||
let v = document.getElementById('pv');
|
||||
if (!v) {
|
||||
v = document.createElement('video');
|
||||
v.id = 'pv'; v.controls = true;
|
||||
document.body.appendChild(v);
|
||||
v.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
v.src = '/stream/' + id + '?t=' + encodeURIComponent(token); // <video> 는 헤더 불가 → 쿼리 토큰
|
||||
v.play().catch(()=>{});
|
||||
}
|
||||
|
||||
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
function fmtSize(b) { return b > 1048576 ? (b/1048576).toFixed(1) + ' MB' : (b/1024).toFixed(0) + ' KB'; }
|
||||
|
||||
(async function init() {
|
||||
const r = await fetch('/api/pair', { method: 'POST', headers: H, body: JSON.stringify({ token, device: navigator.userAgent.slice(0, 40) }) });
|
||||
document.getElementById('dev').textContent = r.ok ? '연결됨' : '인증 실패';
|
||||
refreshQueue(); refreshLib();
|
||||
setInterval(refreshQueue, 2000); // v1 폴링 (SignalR 는 앱 확장 시)
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
using VideoDownloader.App.Browser.Features;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
// ── T2-10 미디어 재생목록 (Brave Playlist 패턴): 감지된 미디어를 쌓고 재생/제거/영속 ──
|
||||
public class MediaPlaylistTests : IDisposable
|
||||
{
|
||||
private readonly string _path = Path.Combine(Path.GetTempPath(), $"playlist_{Guid.NewGuid():N}.json");
|
||||
private readonly MediaPlaylist _pl = new();
|
||||
|
||||
private MediaTrack Mk(string url = "https://v.test/a.mp4", string title = "트랙A", string kind = "Direct")
|
||||
=> new(url, title, kind, AddedAt: DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void Add는_재생목록_끝에_추가하고_중복_URL은_무시한다()
|
||||
{
|
||||
var t = Mk();
|
||||
_pl.Add(t);
|
||||
_pl.Add(Mk(url: "https://v.test/b.mp4", title: "트랙B"));
|
||||
_pl.Add(Mk()); // 동일 URL 중복
|
||||
Assert.Equal(2, _pl.Tracks.Count);
|
||||
Assert.Equal("트랙A", _pl.Tracks[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove와_Clear()
|
||||
{
|
||||
var a = Mk(); var b = Mk("https://v.test/b.mp4", "B");
|
||||
_pl.Add(a); _pl.Add(b);
|
||||
_pl.Remove(a);
|
||||
Assert.Single(_pl.Tracks);
|
||||
_pl.Clear();
|
||||
Assert.Empty(_pl.Tracks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 다음_이전_트랙은_순환한다()
|
||||
{
|
||||
_pl.Add(Mk("u1", "1"));
|
||||
_pl.Add(Mk("u2", "2"));
|
||||
_pl.Add(Mk("u3", "3"));
|
||||
_pl.CurrentIndex = 0;
|
||||
|
||||
Assert.Equal(1, _pl.Next());
|
||||
Assert.Equal(2, _pl.Next());
|
||||
Assert.Equal(0, _pl.Next()); // 순환
|
||||
Assert.Equal(2, _pl.Previous());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 현재_트랙은_CurrentIndex로_얻는다()
|
||||
{
|
||||
_pl.Add(Mk("u1", "1"));
|
||||
_pl.Add(Mk("u2", "2"));
|
||||
_pl.CurrentIndex = 1;
|
||||
Assert.Equal("u2", _pl.Current?.Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 영속_라운드트립()
|
||||
{
|
||||
_pl.Add(Mk("u1", "제목1", "Hls"));
|
||||
_pl.Add(Mk("u2", "제목2", "Direct"));
|
||||
_pl.CurrentIndex = 1;
|
||||
_pl.Save(_path);
|
||||
|
||||
var loaded = new MediaPlaylist();
|
||||
Assert.Equal(0, loaded.Tracks.Count);
|
||||
loaded.Load(_path);
|
||||
Assert.Equal(2, loaded.Tracks.Count);
|
||||
Assert.Equal("제목1", loaded.Tracks[0].Title);
|
||||
Assert.Equal("Hls", loaded.Tracks[0].Kind);
|
||||
Assert.Equal(1, loaded.CurrentIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 손상된_JSON은_빈_목록으로_시작()
|
||||
{
|
||||
File.WriteAllText(_path, "{broken");
|
||||
var pl = new MediaPlaylist();
|
||||
pl.Load(_path);
|
||||
Assert.Empty(pl.Tracks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 재생완료_표시와_완료필터()
|
||||
{
|
||||
_pl.Add(Mk("u1", "1"));
|
||||
_pl.Add(Mk("u2", "2"));
|
||||
_pl.MarkPlayed(_pl.Tracks[0]);
|
||||
Assert.True(_pl.Tracks[0].Played);
|
||||
Assert.False(_pl.Tracks[1].Played);
|
||||
}
|
||||
|
||||
public void Dispose() { try { File.Delete(_path); } catch { } }
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
using VideoDownloader.App.Browser.Features;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
/// <summary>페이지 다운로드 추상화(다운로드 매니저 UI 의 로직 코어) — fake op 로 검증.</summary>
|
||||
public interface IFakeOpHost { }
|
||||
|
||||
public class PageDownloadTrackerTests
|
||||
{
|
||||
private sealed class FakeOp : IDownloadOperation
|
||||
{
|
||||
public string Uri { get; set; } = "https://x.test/file.zip";
|
||||
public string FileName { get; set; } = "file.zip";
|
||||
public long BytesReceived { get; set; }
|
||||
public long TotalBytesToReceive { get; set; } = 1000;
|
||||
public string State { get; set; } = "InProgress";
|
||||
public bool Paused, Resumed, Cancelled;
|
||||
|
||||
public event Action? Changed;
|
||||
public void Raise() => Changed?.Invoke();
|
||||
|
||||
public void Pause() { Paused = true; State = "Paused"; Raise(); }
|
||||
public void Resume() { Resumed = true; State = "InProgress"; Raise(); }
|
||||
public void Cancel() { Cancelled = true; State = "Cancelled"; Raise(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add는_항목을_등록하고_최근순_정렬한다()
|
||||
{
|
||||
var t = new PageDownloadTracker();
|
||||
var a = new FakeOp { FileName = "a.zip" };
|
||||
var b = new FakeOp { FileName = "b.zip" };
|
||||
t.Add(a, a.FileName);
|
||||
t.Add(b, b.FileName);
|
||||
Assert.Equal(2, t.Items.Count);
|
||||
Assert.Equal("b.zip", t.Items[0].FileName); // 최신 우선
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 진행률과_속도를_계산한다()
|
||||
{
|
||||
var t = new PageDownloadTracker();
|
||||
var op = new FakeOp { TotalBytesToReceive = 1000 };
|
||||
var item = t.Add(op, "f.zip");
|
||||
|
||||
item.UpdateSpeed(500, TimeSpan.FromSeconds(2)); // 500B/2s = 250 B/s
|
||||
Assert.Equal(50.0, item.Percent, 2);
|
||||
Assert.Equal(250, item.BytesPerSecond);
|
||||
Assert.Equal("250.0 B/s", item.SpeedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 속도_단위는_KB_MB로_표시된다()
|
||||
{
|
||||
var t = new PageDownloadTracker();
|
||||
var item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "f");
|
||||
|
||||
item.UpdateSpeed(0, TimeSpan.FromSeconds(1)); // 초기
|
||||
Assert.Equal("—", item.SpeedText);
|
||||
|
||||
item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "g");
|
||||
item.UpdateSpeed(2048 * 100, TimeSpan.FromSeconds(1)); // 200 KB/s
|
||||
Assert.Equal("200.0 KB/s", item.SpeedText);
|
||||
|
||||
item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "h");
|
||||
item.UpdateSpeed(3 * 1024 * 1024L, TimeSpan.FromSeconds(1)); // 3 MB/s
|
||||
Assert.Equal("3.0 MB/s", item.SpeedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pause_Resume_Cancel_위임()
|
||||
{
|
||||
var t = new PageDownloadTracker();
|
||||
var op = new FakeOp();
|
||||
var item = t.Add(op, "f");
|
||||
|
||||
t.Pause(item); t.Resume(item); t.Cancel(item);
|
||||
Assert.True(op.Paused && op.Resumed && op.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 완료_항목은_ClearCompleted로_일괄_제거()
|
||||
{
|
||||
var t = new PageDownloadTracker();
|
||||
var done = new FakeOp { State = "Completed" };
|
||||
var running = new FakeOp { State = "InProgress" };
|
||||
t.Add(done, "d"); t.Add(running, "r");
|
||||
|
||||
t.ClearCompleted();
|
||||
Assert.Single(t.Items);
|
||||
Assert.Equal("r", t.Items[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 취소된_항목도_ClearCompleted로_제거된다()
|
||||
{
|
||||
var t = new PageDownloadTracker();
|
||||
var c = new FakeOp { State = "Interrupted" };
|
||||
t.Add(c, "c");
|
||||
t.ClearCompleted();
|
||||
Assert.Empty(t.Items);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
using VideoDownloader.Browser;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
/// <summary>T3-11: 사이트별 권한 정책 — 기억된 상태 우선, 미기억은 Ask(프롬프트).</summary>
|
||||
public class PermissionGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void 미기억_권한은_Ask()
|
||||
=> Assert.Equal(PermissionVerdict.Ask, new PermissionGate().Decide("notifications", "https://x.com"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("allow")]
|
||||
[InlineData("deny")]
|
||||
public void 기억된_판정은_그대로_반환(string v)
|
||||
{
|
||||
var g = new PermissionGate();
|
||||
g.Remember("notifications", "https://x.com", v == "allow" ? PermissionVerdict.Allow : PermissionVerdict.Deny);
|
||||
Assert.Equal(v == "allow" ? PermissionVerdict.Allow : PermissionVerdict.Deny,
|
||||
g.Decide("notifications", "https://x.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 판정은_출처_단위_격리()
|
||||
{
|
||||
var g = new PermissionGate();
|
||||
g.Remember("geolocation", "https://a.com", PermissionVerdict.Allow);
|
||||
Assert.Equal(PermissionVerdict.Ask, g.Decide("geolocation", "https://b.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 판정은_종류_단위_격리()
|
||||
{
|
||||
var g = new PermissionGate();
|
||||
g.Remember("notifications", "https://a.com", PermissionVerdict.Allow);
|
||||
Assert.Equal(PermissionVerdict.Ask, g.Decide("camera", "https://a.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherSensors_등_민감_종류는_자동_거절()
|
||||
=> Assert.Equal(PermissionVerdict.Deny, new PermissionGate().Decide("OtherSensors", "https://x.com"));
|
||||
|
||||
[Fact]
|
||||
public void MultipleAutomaticDownloads_파일_다운로드는_자동_허용()
|
||||
=> Assert.Equal(PermissionVerdict.Allow, new PermissionGate().Decide("MultipleAutomaticDownloads", "https://x.com"));
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
using VideoDownloader.Browser;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
// T1-7 사이트별 줌 영속: 도메인→배율 저장, 범위 클램프, JSON 영속, 최대 항목 제한.
|
||||
public class SiteZoomStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly SiteZoomStore _store;
|
||||
|
||||
public SiteZoomStoreTests()
|
||||
{
|
||||
_path = Path.Combine(Path.GetTempPath(), $"zoomstore_{Guid.NewGuid():N}.json");
|
||||
_store = new SiteZoomStore(_path);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_path); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 미등록_사이트는_기본값_1_0을_반환한다()
|
||||
{
|
||||
Assert.Equal(1.0, _store.Get("https://example.com/page"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 배율을_저장하면_같은_호스트의_다른_경로에서도_읽힌다()
|
||||
{
|
||||
_store.Set("https://example.com/a", 1.5);
|
||||
Assert.Equal(1.5, _store.Get("https://example.com/b/c/d"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void www_접두사는_동일_호스트로_정규화된다()
|
||||
{
|
||||
_store.Set("https://www.example.com/a", 2.0);
|
||||
Assert.Equal(2.0, _store.Get("https://example.com/b"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.05, 0.1)] // 하한 클램프
|
||||
[InlineData(9.0, 5.0)] // 상한 클램프
|
||||
[InlineData(1.25, 1.25)] // 정상 범위
|
||||
public void 배율은_0_1에서_5_0으로_클램프된다(double input, double expected)
|
||||
{
|
||||
_store.Set("https://clamp.com/", input);
|
||||
Assert.Equal(expected, _store.Get("https://clamp.com/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 저장_즉시_파일에_영속되어_새_인스턴스가_읽는다()
|
||||
{
|
||||
_store.Set("https://persist.com/", 1.75);
|
||||
Assert.True(File.Exists(_path));
|
||||
var reloaded = new SiteZoomStore(_path);
|
||||
Assert.Equal(1.75, reloaded.Get("https://persist.com/other"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 최대_300개_초과시_가장_오래_사용하지_한_항목부터_제거된다()
|
||||
{
|
||||
// 300개 채우기
|
||||
for (int i = 0; i < 300; i++)
|
||||
_store.Set($"https://site{i}.com/", 1.0 + i * 0.001);
|
||||
// 가장 먼저 넣은 site0 을 최근에 다시 설정(LRU 갱신) → site1 이 제거 후보가 됨
|
||||
_store.Set("https://site0.com/", 1.9);
|
||||
// 하나 더 추가 → 초과분 제거
|
||||
_store.Set("https://new.com/", 2.0);
|
||||
|
||||
Assert.Equal(300, _store.Count);
|
||||
Assert.Equal(1.0, _store.Get("https://site1.com/")); // 제거됨 → 기본값
|
||||
Assert.Equal(2.0, _store.Get("https://new.com/")); // 유지
|
||||
Assert.Equal(1.9, _store.Get("https://site0.com/")); // LRU 갱신됨 → 유지
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear는_모든_항목과_파일을_지운다()
|
||||
{
|
||||
_store.Set("https://x.com/", 2.0);
|
||||
_store.Clear();
|
||||
Assert.Equal(0, _store.Count);
|
||||
Assert.Equal(1.0, _store.Get("https://x.com/"));
|
||||
Assert.False(File.Exists(_path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 손상된_JSON은_무시하고_빈_상태로_시작한다()
|
||||
{
|
||||
File.WriteAllText(_path, "{ not json !!");
|
||||
var store = new SiteZoomStore(_path);
|
||||
Assert.Equal(0, store.Count);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
using VideoDownloader.App.Browser.Features;
|
||||
using VideoDownloader.Browser.Tabs;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
// ── T2-2 탭 절전: 유휴 비활성 탭 자동 서스펜드 ──
|
||||
public class TabSuspendServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void 활성_탭은_절전하지_않는다()
|
||||
{
|
||||
var m = new TabManager(() => new TabItem());
|
||||
var active = m.NewTab("https://a.com");
|
||||
var svc = new TabSuspendService(TimeSpan.FromMinutes(10),
|
||||
t => throw new InvalidOperationException("suspend should not be called"),
|
||||
t => t == active);
|
||||
|
||||
svc.SuspendIdleTabs(m.Tabs, active, now: active.LastActivated.AddMinutes(30));
|
||||
Assert.False(active.IsSuspended);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 유휴_비활성_탭은_절전한다()
|
||||
{
|
||||
var m = new TabManager(() => new TabItem());
|
||||
var old = m.NewTab("https://a.com");
|
||||
var active = m.NewTab("https://b.com");
|
||||
var suspended = new List<TabItem>();
|
||||
var svc = new TabSuspendService(TimeSpan.FromMinutes(10),
|
||||
t => { suspended.Add(t); return true; },
|
||||
t => t != active);
|
||||
|
||||
var now = old.LastActivated.AddMinutes(11);
|
||||
svc.SuspendIdleTabs(m.Tabs, active, now);
|
||||
Assert.Contains(old, suspended);
|
||||
Assert.DoesNotContain(active, suspended);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 이미_절전된_탭은_중복_서스펜드_하지_않는다()
|
||||
{
|
||||
var m = new TabManager(() => new TabItem());
|
||||
var old = m.NewTab("https://a.com");
|
||||
var active = m.NewTab("https://b.com");
|
||||
old.IsSuspended = true;
|
||||
var calls = 0;
|
||||
var svc = new TabSuspendService(TimeSpan.FromMinutes(10), t => { calls++; return true; }, t => t != active);
|
||||
|
||||
svc.SuspendIdleTabs(m.Tabs, active, old.LastActivated.AddHours(1));
|
||||
Assert.Equal(0, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 최근에_활성화된_탭은_절전하지_않는다()
|
||||
{
|
||||
var m = new TabManager(() => new TabItem());
|
||||
var recent = m.NewTab("https://a.com");
|
||||
var active = m.NewTab("https://b.com");
|
||||
m.Activate(recent); // 방금 사용
|
||||
var calls = 0;
|
||||
var svc = new TabSuspendService(TimeSpan.FromMinutes(10), t => { calls++; return true; }, t => t != active);
|
||||
|
||||
svc.SuspendIdleTabs(m.Tabs, active, recent.LastActivated.AddMinutes(1));
|
||||
Assert.Equal(0, calls);
|
||||
}
|
||||
}
|
||||
|
||||
// ── T2-8 탭 검색(Ctrl+Shift+A): 열린+닫힌 탭 필터 ──
|
||||
public class TabSearchFilterTests
|
||||
{
|
||||
[Fact]
|
||||
public void 제목과_URL_부분일치로_필터링한다()
|
||||
{
|
||||
var m = new TabManager(() => new TabItem());
|
||||
m.NewTab("https://youtube.com/watch?v=1");
|
||||
m.Tabs[0].Title = "My Favorite Video";
|
||||
m.NewTab("https://news.example.com");
|
||||
m.Tabs[1].Title = "News";
|
||||
|
||||
var hits = TabSearchFilter.Search(m.Tabs, "favorite");
|
||||
Assert.Single(hits);
|
||||
Assert.Equal("My Favorite Video", hits[0].Title);
|
||||
|
||||
hits = TabSearchFilter.Search(m.Tabs, "youtube");
|
||||
Assert.Single(hits);
|
||||
|
||||
hits = TabSearchFilter.Search(m.Tabs, "EXAMPLE"); // 대소문자 무시
|
||||
Assert.Single(hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 닫힌_탭도_검색_대상에_포함된다()
|
||||
{
|
||||
var m = new TabManager(() => new TabItem());
|
||||
var t = m.NewTab("https://github.com/x");
|
||||
m.Tabs[0].Title = "GitHub Repo";
|
||||
m.CloseTab(t);
|
||||
|
||||
var hits = TabSearchFilter.Search(m.Tabs, m.ClosedStack, "github");
|
||||
Assert.Single(hits);
|
||||
Assert.Equal("GitHub Repo", hits[0].Title);
|
||||
Assert.True(hits[0].IsClosed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 빈_질의는_빈_결과()
|
||||
{
|
||||
var m = new TabManager(() => new TabItem());
|
||||
m.NewTab("https://a.com");
|
||||
Assert.Empty(TabSearchFilter.Search(m.Tabs, ""));
|
||||
Assert.Empty(TabSearchFilter.Search(m.Tabs, " "));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
using VideoDownloader.Browser.Tabs;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
// ── T2-11 탭 그룹: 색상 라벨 + 그룹 단위 이동/닫기 ──
|
||||
public class TabGroupsTests
|
||||
{
|
||||
private static TabManager NewManager() => new(() => new TabItem());
|
||||
|
||||
[Fact]
|
||||
public void 그룹_지정과_해제()
|
||||
{
|
||||
var m = NewManager();
|
||||
var t = m.NewTab("https://a.com");
|
||||
m.SetGroup(t, "Work", "#e11");
|
||||
Assert.Equal("Work", t.GroupName);
|
||||
Assert.Equal("#e11", t.GroupColor);
|
||||
m.SetGroup(t, null, null);
|
||||
Assert.Null(t.GroupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 같은_그룹_탭은_서로_인접한다()
|
||||
{
|
||||
var m = NewManager();
|
||||
var a = m.NewTab("a"); m.SetGroup(a, "G", "#111");
|
||||
m.NewTab("b"); // 비그룹
|
||||
var c = m.NewTab("c"); m.SetGroup(c, "G", "#111"); // 그룹으로 → a 옆으로 이동
|
||||
Assert.Equal(1, m.Tabs.IndexOf(c));
|
||||
Assert.Equal(0, m.Tabs.IndexOf(a));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 그룹_탭_전체_닫기()
|
||||
{
|
||||
var m = NewManager();
|
||||
var a = m.NewTab("a"); m.SetGroup(a, "G", "#111");
|
||||
var b = m.NewTab("b"); m.SetGroup(b, "G", "#111");
|
||||
m.NewTab("solo");
|
||||
var closed = m.CloseGroup("G");
|
||||
Assert.Equal(2, closed);
|
||||
Assert.Single(m.Tabs);
|
||||
}
|
||||
}
|
||||
|
||||
// ── T3-1 워크스페이스: 탭 세트 저장/전환 ──
|
||||
public class WorkspaceTests
|
||||
{
|
||||
private static TabManager NewManager() => new(() => new TabItem());
|
||||
|
||||
[Fact]
|
||||
public void 워크스페이스_저장후_스왑하면_탭_세트가_교체된다()
|
||||
{
|
||||
var m = NewManager();
|
||||
m.NewTab("https://work1.com");
|
||||
m.NewTab("https://work2.com");
|
||||
var ws = m.CaptureWorkspace("Work");
|
||||
|
||||
m.NewTab("https://personal.com");
|
||||
Assert.Equal(3, m.Tabs.Count);
|
||||
|
||||
m.SwitchWorkspace(ws);
|
||||
Assert.Equal(2, m.Tabs.Count);
|
||||
Assert.Equal("https://work1.com", m.Tabs[0].Url);
|
||||
Assert.Equal("https://work2.com", m.Tabs[1].Url);
|
||||
Assert.Equal("Work", ws.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 워크스페이스_캡처는_현재_탭을_그대로_유지한다()
|
||||
{
|
||||
var m = NewManager();
|
||||
m.NewTab("https://a.com");
|
||||
var ws = m.CaptureWorkspace("X");
|
||||
Assert.Equal(1, m.Tabs.Count); // 캡처 자체는 현재 상태 유지(스냅샷)
|
||||
Assert.Single(ws.Urls);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
using VideoDownloader.Browser.Tabs;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
// Phase A 탭 아키텍처: TabManager 순수 로직(뷰 팩토리 주입 — WebView2 없이 검증).
|
||||
public class TabManagerTests
|
||||
{
|
||||
private static TabManager NewManager() => new(() => new TabItem());
|
||||
|
||||
[Fact]
|
||||
public void NewTab은_탭을_추가하고_활성화한다()
|
||||
{
|
||||
var m = NewManager();
|
||||
var t = m.NewTab("https://a.com");
|
||||
Assert.Single(m.Tabs);
|
||||
Assert.Same(t, m.ActiveTab);
|
||||
Assert.Equal("https://a.com", t.Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 두_번째_탭은_이전_탭을_비활성화하고_활성화된다()
|
||||
{
|
||||
var m = NewManager();
|
||||
var t1 = m.NewTab("https://a.com");
|
||||
var t2 = m.NewTab("https://b.com");
|
||||
Assert.Same(t2, m.ActiveTab);
|
||||
Assert.False(t1.IsActive);
|
||||
Assert.True(t2.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate는_활성_탭을_바꾸고_이벤트를_발생시킨다()
|
||||
{
|
||||
var m = NewManager();
|
||||
var t1 = m.NewTab();
|
||||
var t2 = m.NewTab();
|
||||
var fired = 0;
|
||||
m.ActiveChanged += _ => fired++;
|
||||
m.Activate(t1);
|
||||
Assert.Same(t1, m.ActiveTab);
|
||||
Assert.True(t1.IsActive);
|
||||
Assert.False(t2.IsActive);
|
||||
Assert.Equal(1, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseTab은_제거하고_이웃_탭을_활성화한다()
|
||||
{
|
||||
var m = NewManager();
|
||||
m.NewTab("https://a.com"); // index 0
|
||||
var t2 = m.NewTab("https://b.com"); // index 1 (active)
|
||||
var t3 = m.NewTab("https://c.com"); // index 2
|
||||
m.Activate(t2);
|
||||
|
||||
m.CloseTab(t2);
|
||||
Assert.Equal(2, m.Tabs.Count);
|
||||
Assert.DoesNotContain(t2, m.Tabs);
|
||||
// 닫힌 탭 오른쪽이 있으면 그 탭(t3) 활성화 (Chrome 동작)
|
||||
Assert.Same(t3, m.ActiveTab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 마지막_탭을_닫으면_새_빈_탭으로_대체된다()
|
||||
{
|
||||
var m = NewManager();
|
||||
var only = m.NewTab("https://solo.com");
|
||||
m.CloseTab(only);
|
||||
Assert.Single(m.Tabs);
|
||||
Assert.NotSame(only, m.ActiveTab);
|
||||
Assert.Same(m.ActiveTab, m.Tabs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 닫힌_탭은_스택에_쌓이고_ReopenClosedTab으로_원래_위치에_복원된다()
|
||||
{
|
||||
var m = NewManager();
|
||||
var t1 = m.NewTab("https://a.com");
|
||||
m.NewTab("https://b.com");
|
||||
m.NewTab("https://c.com"); // index 2, active
|
||||
m.CloseTab(m.Tabs[2]); // 마지막 인덱스 탭 닫기
|
||||
|
||||
var reopened = m.ReopenClosedTab();
|
||||
Assert.NotNull(reopened);
|
||||
Assert.Equal("https://c.com", reopened!.Url);
|
||||
Assert.Equal(3, m.Tabs.Count);
|
||||
Assert.Equal(2, m.Tabs.IndexOf(reopened)); // 원래 인덱스 복원
|
||||
Assert.Same(reopened, m.ActiveTab);
|
||||
Assert.Empty(m.ClosedStack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 닫힌_탭_스택은_최대_25개다()
|
||||
{
|
||||
var m = NewManager();
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var t = m.NewTab($"https://x{i}.com");
|
||||
m.CloseTab(t);
|
||||
}
|
||||
Assert.Equal(25, m.ClosedStack.Count);
|
||||
Assert.Equal("https://x29.com", m.ClosedStack.First().Url); // LIFO 최신 우선
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivateNext_Previous는_순환한다()
|
||||
{
|
||||
var m = NewManager();
|
||||
var t1 = m.NewTab("https://a.com");
|
||||
var t2 = m.NewTab("https://b.com");
|
||||
m.Activate(t1);
|
||||
|
||||
m.ActivateNext();
|
||||
Assert.Same(t2, m.ActiveTab);
|
||||
m.ActivateNext(); // 순환
|
||||
Assert.Same(t1, m.ActiveTab);
|
||||
m.ActivatePrevious();
|
||||
Assert.Same(t2, m.ActiveTab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivateAt와_ActivateLast가_동작한다()
|
||||
{
|
||||
var m = NewManager();
|
||||
m.NewTab("a"); m.NewTab("b"); var last = m.NewTab("c");
|
||||
m.ActivateAt(0);
|
||||
Assert.Same(m.Tabs[0], m.ActiveTab);
|
||||
m.ActivateLast();
|
||||
Assert.Same(last, m.ActiveTab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Move는_탭_순서를_바꾼다()
|
||||
{
|
||||
var m = NewManager();
|
||||
var t1 = m.NewTab("a"); var t2 = m.NewTab("b"); var t3 = m.NewTab("c");
|
||||
m.Move(t1, 2); // a 를 끝으로
|
||||
Assert.Equal(2, m.Tabs.IndexOf(t1));
|
||||
Assert.Equal(0, m.Tabs.IndexOf(t2));
|
||||
Assert.Equal(1, m.Tabs.IndexOf(t3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pin은_탭을_좌측_끝으로_이동시키고_핀_표시한다()
|
||||
{
|
||||
var m = NewManager();
|
||||
m.NewTab("a"); m.NewTab("b");
|
||||
var t3 = m.NewTab("c");
|
||||
m.SetPinned(t3, true);
|
||||
Assert.True(t3.IsPinned);
|
||||
Assert.Equal(0, m.Tabs.IndexOf(t3)); // 핀 탭은 좌측 정렬
|
||||
m.SetPinned(t3, false);
|
||||
Assert.False(t3.IsPinned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptureSession은_URL과_활성_인덱스와_핀을_담는다()
|
||||
{
|
||||
var m = NewManager();
|
||||
m.NewTab("https://a.com");
|
||||
var t2 = m.NewTab("https://b.com");
|
||||
m.NewTab("https://c.com");
|
||||
m.SetPinned(t2, true); // b 가 좌측으로 이동 → 순서: b,a,c
|
||||
m.Activate(t2);
|
||||
|
||||
var s = m.CaptureSession();
|
||||
Assert.Equal(new[] { "https://b.com", "https://a.com", "https://c.com" }, s.Urls);
|
||||
Assert.Equal(0, s.ActiveIndex);
|
||||
Assert.Equal(new[] { true, false, false }, s.Pinned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreSession은_저장된_탭들을_재생성한다()
|
||||
{
|
||||
var m = NewManager();
|
||||
// CaptureSession 불변식: 핀 탭이 먼저 오는 순서로 저장됨
|
||||
var snap = new SessionSnapshot(
|
||||
new() { "https://b.com", "https://a.com" }, 0, new() { true, false });
|
||||
m.RestoreSession(snap);
|
||||
Assert.Equal(2, m.Tabs.Count);
|
||||
Assert.Equal("https://b.com", m.ActiveTab!.Url);
|
||||
Assert.True(m.Tabs[0].IsPinned); // 핀 탭이 좌측
|
||||
Assert.False(m.Tabs[1].IsPinned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreSession은_기존_탭을_치환한다()
|
||||
{
|
||||
var m = NewManager();
|
||||
m.NewTab("https://old.com");
|
||||
m.RestoreSession(new SessionSnapshot(new() { "https://new.com" }, 0, new() { false }));
|
||||
Assert.Single(m.Tabs);
|
||||
Assert.Equal("https://new.com", m.Tabs[0].Url);
|
||||
}
|
||||
}
|
||||
|
||||
// 세션 JSON 저장소(T1-10) 분리 테스트
|
||||
public class TabSessionStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _path = Path.Combine(Path.GetTempPath(), $"session_{Guid.NewGuid():N}.json");
|
||||
|
||||
[Fact]
|
||||
public void 저장_후_로드_라운드트립()
|
||||
{
|
||||
var snap = new SessionSnapshot(
|
||||
new() { "https://a.com", "https://b.com", "https://c.com" }, 2, new() { true, false, false });
|
||||
TabSessionStore.Save(_path, snap);
|
||||
var loaded = TabSessionStore.Load(_path);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(snap.Urls, loaded!.Urls);
|
||||
Assert.Equal(2, loaded.ActiveIndex);
|
||||
Assert.Equal(snap.Pinned, loaded.Pinned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 파일_없으면_null()
|
||||
{
|
||||
Assert.Null(TabSessionStore.Load(_path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 손상된_JSON이면_null()
|
||||
{
|
||||
File.WriteAllText(_path, "{ broken");
|
||||
Assert.Null(TabSessionStore.Load(_path));
|
||||
}
|
||||
|
||||
public void Dispose() { try { File.Delete(_path); } catch { } }
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
using VideoDownloader.Browser;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
/// <summary>T3-14: 추적 방지 — 블랙리스트 도메인 매칭(서브도메인 포함) + 차단 카운터.</summary>
|
||||
public class TrackerBlockTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("https://google-analytics.com/analytics.js", true)] // 정확히 도메인
|
||||
[InlineData("https://www.google-analytics.com/g/collect", true)] // 서브도메인
|
||||
[InlineData("https://x.com/notgoogle-analytics.com/a", false)] // 접미사 함정 제외
|
||||
[InlineData("https://doubleclick.net/instream/ad_status.js", true)]
|
||||
[InlineData("https://safe.example.com/lib.js", false)] // 무관 도메인
|
||||
public void Matches_블랙리스트_도메인_판정(string url, bool expected)
|
||||
=> Assert.Equal(expected, new TrackerBlocklist().Matches(new Uri(url)));
|
||||
|
||||
[Fact]
|
||||
public void 커스텀_도메인_주입_가능()
|
||||
{
|
||||
var bl = new TrackerBlocklist(new[] { "blocked.test" });
|
||||
Assert.True(bl.Matches(new Uri("https://t.blocked.test/x.js")));
|
||||
Assert.False(bl.Matches(new Uri("https://ok.com/x.js")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Counter_차단_누적_도메인별_집계_변경_통지()
|
||||
{
|
||||
var c = new TrackerCounter();
|
||||
var changed = 0;
|
||||
c.Changed += () => changed++;
|
||||
Assert.Equal(0, c.Total);
|
||||
c.Record("https://www.google-analytics.com/a.js");
|
||||
c.Record("https://google-analytics.com/b.js");
|
||||
c.Record("https://scorecardresearch.com/c.js");
|
||||
Assert.Equal(3, c.Total);
|
||||
Assert.Equal(2, c.ByDomain["google-analytics.com"]);
|
||||
Assert.Equal(1, c.ByDomain["scorecardresearch.com"]);
|
||||
Assert.Equal(3, changed);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
using VideoDownloader.Browser;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
/// <summary>T3-15: 파라미터 제거 복사 + 주소창 진행 상태.</summary>
|
||||
public class UrlCleanerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("https://x.com/a?utm_source=1&b=2", "https://x.com/a")] // 쿼리 제거
|
||||
[InlineData("https://x.com/a#frag", "https://x.com/a")] // 프래그먼트 제거
|
||||
[InlineData("https://x.com/a?b=1#f", "https://x.com/a")] // 둘 다
|
||||
[InlineData("https://x.com/a/", "https://x.com/a/")] // 없으면 무변경
|
||||
[InlineData("https://x.com/a?q=한글", "https://x.com/a")] // 유니코드 쿼리
|
||||
[InlineData("x.com/a?b=1", "x.com/a")] // 스킴 없어도 동작
|
||||
public void StripQuery_파라미터와_프래그먼트를_제거(string input, string expected)
|
||||
=> Assert.Equal(expected, UrlCleaner.StripQuery(input));
|
||||
|
||||
[Fact]
|
||||
public void StripQuery_빈값_null_안전()
|
||||
{
|
||||
Assert.Equal("", UrlCleaner.StripQuery(""));
|
||||
Assert.Null(UrlCleaner.StripQuery(null));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T3-15: 탭 전체 네비게이션 진행 집계 — 주소창 진행 바 표시 상태.</summary>
|
||||
public class NavProgressTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void 초기값은_비진행()
|
||||
=> Assert.False(new NavProgressTracker().IsNavigating);
|
||||
|
||||
[Fact]
|
||||
public void 시작하면_진행()
|
||||
{
|
||||
var t = new NavProgressTracker();
|
||||
t.Started();
|
||||
Assert.True(t.IsNavigating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 완료되면_비진행()
|
||||
{
|
||||
var t = new NavProgressTracker();
|
||||
t.Started();
|
||||
t.Completed();
|
||||
Assert.False(t.IsNavigating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 동시_내비게이션은_마지막_완료까지_진행() // 프레임 리다이렉트·중복 시작
|
||||
{
|
||||
var t = new NavProgressTracker();
|
||||
t.Started(); t.Started();
|
||||
t.Completed();
|
||||
Assert.True(t.IsNavigating);
|
||||
t.Completed();
|
||||
Assert.False(t.IsNavigating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 과잉_완료로_음수_되지_않음()
|
||||
{
|
||||
var t = new NavProgressTracker();
|
||||
t.Completed();
|
||||
Assert.False(t.IsNavigating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNavigating_변경_시_INPC_발화()
|
||||
{
|
||||
var t = new NavProgressTracker();
|
||||
var fired = new List<string?>();
|
||||
t.PropertyChanged += (_, e) => fired.Add(e.PropertyName);
|
||||
t.Started();
|
||||
t.Completed();
|
||||
t.Completed(); // false→false → 이벤트 없음
|
||||
Assert.Equal(new[] { nameof(NavProgressTracker.IsNavigating), nameof(NavProgressTracker.IsNavigating) }, fired);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using VideoDownloader.App;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// 아이콘 tofu 실결함(사용자 보고 "아이콘은 절대 안고쳐지네") 회귀 테스트 —
|
||||
/// 근본 원인: Shared.xaml 의 암시적 TextBlock 스타일이 FontFamily 를 UiFont 로 강제해,
|
||||
/// 버튼 ContentPresenter 가 만드는 자동 TextBlock 이 IconFont 상속을 잃고 PUA 글리프가 notdef 가 됐다.
|
||||
/// 아이콘 버튼의 실제 콘텐츠 TextBlock 이 아이콘 폰트로 해석되는지 실창에서 검증한다.
|
||||
/// </summary>
|
||||
[Collection("E2E")]
|
||||
public class IconContentFontTests
|
||||
{
|
||||
private static TextBlock? FindTextBlock(DependencyObject root)
|
||||
{
|
||||
if (root is TextBlock tb) return tb;
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
|
||||
{
|
||||
if (FindTextBlock(VisualTreeHelper.GetChild(root, i)) is { } found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 아이콘_버튼의_콘텐츠_TextBlock_은_아이콘폰트로_렌더된다()
|
||||
{
|
||||
var dataDir = Path.Combine(Path.GetTempPath(), $"vd_iconfont_{Guid.NewGuid():N}");
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir);
|
||||
try
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
var (navFamily, captionFamily, bodyFamily) = await sta.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
// 앱과 동일 조건: 절대 pack URI 테마 재로드 후 실창
|
||||
VideoDownloader.App.App.ApplyTheme("dark");
|
||||
var win = new MainWindow { Left = 20, Top = 700, WindowStartupLocation = WindowStartupLocation.Manual };
|
||||
win.Show();
|
||||
win.UpdateLayout();
|
||||
|
||||
var nav = (Button)win.FindName("NavBack")!;
|
||||
var navText = FindTextBlock(nav)!;
|
||||
|
||||
// 캡션(최대화/복원) 버튼 — CaptionButton 스타일 경유
|
||||
var caption = win.FindName("MaxRestoreBtn") as Button;
|
||||
var captionText = caption != null ? FindTextBlock(caption) : null;
|
||||
|
||||
// 일반 본문 TextBlock 은 여전히 UI 폰트를 따라야 한다(암시 스타일의 원래 목적)
|
||||
var status = (TextBlock)win.FindName("StatusText")!;
|
||||
|
||||
var result = (navText.FontFamily.Source, captionText?.FontFamily.Source ?? "(캡션 버튼 없음)", status.FontFamily.Source);
|
||||
win.Close();
|
||||
return result;
|
||||
});
|
||||
|
||||
Assert.Equal("Segoe Fluent Icons", navFamily);
|
||||
if (captionFamily != "(캡션 버튼 없음)")
|
||||
Assert.Equal("Segoe Fluent Icons", captionFamily);
|
||||
Assert.Contains("Pretendard", bodyFamily); // 본문은 UI 폰트 유지
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
|
||||
try { Directory.Delete(dataDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// 아이콘 tofu 실결함(사용자 보고: "아이콘은 절대 안고쳐지네") 재현 시도 —
|
||||
/// 테마 사전을 앱과 같은 방식으로 로드했을 때 FluentIcon/IconButton 경유 글리프가
|
||||
/// 명시적 Segoe Fluent Icons 렌더와 픽셀 단위로 같은지 검증한다.
|
||||
/// 같지 않고 본문 폰트 렌더(tofu)와 같다면 리소스 해석이 깨진 것이다.
|
||||
/// </summary>
|
||||
[Collection("E2E")]
|
||||
public class IconGlyphRenderTests
|
||||
{
|
||||
private static byte[] Render(FrameworkElement el)
|
||||
{
|
||||
el.Measure(new Size(48, 48));
|
||||
el.Arrange(new Rect(0, 0, 48, 48));
|
||||
el.UpdateLayout();
|
||||
var rtb = new RenderTargetBitmap(48, 48, 96, 96, PixelFormats.Pbgra32);
|
||||
rtb.Render(el);
|
||||
var px = new byte[48 * 48 * 4];
|
||||
rtb.CopyPixels(px, 48 * 4, 0);
|
||||
return px;
|
||||
}
|
||||
|
||||
private static int Lit(byte[] px)
|
||||
{
|
||||
var lit = 0;
|
||||
for (var i = 3; i < px.Length; i += 4) if (px[i] > 60) lit++;
|
||||
return lit;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FluentIcon_스타일_글리프는_명시적_아이콘폰트_렌더와_동일하다()
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
var (family, litStyled, litExplicit, litBody, sameAsExplicit, sameAsBody) = await sta.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
TextBlock Make() => new()
|
||||
{
|
||||
Text = "", FontSize = 14, Foreground = Brushes.White,
|
||||
Background = Brushes.Black, Width = 48, Height = 48,
|
||||
};
|
||||
|
||||
var styled = Make();
|
||||
styled.Style = (Style)Application.Current.FindResource("FluentIcon");
|
||||
|
||||
var explicitIcon = Make();
|
||||
explicitIcon.FontFamily = new FontFamily("Segoe Fluent Icons");
|
||||
|
||||
var body = Make();
|
||||
body.FontFamily = (FontFamily)Application.Current.FindResource("UiFont");
|
||||
|
||||
var pStyled = Render(styled);
|
||||
var pExplicit = Render(explicitIcon);
|
||||
var pBody = Render(body);
|
||||
|
||||
return (styled.FontFamily.Source, Lit(pStyled), Lit(pExplicit), Lit(pBody),
|
||||
pStyled.SequenceEqual(pExplicit), pStyled.SequenceEqual(pBody));
|
||||
});
|
||||
|
||||
Assert.True(litExplicit > 0, "기준(명시적 Segoe Fluent Icons) 렌더 자체가 비어 있음 — 폰트 미설치?");
|
||||
Assert.True(sameAsExplicit && !sameAsBody,
|
||||
$"FluentIcon 경유 글리프가 아이콘 폰트로 렌더되지 않음 — 해석된 폰트: '{family}', " +
|
||||
$"픽셀(스타일/명시/본문): {litStyled}/{litExplicit}/{litBody}, 본문과 동일: {sameAsBody}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 앱_시작_시퀀스를_그대로_태워도_FluentIcon_글리프가_렌더된다()
|
||||
{
|
||||
// 실제 App.OnStartup 과 동일한 순서: ApplyTheme(절대 pack URI 재로드) → 강조색 → 폰트 → 모션
|
||||
var dataDir = Path.Combine(Path.GetTempPath(), $"vd_icon_{Guid.NewGuid():N}");
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir);
|
||||
try
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
var (family, same, litStyled, litExplicit) = await sta.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
VideoDownloader.App.App.ApplyTheme("dark");
|
||||
VideoDownloader.App.App.ApplyAccent(null);
|
||||
VideoDownloader.App.App.ApplyFontSettings();
|
||||
VideoDownloader.App.App.ApplyReduceMotion(false);
|
||||
|
||||
var styled = new TextBlock
|
||||
{
|
||||
Text = "\uE76B", FontSize = 14, Foreground = Brushes.White,
|
||||
Background = Brushes.Black, Width = 48, Height = 48,
|
||||
Style = (Style)Application.Current.FindResource("FluentIcon"),
|
||||
};
|
||||
var explicitIcon = new TextBlock
|
||||
{
|
||||
Text = "\uE76B", FontSize = 14, Foreground = Brushes.White,
|
||||
Background = Brushes.Black, Width = 48, Height = 48,
|
||||
FontFamily = new FontFamily("Segoe Fluent Icons"),
|
||||
};
|
||||
var pStyled = Render(styled);
|
||||
var pExplicit = Render(explicitIcon);
|
||||
return (styled.FontFamily.Source, pStyled.SequenceEqual(pExplicit), Lit(pStyled), Lit(pExplicit));
|
||||
});
|
||||
|
||||
Assert.True(same,
|
||||
$"앱 시작 시퀀스 후 FluentIcon 글리프 깨짐 — 해석된 폰트: '{family}', 픽셀(스타일/명시): {litStyled}/{litExplicit}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
|
||||
try { Directory.Delete(dataDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using VideoDownloader.Browser;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// E2E (T3-11/T3-14): 실제 WebView2 에서 권한 정책 응답과 추적 요청 차단·집계를 검증.
|
||||
/// TabViewService 와 동일한 배선(PermissionGate/TrackerBlocklist/TrackerCounter)을 사용한다.
|
||||
/// </summary>
|
||||
[Collection("E2E")]
|
||||
public class PermissionTrackerE2ETests
|
||||
{
|
||||
[Fact]
|
||||
public async Task 권한_요청이_정책을_거쳐_허용된다_T3_11()
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
|
||||
var result = await sta.InvokeAsync(async () =>
|
||||
{
|
||||
var window = new Window();
|
||||
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
|
||||
window.Content = browser;
|
||||
window.Show();
|
||||
|
||||
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
|
||||
await browser.EnsureCoreWebView2Async(env);
|
||||
var cv = browser.CoreWebView2;
|
||||
|
||||
// TabViewService 배선과 동일: 기억된 판정은 즉시 응답(출처는 이벤트에서 관측된 것으로 기억)
|
||||
var gate = new PermissionGate();
|
||||
cv.PermissionRequested += (_, e) =>
|
||||
{
|
||||
var o = VideoDownloader.App.Services.TabViewService.OriginOf(e.Uri);
|
||||
gate.Remember("Notifications", o, PermissionVerdict.Allow); // E2E: 알림은 허용 정책
|
||||
var verdict = gate.Decide(e.PermissionKind.ToString(), o);
|
||||
e.State = verdict == PermissionVerdict.Allow ? CoreWebView2PermissionState.Allow : CoreWebView2PermissionState.Deny;
|
||||
e.Handled = true;
|
||||
};
|
||||
|
||||
var html = "<html><body><script>Notification.requestPermission().then(p => { document.title = 'PERM=' + p; });</script></body></html>";
|
||||
cv.NavigateToString(html);
|
||||
await WaitUntilNavCompleted(cv);
|
||||
var title = await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""),
|
||||
t => t.StartsWith("PERM=", StringComparison.Ordinal), 10000);
|
||||
|
||||
window.Close();
|
||||
return title;
|
||||
});
|
||||
|
||||
try { Directory.Delete(udf, true); } catch { }
|
||||
Assert.Equal("PERM=granted", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 블랙리스트_스크립트가_차단되고_카운터에_집계된다_T3_14()
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
|
||||
var result = await sta.InvokeAsync(async () =>
|
||||
{
|
||||
var window = new Window();
|
||||
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
|
||||
window.Content = browser;
|
||||
window.Show();
|
||||
|
||||
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
|
||||
await browser.EnsureCoreWebView2Async(env);
|
||||
var cv = browser.CoreWebView2;
|
||||
|
||||
var bl = new TrackerBlocklist(new[] { "blocked.test" });
|
||||
var counter = new TrackerCounter();
|
||||
cv.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
|
||||
cv.WebResourceRequested += (_, e) =>
|
||||
{
|
||||
if (!bl.Matches(new Uri(e.Request.Uri))) return;
|
||||
e.Response = env.CreateWebResourceResponse(null, 204, "Blocked", "");
|
||||
counter.Record(e.Request.Uri);
|
||||
};
|
||||
|
||||
var html = "<html><body><p>tracker-e2e</p>" +
|
||||
"<script src=\"https://www.blocked.test/a.js\"></script>" +
|
||||
"<script src=\"https://www.blocked.test/b.js\"></script>" +
|
||||
"<script src=\"https://ok.example.com/c.js\"></script>" +
|
||||
"</body></html>";
|
||||
cv.NavigateToString(html);
|
||||
await WaitUntilNavCompleted(cv);
|
||||
await Wait.Until(() => Task.FromResult(counter.Total), t => t >= 2, 10000);
|
||||
|
||||
window.Close();
|
||||
return counter.Total;
|
||||
});
|
||||
|
||||
try { Directory.Delete(udf, true); } catch { }
|
||||
Assert.Equal(2, result); // 정상 도메인(ok.example.com)은 집계되지 않음
|
||||
}
|
||||
|
||||
private static async Task WaitUntilNavCompleted(CoreWebView2 cv)
|
||||
{
|
||||
var tcs = new TaskCompletionSource();
|
||||
EventHandler<CoreWebView2NavigationCompletedEventArgs> h = (_, _) => tcs.TrySetResult();
|
||||
cv.NavigationCompleted += h;
|
||||
if (tcs.Task.IsCompleted) return;
|
||||
await Task.WhenAny(tcs.Task, Task.Delay(15000));
|
||||
cv.NavigationCompleted -= h;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
using System.Windows;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using VideoDownloader.App.Browser.Features;
|
||||
using VideoDownloader.Browser.Tabs;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// E2E (T2-4): 실제 WebView2 + 번들된 Mozilla Readability.js 로
|
||||
/// 기사 페이지에서 본문을 추출해 리더 뷰로 교체하고 원본 URL 복귀까지 검증.
|
||||
/// </summary>
|
||||
[Collection("E2E")]
|
||||
public class ReaderModeE2ETests
|
||||
{
|
||||
private const string ArticleHtml = """
|
||||
<html><head><title>테스트 기사</title></head><body>
|
||||
<nav>광고 광고 광고 내비게이션</nav>
|
||||
<article>
|
||||
<h1>리더 모드 E2E 기사</h1>
|
||||
<p>첫 번째 문단입니다. 이 문단은 Readability 가 본문으로 추출해야 합니다. 충분한 길이의 텍스트가 필요하므로 몇 번 더 반복합니다. 본문 본문 본문.</p>
|
||||
<p>두 번째 문단입니다. 리더 모드는 광고와 내비게이션을 제거하고 본문만 남깁니다. 추출 결과에 이 텍스트가 포함되어야 합니다.</p>
|
||||
<p>세 번째 문단입니다. 문단이 세 개 이상이면 Readability 의 휴리스틱이 본문으로 판정합니다. 마커텍스트-고유문구-레인보우4747.</p>
|
||||
</article>
|
||||
<footer>푸터 링크 모음</footer>
|
||||
</body></html>
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task 기사를_리더로_추출하고_원본으로_복귀한다()
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
var udf = Path.Combine(Path.GetTempPath(), $"vd_reader_{Guid.NewGuid():N}");
|
||||
var ok = await sta.InvokeAsync(async () =>
|
||||
{
|
||||
var window = new Window();
|
||||
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
|
||||
window.Content = browser;
|
||||
window.Show();
|
||||
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
|
||||
await browser.EnsureCoreWebView2Async(env);
|
||||
var cv = browser.CoreWebView2;
|
||||
|
||||
cv.NavigateToString(ArticleHtml);
|
||||
await WaitNav(cv);
|
||||
|
||||
var tab = new TabItem { View = browser };
|
||||
|
||||
// 1) 리더 진입
|
||||
var entered = await ReaderMode.ToggleAsync(tab, lightTheme: false);
|
||||
await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""), t => t.Contains("테스트 기사"), 15000);
|
||||
var titleAfter = cv.DocumentTitle ?? "";
|
||||
var bodyText = await cv.ExecuteScriptAsync("document.body.innerText");
|
||||
var hasMarker = bodyText.Contains("마커텍스트-고유문구-레인보우4747");
|
||||
var hasNav = bodyText.Contains("광고 광고 광고");
|
||||
var isReader = tab.IsReader;
|
||||
var originalSaved = tab.ReaderOriginalUrl;
|
||||
|
||||
// 2) 복귀
|
||||
var exited = await ReaderMode.ToggleAsync(tab, lightTheme: false);
|
||||
var restoredReader = tab.IsReader == false;
|
||||
|
||||
window.Close();
|
||||
return (entered: entered == true, titleAfter, hasMarker, navRemoved: !hasNav, isReader, originalSaved, exited: exited == false, restoredReader);
|
||||
});
|
||||
|
||||
try { Directory.Delete(udf, true); } catch { }
|
||||
Assert.True(ok.entered, "리더 진입 실패");
|
||||
Assert.Contains("테스트 기사", ok.titleAfter);
|
||||
Assert.True(ok.hasMarker, "본문 마커 누락");
|
||||
Assert.True(ok.navRemoved, "내비/광고 제거 안됨");
|
||||
Assert.True(ok.isReader);
|
||||
Assert.True(ok.exited);
|
||||
Assert.True(ok.restoredReader);
|
||||
}
|
||||
|
||||
private static Task WaitNav(CoreWebView2 cv)
|
||||
{
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
EventHandler<CoreWebView2NavigationCompletedEventArgs>? h = null;
|
||||
h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
|
||||
cv.NavigationCompleted += h;
|
||||
return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
using System.Windows;
|
||||
using VideoDownloader.App;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// E2E: 설정창 "앱 업데이트" 섹션(DEPLOYMENT_PLAN §6 자동 업데이트 v1) —
|
||||
/// 실제 SettingsWindow 를 띄워 설정값 로드 배선과 잘못된 저장소 형식의 즉시 안내(네트워크 불필요 경로)를 검증.
|
||||
/// </summary>
|
||||
[Collection("E2E")]
|
||||
public class SettingsUpdateUiE2ETests
|
||||
{
|
||||
[Fact]
|
||||
public void 설정창_업데이트_저장소_로드와_형식오류_안내()
|
||||
{
|
||||
// VD_DATA_DIR 격리: SettingsWindow 가 참조하는 프로필 경로를 실사용자와 분리
|
||||
var dataDir = Path.Combine(Path.GetTempPath(), $"vd_set_{Guid.NewGuid():N}");
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir);
|
||||
try
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
sta.Invoke(() =>
|
||||
{
|
||||
var config = new AppConfig { UpdateRepo = "user/videodownloader" };
|
||||
var win = new SettingsWindow(config);
|
||||
|
||||
// 설정 → UI 로드 배선
|
||||
Assert.Equal("user/videodownloader", win.UpdateRepoBox.Text);
|
||||
|
||||
// owner/repo 형식이 아니면 네트워크 호출 없이 즉시 안내
|
||||
win.UpdateRepoBox.Text = "invalid-format";
|
||||
win.CheckUpdate_Click(null, new RoutedEventArgs());
|
||||
Assert.Contains("owner/repo", win.UpdateResultText.Text);
|
||||
|
||||
win.Close();
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
|
||||
try { Directory.Delete(dataDir, recursive: true); } catch (DirectoryNotFoundException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// UI 복잡 버전 RED 스펙 — §G 접근성 유즈케이스(docs/UI_USECASES.md §G, UC-A01~A20).
|
||||
/// RED 단계: 전부 실패하는 것이 정상이다.
|
||||
/// </summary>
|
||||
public class UiUsecaseAccessibilityRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void UC_A01_자동화_속성이_하나라도_사용된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("AutomationProperties"),
|
||||
"UC-A01", "화면 낭독기 지원을 위한 AutomationProperties 사용이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A02_주요_컨트롤에_자동화_이름이_부여된다()
|
||||
{
|
||||
var count = System.Text.RegularExpressions.Regex.Matches(SourceTree.MainWindowXaml, "AutomationProperties\\.Name").Count;
|
||||
SourceTree.Usecase(
|
||||
count >= 15,
|
||||
$"UC-A02", $"주요 컨트롤 15개 이상에 AutomationProperties.Name 이 필요하다 (현재 {count}개)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UC_A03_상태_변화가_낭독기에_자동_전달된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("LiveSetting"),
|
||||
"UC-A03", "상태바·진행률의 LiveSetting 선언이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A04_시스템_고대비_모드를_감지한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("HighContrast"),
|
||||
"UC-A04", "SystemParameters.HighContrast 감지 처리가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A05_키보드_포커스_비주얼이_정의된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SharedXaml.Contains("FocusVisualStyle"),
|
||||
"UC-A05", "공용 FocusVisualStyle 정의가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A06_Tab_순서가_지정된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("TabIndex"),
|
||||
"UC-A06", "논리적 Tab 이동을 위한 TabIndex 지정이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A07_ToolTipService_설정이_사용된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("ToolTipService"),
|
||||
"UC-A07", "툴팁 표시 시간 등 ToolTipService 설정이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A08_접근_키_AccessText가_사용된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("AccessText"),
|
||||
"UC-A08", "Alt 접근 키를 위한 AccessText 사용이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A09_사용자_지정_자동화_피어가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("AutomationPeer"),
|
||||
"UC-A09", "목록·탭 컨트롤용 AutomationPeer 구현이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A10_HelpText가_제공된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("AutomationProperties.HelpText"),
|
||||
"UC-A10", "컨트롤 추가 설명 AutomationProperties.HelpText 가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A11_완료_상태가_색과_무관하게_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("SuccessIcon"),
|
||||
"UC-A11", "완료 상태 아이콘(SuccessIcon)으로 색 무의존 표시가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A12_접근성_설정_섹션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("접근성"),
|
||||
"UC-A12", "설정창에 '접근성' 섹션이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A13_UI_확대_배율을_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "UiFontScale"),
|
||||
"UC-A13", "저시력 지원용 AppConfig.UiFontScale 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A14_팝업_키보드_탐색이_설정된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("KeyboardNavigation.TabNavigation"),
|
||||
"UC-A14", "팝업 내 키보드 탐색(KeyboardNavigation) 설정이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A15_자막_표시_옵션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "ShowCaptions"),
|
||||
"UC-A15", "AppConfig.ShowCaptions 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A16_애니메이션_감소_설정이_스타일에_반영된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SharedXaml.Contains("ReduceMotion"),
|
||||
"UC-A16", "Shared.xaml 이 ReduceMotion 설정을 스타일에 반영해야 한다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A17_리더_모드_HTML에_언어가_선언된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.ReaderModeCs.Contains("lang="),
|
||||
"UC-A17", "리더 모드 HTML 에 lang=\"ko\" 선언이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A18_접근성_선언_문서가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.DocFileExists("ACCESSIBILITY.md"),
|
||||
"UC-A18", "접근성 지원 범위 문서(docs/ACCESSIBILITY.md)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_A19_라이트_진행_트랙이_배경과_구별된다()
|
||||
{
|
||||
var fg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "TrackBrush");
|
||||
var bg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "PanelBg");
|
||||
Assert.False(fg is null || bg is null, "UC-A19 — 테마 브러시 파싱 실패");
|
||||
var ratio = SourceTree.ContrastRatio(fg!, bg!);
|
||||
Assert.True(ratio >= 3.0, $"UC-A19 RED — 라이트 TrackBrush/PanelBg 대비 {ratio:F2}:1 < 3:1 (비텍스트 구성요소 기준)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UC_A20_버튼_테두리가_카드_배경과_구별된다()
|
||||
{
|
||||
var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "BorderBrush");
|
||||
var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBg");
|
||||
Assert.False(fg is null || bg is null, "UC-A20 — 테마 브러시 파싱 실패");
|
||||
var ratio = SourceTree.ContrastRatio(fg!, bg!);
|
||||
Assert.True(ratio >= 3.0, $"UC-A20 RED — 다크 BorderBrush/CardBg 대비 {ratio:F2}:1 < 3:1 (클릭 대상 식별 기준)");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
using VideoDownloader.App;
|
||||
using VideoDownloader.App.Browser.Features;
|
||||
using VideoDownloader.App.ViewModels;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// UI 복잡 버전 RED 스펙 — §E 다운로드 관리자 유즈케이스(docs/UI_USECASES.md §E, UC-D01~D30).
|
||||
/// RED 단계: 전부 실패하는 것이 정상이다.
|
||||
/// </summary>
|
||||
public class UiUsecaseDownloadsRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void UC_D01_다운로드_정렬_순서를_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "DownloadSortOrder"),
|
||||
"UC-D01", "AppConfig.DownloadSortOrder 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D02_진행중_완료_실패_필터_탭이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("DownloadFilterTabs"),
|
||||
"UC-D02", "다운로드 필터 탭(DownloadFilterTabs) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D03_다운로드_목록을_검색할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("DownloadSearchBox"),
|
||||
"UC-D03", "다운로드 검색 상자(DownloadSearchBox)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D04_남은_시간_ETA가_카드에_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "RemainingText"),
|
||||
"UC-D04", "다운로드 카드 VM 에 RemainingText(남은 시간) 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D05_현재_속도가_카드에_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "SpeedText"),
|
||||
"UC-D05", "다운로드 카드 VM 에 SpeedText 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D06_전체_일시정지를_할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(MainWindow), "PauseAllDownloads"),
|
||||
"UC-D06", "MainWindow.PauseAllDownloads() 가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D07_전체_재개를_할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(MainWindow), "ResumeAllDownloads"),
|
||||
"UC-D07", "MainWindow.ResumeAllDownloads() 가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D08_완료_항목_자동_치움을_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "AutoClearCompleted"),
|
||||
"UC-D08", "AppConfig.AutoClearCompleted 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D09_재시도_횟수가_카드에_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "RetryCount"),
|
||||
"UC-D09", "다운로드 카드 VM 에 RetryCount 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D10_세그먼트_진행이_시각화된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "SegmentMap"),
|
||||
"UC-D10", "세그먼트별 진행 맵(SegmentMap) 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D11_토스트_알림_컴포넌트가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.FindType("ToastNotifier") is not null,
|
||||
"UC-D11", "다운로드 완료 알림을 담당하는 ToastNotifier 컴포넌트가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D12_알림_소리를_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "NotifySound"),
|
||||
"UC-D12", "AppConfig.NotifySound 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D13_완료_시_폴더_자동_열기를_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "OpenFolderOnComplete"),
|
||||
"UC-D13", "AppConfig.OpenFolderOnComplete 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D14_카드_우클릭_컨텍스트_메뉴가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("CardContextMenu"),
|
||||
"UC-D14", "다운로드 카드 컨텍스트 메뉴(CardContextMenu)가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D15_카드를_펼쳐_상세_정보를_본다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("CardDetailExpander"),
|
||||
"UC-D15", "카드 상세 펼침(CardDetailExpander) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D16_대기_현황_요약이_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("QueueSummaryText"),
|
||||
"UC-D16", "패널의 대기 현황 요약(QueueSummaryText)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D17_속도_스파크라인이_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("SpeedSparkline"),
|
||||
"UC-D17", "속도 추이 스파크라인(SpeedSparkline) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D18_전체_속도_합계가_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("GlobalSpeedText"),
|
||||
"UC-D18", "전체 다운로드 속도(GlobalSpeedText) 표시가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D19_파일_총_크기가_카드에_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "TotalSizeText"),
|
||||
"UC-D19", "카드 VM 에 TotalSizeText 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D20_자막_언어를_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("SubtitleLangBox"),
|
||||
"UC-D20", "설정창에 자막 언어 선택(SubtitleLangBox)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D21_다운로드_스케줄러가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.FindType("DownloadScheduler") is not null,
|
||||
"UC-D21", "시간대 예약을 담당하는 DownloadScheduler 컴포넌트가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D22_속도_상한을_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "MaxDownloadSpeedKbps"),
|
||||
"UC-D22", "AppConfig.MaxDownloadSpeedKbps 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D23_목록을_CSV로_내보낼_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(MainWindow), "ExportDownloads"),
|
||||
"UC-D23", "MainWindow.ExportDownloads() 내보내기 기능이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D24_항목_우선순위를_정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "Priority"),
|
||||
"UC-D24", "카드 VM 에 대기열 우선순위(Priority) 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D25_중복_다운로드_경고를_받는다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(MainWindow), "ConfirmDuplicateDownload"),
|
||||
"UC-D25", "MainWindow.ConfirmDuplicateDownload() 확인 절차가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D26_최종_저장_파일명이_카드에_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "FinalFileName"),
|
||||
"UC-D26", "카드 VM 에 충돌 회피 후 파일명(FinalFileName)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D27_오류_메시지_복사_버튼이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("CopyErrorButton"),
|
||||
"UC-D27", "오류 복사 버튼(CopyErrorButton) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D28_페이지_다운로드도_전체_일시정지가_된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MethodExists(typeof(PageDownloadTracker), "PauseAll"),
|
||||
"UC-D28", "PageDownloadTracker.PauseAll() 메서드가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D29_미디어_종류_배지가_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("MediaKindBadge"),
|
||||
"UC-D29", "감지 미디어 종류 배지(MediaKindBadge) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_D30_사이트별_그룹_헤더가_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("SiteGroupHeader"),
|
||||
"UC-D30", "사이트별 묶음 헤더(SiteGroupHeader) 요소가 필요하다");
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// UI 복잡 버전 RED 스펙 — §H 키보드·마우스 인터랙션 유즈케이스(docs/UI_USECASES.md §H, UC-K01~K20).
|
||||
/// RED 단계: 전부 실패하는 것이 정상이다.
|
||||
/// </summary>
|
||||
public class UiUsecaseInteractionRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void UC_K01_단축키_설정_항목이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.SettingsWindowXaml.Contains("단축키"),
|
||||
"UC-K01", "설정창에 '단축키' 항목이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K02_단축키를_사용자가_바꿀_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "Shortcuts"),
|
||||
"UC-K02", "AppConfig.Shortcuts 사용자 지정 단축키 맵이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K03_단축키_관리_컴포넌트가_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.FindType("ShortcutManager") is not null,
|
||||
"UC-K03", "충돌 검증을 포함한 ShortcutManager 컴포넌트가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K04_단축키_전체_목록_팝업이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("ShortcutHelpPopup"),
|
||||
"UC-K04", "단축키 도움말 팝업(ShortcutHelpPopup)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K05_마우스_제스처로_탐색할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("MouseGesture") || SourceTree.MainWindowCs.Contains("XButton"),
|
||||
"UC-K05", "마우스 제스처(XButton 등) 뒤로/앞처 처리가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K06_휠_클릭으로_새_탭을_연다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("MiddleButton"),
|
||||
"UC-K06", "링크 휠 클릭(MiddleButton) 새 탭 처리가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K07_붙여넣기_즉시_이동_옵션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "PasteAndGo"),
|
||||
"UC-K07", "AppConfig.PasteAndGo 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K08_주소창_포커스_시_전체_선택_옵션이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "FocusSelectsAddress"),
|
||||
"UC-K08", "AppConfig.FocusSelectsAddress 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K09_모두_일시정지_빠른_명령이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("모두 일시정지"),
|
||||
"UC-K09", "팔레트/버튼의 '모두 일시정지' 명령이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K10_Esc로_모든_팝업을_한번에_닫는다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("CloseAllPopups") || SourceTree.MainWindowCs.Contains("CloseAllOverlays"),
|
||||
"UC-K10", "열린 팝업 일괄 닫기(CloseAllPopups) 처리가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K11_다운로드_중_종료_확인을_받는다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("종료하시겠습니까"),
|
||||
"UC-K11", "다운로드 진행 중 종료 확인 대화상자가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K12_F12_개발자_도구를_연다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("OpenDevTools"),
|
||||
"UC-K12", "F12 개발자 도구(OpenDevTools) 처리가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K13_프로필_전환_기능이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("프로필 전환"),
|
||||
"UC-K13", "세션 프로필 전환 기능이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K14_현재_줌_배율이_표시된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("ZoomIndicator"),
|
||||
"UC-K14", "줌 배율 표시(ZoomIndicator) 요소가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K15_기본_줌을_설정할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.PropertyExists(typeof(AppConfig), "DefaultZoomFactor"),
|
||||
"UC-K15", "AppConfig.DefaultZoomFactor 속성이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K16_즐겨찾기_관리_창이_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.FindType("BookmarkManagerWindow") is not null,
|
||||
"UC-K16", "즐겨찾기 관리 창(BookmarkManagerWindow)이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K17_방문_기록_개별_삭제가_된다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowXaml.Contains("DeleteHistory_Click"),
|
||||
"UC-K17", "방문 기록 개별 삭제(DeleteHistory_Click) 처리기가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K18_영역을_지정해_캡처할_수_있다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("RegionCapture"),
|
||||
"UC-K18", "영역 지정 캡처(RegionCapture) 기능이 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K19_노트를_Ctrl_S로_저장한다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("NotesSave_Click"),
|
||||
"UC-K19", "노트 저장(NotesSave_Click) 처리기가 필요하다");
|
||||
|
||||
[Fact]
|
||||
public void UC_K20_링크_우클릭으로_바로_받는다()
|
||||
=> SourceTree.Usecase(
|
||||
SourceTree.MainWindowCs.Contains("링크 다운로드"),
|
||||
"UC-K20", "페이지 링크 우클릭 '링크 다운로드' 메뉴가 필요하다");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue