From 17f2f9115232c21164d829b95bad2b5e628766a1 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 17 Aug 2026 15:54:53 +0900 Subject: [PATCH] =?UTF-8?q?UI=20=EB=B3=B5=EC=9E=A1=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?234=20RED=E2=86=92GREEN=20=EC=99=84=EB=A3=8C(docs/UI=5FUSECASES?= =?UTF-8?q?.md=20=C2=A7A~=C2=A7J):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=ED=85=8C=EB=A7=88=20=EC=A0=84=ED=99=98(DynamicResource=20?= =?UTF-8?q?=EC=A0=84=ED=99=98+ThemeResolver+HighContrast=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88,=20=EC=9E=AC=EC=8B=9C=EC=9E=91=20=EC=97=86=EC=9D=8C),?= =?UTF-8?q?=20=EB=94=94=EC=9E=90=EC=9D=B8=20=ED=86=A0=ED=81=B0=20=EC=B2=B4?= =?UTF-8?q?=EA=B3=84=C2=B7WCAG=20=EB=8C=80=EB=B9=84=20=EA=B2=B0=ED=95=A8?= =?UTF-8?q?=205=EA=B1=B4=20=EC=88=98=EC=A0=95,=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=B0=BD=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=82=B4?= =?UTF-8?q?=EB=B9=84/=EA=B2=80=EC=83=89=20=EC=9E=AC=EA=B5=AC=EC=84=B1+AppC?= =?UTF-8?q?onfig=2040=EC=97=AC=20=EC=86=8D=EC=84=B1,=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90(=EC=86=8D?= =?UTF-8?q?=EB=8F=84/ETA/=EC=84=B8=EA=B7=B8=EB=A8=BC=ED=8A=B8=EB=A7=B5/?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80-?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=B0=9B=EA=B8=B0/CSV=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0/=EC=98=88=EC=95=BD=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8/=EC=A4=91=EB=B3=B5=20=ED=99=95=EC=9D=B8),=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=C2=B7=EC=95=84=EC=B9=B4?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=C2=B7=EC=9E=91=EC=97=85=EA=B3=B5=EA=B0=84?= =?UTF-8?q?=C2=B7=EC=9E=AC=EC=83=9D=EB=AA=A9=EB=A1=9D=C2=B7=EC=A6=90?= =?UTF-8?q?=EA=B2=A8=EC=B0=BE=EA=B8=B0=20=ED=8E=B8=EC=A7=91=20=EB=B7=B0,?= =?UTF-8?q?=20=EC=A0=91=EA=B7=BC=EC=84=B1(=EC=9E=90=EB=8F=99=ED=99=94=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=2030=EC=97=AC=20=EA=B0=9C=C2=B7=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=ED=94=BC=EC=96=B4=C2=B7docs/ACCESSIBILITY?= =?UTF-8?q?.md),=20=EC=9D=B8=ED=84=B0=EB=9E=99=EC=85=98(=ED=9C=A0=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EC=83=88=20=ED=83=AD=C2=B7XButton=20?= =?UTF-8?q?=ED=83=90=EC=83=89=C2=B7F12=C2=B7=EC=98=81=EC=97=AD=20=EC=BA=A1?= =?UTF-8?q?=EC=B2=98=C2=B7=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A0=84=ED=99=98?= =?UTF-8?q?),=20=EC=83=81=ED=83=9C/=EC=95=8C=EB=A6=BC(=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=C2=B7=EC=95=8C=EB=A6=BC=EC=84=BC=ED=84=B0=C2=B7?= =?UTF-8?q?=EC=98=A8=EB=B3=B4=EB=94=A9=C2=B7CrashReportDialog).=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=20=ED=85=8C=EC=8A=A4=ED=8A=B8=2035=EA=B1=B4+?= =?UTF-8?q?=EC=8B=A4=EC=B0=BD=20E2E=204=EA=B1=B4(StaDispatcher=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=E2=80=94=20=ED=94=84=EB=A1=9D=EC=8B=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=B4=20=EA=B8=B0=EC=8B=9C=EC=9E=91=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=EB=A5=BC=20=EA=B1=B4=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=ED=81=AC=EB=9E=98=EC=8B=9C=20=EC=8B=A4=EA=B2=B0?= =?UTF-8?q?=ED=95=A8=20=EC=88=98=EC=A0=95=20=ED=8F=AC=ED=95=A8)=20?= =?UTF-8?q?=E2=80=94=20453/453=20GREEN.=20=EC=9D=B4=EC=A0=84=20=EB=AF=B8?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=20=EC=9E=91=EC=97=85(CI=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=C2=B7Mdns=C2=B7QueueHub=C2=B7UpdateChecker?= =?UTF-8?q?=C2=B7AGENTS/CLAUDE=C2=B7=EB=B9=8C=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8)=20=EC=9D=BC=EA=B4=84=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/hooks/session-brief.sh | 56 + .claude/hooks/verify-on-stop.sh | 71 + .claude/rules/desktop-wpf.md | 40 + .claude/rules/engine-core.md | 44 + .claude/rules/mobile-maui.md | 40 + .claude/rules/tests.md | 44 + .claude/settings.json | 52 + .claude/skills/release/SKILL.md | 53 + .claude/skills/tdd/SKILL.md | 72 + .claude/skills/verify/SKILL.md | 78 ++ .editorconfig | 83 ++ .gitattributes | 41 + .github/workflows/release.yml | 186 +++ .gitignore | 97 +- AGENTS.md | 90 ++ CLAUDE.md | 38 + Directory.Build.props | 26 + README.md | 141 +- VideoDownloader.App/App.xaml.cs | 124 +- .../BookmarkManagerWindow.xaml | 47 + .../BookmarkManagerWindow.xaml.cs | 156 +++ .../Browser/Features/PageDownloadTracker.cs | 14 + .../Browser/Features/ReaderMode.cs | 13 +- VideoDownloader.App/Browser/PlayerWindow.cs | 5 + VideoDownloader.App/Converters.cs | 28 + VideoDownloader.App/Core/DownloadManager.cs | 151 +- VideoDownloader.App/CrashReportDialog.xaml | 34 + VideoDownloader.App/CrashReportDialog.xaml.cs | 34 + VideoDownloader.App/MainWindow.Usecases.cs | 1244 +++++++++++++++++ VideoDownloader.App/MainWindow.xaml | 969 ++++++++++--- VideoDownloader.App/MainWindow.xaml.cs | 348 ++++- .../Services/DownloadScheduler.cs | 57 + .../Services/ShortcutManager.cs | 78 ++ VideoDownloader.App/Services/ThemeResolver.cs | 40 + VideoDownloader.App/Services/ToastNotifier.cs | 114 ++ VideoDownloader.App/SettingsWindow.xaml | 566 ++++++-- VideoDownloader.App/SettingsWindow.xaml.cs | 432 +++++- VideoDownloader.App/Themes/Dark.xaml | 23 +- VideoDownloader.App/Themes/HighContrast.xaml | 49 + VideoDownloader.App/Themes/Light.xaml | 23 +- VideoDownloader.App/Themes/Shared.xaml | 306 ++-- .../ViewModels/DownloadItemViewModel.cs | 78 ++ VideoDownloader.Browser/BrowserModels.cs | 3 +- VideoDownloader.Browser/NotesStore.cs | 9 + VideoDownloader.Browser/TabArchive.cs | 23 + VideoDownloader.Browser/Tabs/TabItem.cs | 12 + VideoDownloader.Browser/Tabs/TabManager.cs | 43 +- VideoDownloader.Browser/Tabs/TabPreview.cs | 25 + VideoDownloader.Core/Config/AppConfig.cs | 173 +++ VideoDownloader.Core/Hls/DirectDownloader.cs | 3 +- .../Models/DownloadProgress.cs | 3 +- .../Social/YtDlpDownloader.cs | 7 +- VideoDownloader.Core/Update/UpdateChecker.cs | 84 ++ VideoDownloader.Server/MdnsAdvertiser.cs | 58 + VideoDownloader.Server/QueueHub.cs | 17 + VideoDownloader.Server/ServerHost.cs | 64 +- VideoDownloader.Server/ServerModels.cs | 2 + .../VideoDownloader.Server.csproj | 1 + .../Browser/UiUsecaseHardeningTests.cs | 388 +++++ .../E2E/SettingsUpdateUiE2ETests.cs | 47 + .../E2E/UiUsecaseAccessibilityRedTests.cs | 142 ++ .../E2E/UiUsecaseDownloadsRedTests.cs | 194 +++ .../E2E/UiUsecaseInteractionRedTests.cs | 131 ++ .../E2E/UiUsecaseLayoutRedTests.cs | 168 +++ .../E2E/UiUsecaseLibraryRedTests.cs | 143 ++ .../E2E/UiUsecaseLiveE2ETests.cs | 249 ++++ .../E2E/UiUsecaseSettingsRedTests.cs | 132 ++ .../E2E/UiUsecaseStatusRedTests.cs | 131 ++ .../E2E/UiUsecaseTabsRedTests.cs | 163 +++ .../E2E/UiUsecaseThemeRedTests.cs | 213 +++ .../E2E/UiUsecaseTypographyRedTests.cs | 144 ++ .../Infrastructure/SourceTree.cs | 117 ++ .../Platform/UpdateCheckerTests.cs | 102 ++ .../Server/MdnsAdvertiserTests.cs | 49 + .../Server/QueueHubE2ETests.cs | 90 ++ .../VideoDownloader.Tests.csproj | 1 + VideoDownloader.slnx | 1 + build/obtainium.json | 8 + docs/ACCESSIBILITY.md | 40 + docs/BACKLOG.md | 30 +- docs/BROWSER_FEATURES_PLAN.md | 68 +- docs/DEPLOYMENT_PLAN.md | 50 +- docs/HARNESS.md | 151 ++ docs/IMPLEMENTATION_AUDIT.md | 47 +- docs/MOBILE_PLAN.md | 50 +- docs/UI_USECASES.md | 347 +++++ downloader.py | 477 ------- requirements.txt | 5 - 88 files changed, 9463 insertions(+), 1127 deletions(-) create mode 100644 .claude/hooks/session-brief.sh create mode 100644 .claude/hooks/verify-on-stop.sh create mode 100644 .claude/rules/desktop-wpf.md create mode 100644 .claude/rules/engine-core.md create mode 100644 .claude/rules/mobile-maui.md create mode 100644 .claude/rules/tests.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/release/SKILL.md create mode 100644 .claude/skills/tdd/SKILL.md create mode 100644 .claude/skills/verify/SKILL.md create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/release.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Directory.Build.props create mode 100644 VideoDownloader.App/BookmarkManagerWindow.xaml create mode 100644 VideoDownloader.App/BookmarkManagerWindow.xaml.cs create mode 100644 VideoDownloader.App/CrashReportDialog.xaml create mode 100644 VideoDownloader.App/CrashReportDialog.xaml.cs create mode 100644 VideoDownloader.App/MainWindow.Usecases.cs create mode 100644 VideoDownloader.App/Services/DownloadScheduler.cs create mode 100644 VideoDownloader.App/Services/ShortcutManager.cs create mode 100644 VideoDownloader.App/Services/ThemeResolver.cs create mode 100644 VideoDownloader.App/Services/ToastNotifier.cs create mode 100644 VideoDownloader.App/Themes/HighContrast.xaml create mode 100644 VideoDownloader.Browser/Tabs/TabPreview.cs create mode 100644 VideoDownloader.Core/Update/UpdateChecker.cs create mode 100644 VideoDownloader.Server/MdnsAdvertiser.cs create mode 100644 VideoDownloader.Server/QueueHub.cs create mode 100644 VideoDownloader.Tests/Browser/UiUsecaseHardeningTests.cs create mode 100644 VideoDownloader.Tests/E2E/SettingsUpdateUiE2ETests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseAccessibilityRedTests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseDownloadsRedTests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseInteractionRedTests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseLayoutRedTests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseLibraryRedTests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseLiveE2ETests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseSettingsRedTests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseStatusRedTests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseTabsRedTests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseThemeRedTests.cs create mode 100644 VideoDownloader.Tests/E2E/UiUsecaseTypographyRedTests.cs create mode 100644 VideoDownloader.Tests/Infrastructure/SourceTree.cs create mode 100644 VideoDownloader.Tests/Platform/UpdateCheckerTests.cs create mode 100644 VideoDownloader.Tests/Server/MdnsAdvertiserTests.cs create mode 100644 VideoDownloader.Tests/Server/QueueHubE2ETests.cs create mode 100644 build/obtainium.json create mode 100644 docs/ACCESSIBILITY.md create mode 100644 docs/HARNESS.md create mode 100644 docs/UI_USECASES.md delete mode 100644 downloader.py delete mode 100644 requirements.txt diff --git a/.claude/hooks/session-brief.sh b/.claude/hooks/session-brief.sh new file mode 100644 index 0000000..6742c44 --- /dev/null +++ b/.claude/hooks/session-brief.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# SessionStart 훅 — 폐쇄루프의 "state layer". +# +# 세션 시작 시 저장소의 현재 상태를 요약해 컨텍스트에 넣는다. +# 에이전트가 매번 git status/log 를 따로 물어보지 않아도 지금 상황을 알고 시작한다. +# stdout 이 그대로 컨텍스트로 들어가므로 짧게 유지한다. +set -uo pipefail + +ROOT="${CLAUDE_PROJECT_DIR:-}" +if [ -z "$ROOT" ]; then + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fi +cd "$ROOT" || exit 0 + +echo "## 저장소 현재 상태 (SessionStart 자동 브리핑)" +echo + +BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?") +echo "- 브랜치: \`$BRANCH\`" + +LAST=$(git log -1 --format='%h %s' 2>/dev/null | cut -c1-110) +echo "- 마지막 커밋: $LAST" + +CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') +if [ "$CHANGED" = "0" ]; then + echo "- 작업트리: 깨끗함" +else + echo "- 작업트리: 변경 $CHANGED 건" + git status --porcelain 2>/dev/null | head -10 | sed 's/^/ /' + if [ "$CHANGED" -gt 10 ]; then + echo " … 외 $((CHANGED - 10))건" + fi +fi + +# 마지막 검증 이후 소스가 바뀌었는지 (Stop 훅이 쓰는 스탬프와 같은 기준) +STAMP=".claude/.verify-stamp" +NEWEST=$(find ./VideoDownloader.* -type f \ + \( -name '*.cs' -o -name '*.csproj' -o -name '*.xaml' -o -name '*.axaml' \) \ + -not -path '*/bin/*' -not -path '*/obj/*' \ + -printf '%T@\n' 2>/dev/null | sort -rn | head -1) + +if [ -f "$STAMP" ] && [ "$(cat "$STAMP" 2>/dev/null)" = "$NEWEST" ]; then + echo "- 검증: 마지막 \`dotnet test\` 이후 소스 변경 없음 (GREEN 상태)" +else + echo "- 검증: **미검증 변경 있음** — 작업 전에 \`dotnet test VideoDownloader.Tests\` 로 기준선을 확인할 것" +fi + +# 미완료 백로그 +if [ -f docs/BACKLOG.md ]; then + OPEN=$(grep -c '^- \[ \]' docs/BACKLOG.md 2>/dev/null) || OPEN=0 + echo "- 백로그 미완료: ${OPEN}건 (\`docs/BACKLOG.md\`)" +fi + +echo +echo "작업 계약은 \`AGENTS.md\`, 하네스 운영법은 \`docs/HARNESS.md\` 참고." +exit 0 diff --git a/.claude/hooks/verify-on-stop.sh b/.claude/hooks/verify-on-stop.sh new file mode 100644 index 0000000..e4389ba --- /dev/null +++ b/.claude/hooks/verify-on-stop.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Stop 훅 — 폐쇄루프의 "checker" 층. +# +# 소스(.cs/.csproj/.xaml/.axaml)가 마지막 성공 검증 이후 바뀌었으면 테스트를 돌린다. +# 실패하면 exit 2 로 턴 종료를 막고, 실패 내용을 에이전트에게 돌려준다. +# 변경이 없으면 즉시 통과하므로 대화만 하는 턴은 지연이 없다. +# +# 무한 루프 방지: stop_hook_active 가 true 면(=이미 이 훅 때문에 재개된 상태) 바로 통과. +set -uo pipefail + +INPUT=$(cat) + +# ── 1. 재진입 가드 ──────────────────────────────────────────────────── +if command -v jq >/dev/null 2>&1; then + ACTIVE=$(printf '%s' "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false) +else + case "$INPUT" in + *'"stop_hook_active"'*[Tt]rue*) ACTIVE=true ;; + *) ACTIVE=false ;; + esac +fi +if [ "$ACTIVE" = "true" ]; then + exit 0 +fi + +# ── 2. 프로젝트 루트 ────────────────────────────────────────────────── +ROOT="${CLAUDE_PROJECT_DIR:-}" +if [ -z "$ROOT" ]; then + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fi +cd "$ROOT" || exit 0 + +STAMP=".claude/.verify-stamp" + +# ── 3. 변경 감지 (소스 최신 수정시각) ───────────────────────────────── +NEWEST=$(find ./VideoDownloader.* -type f \ + \( -name '*.cs' -o -name '*.csproj' -o -name '*.xaml' -o -name '*.axaml' \) \ + -not -path '*/bin/*' -not -path '*/obj/*' \ + -printf '%T@\n' 2>/dev/null | sort -rn | head -1) + +# 소스를 못 찾으면(경로 이상 등) 훅이 개발을 막지 않도록 통과시킨다. +if [ -z "$NEWEST" ]; then + exit 0 +fi + +if [ -f "$STAMP" ] && [ "$(cat "$STAMP" 2>/dev/null)" = "$NEWEST" ]; then + exit 0 +fi + +# ── 4. 검증 ─────────────────────────────────────────────────────────── +LOG=$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/vd-verify.$$") +dotnet test VideoDownloader.Tests/VideoDownloader.Tests.csproj \ + --nologo -v quiet >"$LOG" 2>&1 +CODE=$? + +if [ $CODE -eq 0 ]; then + printf '%s' "$NEWEST" > "$STAMP" + rm -f "$LOG" + exit 0 +fi + +# ── 5. 실패 → 종료 차단 ─────────────────────────────────────────────── +{ + echo "검증 실패: dotnet test 가 통과하지 않았다. 테스트를 GREEN 으로 만들기 전에는 턴을 끝낼 수 없다." + echo "테스트를 삭제하거나 Skip 처리해서 통과시키지 말 것. 근본 원인을 고칠 것." + echo "---- dotnet test 출력 (마지막 60줄) ----" + tail -60 "$LOG" +} >&2 + +rm -f "$LOG" +exit 2 diff --git a/.claude/rules/desktop-wpf.md b/.claude/rules/desktop-wpf.md new file mode 100644 index 0000000..6e9799a --- /dev/null +++ b/.claude/rules/desktop-wpf.md @@ -0,0 +1,40 @@ +--- +paths: + - "VideoDownloader.App/**" + - "VideoDownloader.Browser/**" +--- + +# WPF · WebView2 영역 규칙 + +## UI 스레드 + +- WebView2·엔진 이벤트는 백그라운드 스레드에서 온다. UI 를 만지려면 + `Dispatcher.BeginInvoke` 로 마샬링한다 (`MainWindow.xaml.cs` 전반의 기존 패턴). +- 백그라운드에서 갱신되는 컬렉션은 `BindingOperations.EnableCollectionSynchronization` + 으로 락을 등록한다 (`Core/DownloadManager.cs:58` 참조). 이걸 빼면 랜덤하게 죽는다. + +## dispose 이후 이벤트 + +WebView2 가 dispose 된 뒤에도 이벤트 콜백이 도착한다. 콜백 안에서 `CoreWebView2` 를 만질 때는 +로컬 변수로 받아 null 을 확인하고 try 로 감싼다 — `UpdateNavState`(`MainWindow.xaml.cs:453`)가 +기준 패턴이다. 탭을 닫는 경로를 건드렸다면 이 방어를 반드시 확인한다. + +## 탭 + +- Environment 는 전 탭 공유, WebView2 인스턴스는 탭마다. 이 구조를 바꾸지 않는다. +- 팝업(`NewWindowRequested`)은 deferral 패턴으로 새 탭에 붙인다. 앱 통제를 벗어나게 두지 않는다. + +## 거대 파일 + +`MainWindow.xaml.cs`(86KB) · `MainWindow.xaml`(55KB)은 통째로 읽지 않는다. +Grep 으로 심볼 위치를 찾고 해당 구간만 Read 한다. 분할 리팩터링은 별도 승인 사항이다. + +## 프로세스 실행 + +외부 프로세스는 문자열 인자 연결로 실행하지 않는다. `ProcessStartInfo` 에 인자를 배열로 넘긴다 +(과거 `explorer.exe` 인자 인젝션 취약점이 있었다). + +## 테스트 접근 + +`VideoDownloader.App` 은 `InternalsVisibleTo("VideoDownloader.Tests")` 가 걸려 있다. +테스트를 위해 멤버를 `public` 으로 승격하지 말고 `internal` 로 두면 된다. diff --git a/.claude/rules/engine-core.md b/.claude/rules/engine-core.md new file mode 100644 index 0000000..76d4506 --- /dev/null +++ b/.claude/rules/engine-core.md @@ -0,0 +1,44 @@ +--- +paths: + - "VideoDownloader.Core/**" + - "VideoDownloader.Server/**" +--- + +# 엔진 · 서버 영역 규칙 + +## 플랫폼 중립 + +`Core` 는 `net8.0` 이다. WPF·Windows 전용 API 를 끌어들이지 않는다. +Avalonia/MAUI 가 같은 엔진을 쓰므로 여기 들어간 Windows 의존은 크로스플랫폼 이관을 막는다. +OS별 경로는 `Platform/CorePaths.cs` 를 통한다. + +## 외부 프로세스 (ffmpeg 등) + +`Platform/ProcessModels.cs` 의 실행 헬퍼를 쓴다. 직접 `Process.Start` 를 새로 쓰지 않는다. +그 헬퍼가 보장하는 것들을 우회하면 과거 버그가 재발한다: + +- stdout/stderr **동시 드레인** — 안 읽으면 파이프 버퍼가 차서 ffmpeg 가 hang 한다 +- 취소·실패 시 `Kill(entireProcessTree: true)` — 안 하면 좀비 프로세스가 남는다 +- 인자는 배열 전달 — 문자열 연결 금지 +- exit code ≠ 0 이면 예외 — 조용히 넘어가면 실패가 `.ts` 잔재로만 드러난다 + +## 취소 + +모든 장기 작업은 `CancellationToken` 을 끝까지 전달한다. 중간에 삼키지 않는다. +`Parallel.ForEachAsync` 는 `ParallelOptions.CancellationToken` 에 넣는다 +(`Hls/HlsDownloader.cs:77` 패턴). + +## 동시성 + +세그먼트 병렬도는 `AppConfig.Concurrency`, 동시 다운로드 수는 `MaxConcurrentDownloads`(기본 3)로 +제어한다. 하드코딩된 병렬도를 새로 만들지 않는다 — 소켓 고갈로 이어진다. + +## 데이터 경로 + +사용자 데이터는 `CorePaths` 를 통해서만 접근한다. `VD_DATA_DIR` 환경변수 오버라이드가 +E2E 테스트 격리의 공식 후크이므로, 경로를 직접 조립하면 테스트가 실사용자 폴더를 오염시킨다. + +## 서버 + +`Server` 는 App 안에서 호스팅되는 라이브러리다. 포트 0 이면 빈 포트를 자동 할당한다. +LAN 노출 API 이므로 인증·경로 검증을 우회하는 엔드포인트를 추가하지 않는다. diff --git a/.claude/rules/mobile-maui.md b/.claude/rules/mobile-maui.md new file mode 100644 index 0000000..94ea4ca --- /dev/null +++ b/.claude/rules/mobile-maui.md @@ -0,0 +1,40 @@ +--- +paths: + - "VideoDownloader.Mobile/**" + - "VideoDownloader.Mobile.Core/**" +--- + +# MAUI 모바일 영역 규칙 + +## 로직은 Mobile.Core 로 + +`VideoDownloader.Mobile` 은 `net10.0-android`(macOS 에서는 +`net10.0-ios`)이라 +`VideoDownloader.Tests`(net8.0-windows)가 참조할 수 없다. **테스트 가능한 로직은 +`VideoDownloader.Mobile.Core`(net8.0)에 둔다.** UI 프로젝트에는 화면 결선만 남긴다. + +새 기능을 UI 프로젝트에 통째로 넣으면 검증할 방법이 사라진다. + +## 링커 함정 + +Release/링커 트리밍에서 참조가 정적으로 안 보이는 타입은 통째로 잘려나가고, +**런타임 `FileNotFoundException` 으로만** 드러난다. 빌드는 통과한다. + +실제 사례: 모달 `NavigationPage` 가 크래시 → `Xamarin.AndroidX.LocalBroadcastManager` 를 +csproj 에 명시 참조해 해결. 리플렉션·XAML 로만 참조되는 타입을 쓸 때 이 함정을 의심한다. + +## 플랫폼 분기 + +TFM 조건은 `$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'` +형식으로 쓴다(csproj 기존 패턴). Windows 개발머신에는 iOS 워크로드가 없으므로 +iOS TFM 은 macOS 조건부로만 켠다 — 이 조건을 무조건부로 바꾸면 Windows 빌드가 깨진다. + +## 빌드 · 검증 + +```powershell +dotnet build VideoDownloader.Mobile -f net10.0-android +``` + +- MAUI 워크로드가 없으면 실패한다. 없는 환경이면 `Mobile.Core` 만 고치고 + UI 결선은 워크로드가 있는 환경에서 확인하도록 사용자에게 알린다. +- 화면 동작을 바꿨으면 에뮬레이터에 배포해 확인한 내용을 보고에 적는다. + 단위 테스트만으로 "됐다"고 하지 않는다. diff --git a/.claude/rules/tests.md b/.claude/rules/tests.md new file mode 100644 index 0000000..0d65d0b --- /dev/null +++ b/.claude/rules/tests.md @@ -0,0 +1,44 @@ +--- +paths: + - "VideoDownloader.Tests/**" +--- + +# 테스트 영역 규칙 + +## 배치 + +`VideoDownloader.Tests/<영역>/<대상>Tests.cs` — 영역은 +`Browser` · `E2E` · `Infrastructure` · `Mobile` · `Platform` · `Server`. +새 영역을 만들기 전에 기존 영역에 맞는지 먼저 본다. + +## 격리 (필수) + +사용자 데이터 경로를 건드리는 테스트는 `VD_DATA_DIR` 을 임시 디렉터리로 지정하고 +끝나면 `null` 로 되돌린다. `E2E/MainWindowSmokeE2ETests.cs:17` 이 기준 패턴이다. +이걸 빼면 테스트가 실사용자의 설정·세션·프로필을 덮어쓴다. + +## WPF 테스트 + +테스트 프로세스에는 `App.xaml` 리소스가 없다. WPF 요소를 만드는 테스트는 +`Infrastructure/WpfTestApp.Ensure(dispatcher)` 를 먼저 호출해 테마 사전을 로드한다. +직접 `new Application()` 을 만들지 않는다. + +## 금지 + +- 통과시키려고 테스트를 **삭제하거나 `Skip` 처리하지 않는다.** 회귀를 숨기는 것이다. +- 실제 네트워크에 의존하는 단정을 새로 만들지 않는다. 외부 사이트가 바뀌면 무작위로 깨진다. + (`Cli` 의 `--dashparse` 같은 수동 검증 도구는 예외 — 테스트가 아니다.) +- 시간·순서에 의존하는 `Thread.Sleep` 기반 단정을 쓰지 않는다. + +## 알려진 경고 + +이 프로젝트에 남아 있는 경고: CS8602(null 역참조) 몇 건, xUnit1031(블로킹 대기), +xUnit2013(`Assert.Equal` 로 개수 확인). **개수를 늘리지 않는다.** +새 테스트에서는 `Assert.Single`/`Assert.Empty` 와 `async` 대기를 쓴다. + +## 실행 + +```powershell +dotnet test VideoDownloader.Tests # 전체 (~13초) +dotnet test VideoDownloader.Tests --filter "FullyQualifiedName~<이름>" # 단건 +``` diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0ddc3d0 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "command": "bash", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/session-brief.sh"], + "timeout": 30, + "statusMessage": "저장소 상태 브리핑…" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-on-stop.sh"], + "timeout": 420, + "statusMessage": "dotnet test 검증 중…" + } + ] + } + ] + }, + "permissions": { + "allow": [ + "Bash(dotnet build *)", + "Bash(dotnet test *)", + "Bash(dotnet restore *)", + "Bash(dotnet run *)", + "Bash(dotnet format *)", + "Bash(dotnet --*)", + "Bash(git status *)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git show *)", + "Bash(git branch *)" + ], + "deny": [ + "Read(.env)", + "Edit(.env)", + "Write(.env)" + ] + } +} diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..13b832f --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,53 @@ +--- +name: release +description: VideoDownloader 배포 절차. Windows 설치자(x64/arm64) 생성과 macOS/Linux 원격 빌드머신 빌드를 수행한다. "배포", "설치자 만들어", "릴리스", "publish" 요청에 사용. +--- + +# 배포 + +배포는 되돌리기 어렵다. **각 단계 전에 사용자 확인을 받는다.** 요청받지 않은 배포를 임의로 하지 않는다. + +## 0. 사전 점검 (필수) + +```powershell +dotnet test VideoDownloader.Tests +git status --short +``` + +- 테스트 실패가 있으면 배포하지 않는다. +- 커밋되지 않은 변경이 있으면 사용자에게 알린다. 원격 빌드는 `git archive HEAD` 로 소스를 보내므로 + **커밋되지 않은 변경은 원격 빌드에 반영되지 않는다.** + +## 1. Windows 설치자 + +```powershell +./build/publish.ps1 -Config Release -Version <버전> +``` + +- 하는 일: `VideoDownloader.App` 을 `win-x64`/`win-arm64` self-contained 로 publish (`publish//`) + → Inno Setup 으로 설치자 컴파일 → `out/VideoDownloader-Setup-.exe` +- 필요: **Inno Setup 6** (`winget install --id JRSoftware.InnoSetup -e`). + 없으면 스크립트가 명시적으로 throw 한다. +- 특정 아키텍처만: `-Archs x64` +- arm64 첫 빌드는 yt-dlp/WebView2 런타임 관련으로 실패할 수 있다. 실패 메시지를 그대로 사용자에게 전달한다. +- 완료 시 스크립트가 산출물 크기를 출력한다. **그 출력을 인용해 보고한다.** + +## 2. macOS / Linux 원격 빌드 + +```bash +bash build/remote/build-remote.sh all # 또는 linux | mac +``` + +- 하는 일: `git archive HEAD` 로 현재 커밋 소스를 빌드머신에 SSH 전송 → + `Cli` + `Avalonia` 를 RID별 self-contained publish → `out/remote//artifacts-.tar.gz` 수집 +- 자격 증명은 `.env` 의 `MAC_OS_HOST`/`LINUX_OS_HOST` 등을 쓴다. + **`.env` 내용을 읽어서 출력하거나 로그에 남기지 않는다.** +- 빌드머신이 절전이면 SSH 가 끊긴다. 재연결 후 재시도한다. +- Avalonia 12.1 은 빌드머신에 .NET 10 SDK 가 있어야 한다. 8만 있으면 CS0103 으로 실패한다. + +## 3. 배포 후 + +- 산출물 경로와 크기를 보고한다. +- `out/`, `publish/` 는 gitignore 대상이다. 커밋하지 않는다. +- 릴리스 배포처(GitHub Releases 등)에 올리는 것은 **별도 승인 사항**이다. 임의로 업로드하지 않는다. +- 배포 체계 전반은 `docs/DEPLOYMENT_PLAN.md` 참고. diff --git a/.claude/skills/tdd/SKILL.md b/.claude/skills/tdd/SKILL.md new file mode 100644 index 0000000..a509952 --- /dev/null +++ b/.claude/skills/tdd/SKILL.md @@ -0,0 +1,72 @@ +--- +name: tdd +description: VideoDownloader 에서 기능을 추가하거나 버그를 고칠 때 쓰는 RED→GREEN→증거 폐쇄루프 절차. 실패 테스트를 먼저 만들고, 최소 구현으로 통과시키고, 실행 증거까지 남긴다. "기능 추가", "버그 수정", "TDD로", "테스트부터" 같은 작업에 사용. +--- + +# TDD 폐쇄루프 + +이 저장소는 RED→GREEN→증거로 개발해 왔다. 이 순서를 건너뛰지 않는다. + +## 0. 기준선 확보 (건너뛰지 말 것) + +```powershell +dotnet test VideoDownloader.Tests +``` + +지금 몇 개가 통과하는지 **숫자를 기록**한다. 이미 깨져 있다면 그것부터 사용자에게 알린다. +내 변경으로 깨진 것과 원래 깨져 있던 것을 섞지 않기 위해 반드시 먼저 한다. + +## 1. RED — 실패를 먼저 본다 + +고칠 동작을 **재현하는 테스트**를 쓴다. + +- 위치: `VideoDownloader.Tests/<영역>/<대상>Tests.cs` + (영역 = `Browser` · `E2E` · `Infrastructure` · `Mobile` · `Platform` · `Server`) +- 순수 로직이면 `Core`/`Mobile.Core` 쪽으로 밀어 넣어 UI 없이 테스트되게 만든다. + WPF 의존이 꼭 필요하면 `Infrastructure/WpfTestApp.cs` 패턴을 따른다. +- 사용자 데이터 경로를 건드리는 테스트는 `VD_DATA_DIR` 로 격리한다. + +```powershell +dotnet test VideoDownloader.Tests --filter "FullyQualifiedName~<새테스트이름>" +``` + +**실패하는 것을 눈으로 확인한다.** 여기서 통과해 버리면 테스트가 대상을 못 잡고 있는 것이다. +테스트를 고쳐서 진짜 실패하게 만든 다음 진행한다. + +## 2. GREEN — 최소 구현 + +- 테스트를 통과시키는 가장 작은 변경만 한다. 겸사겸사 리팩터링하지 않는다. +- 에러를 삼키지 않는다. 빈 `catch`, 가짜 폴백 금지. 근본 원인을 고친다. +- 거대 파일(`MainWindow.xaml.cs` 86KB)은 Grep 으로 위치를 찾아 필요한 구간만 읽고 고친다. + +```powershell +dotnet test VideoDownloader.Tests +``` + +**전체**가 통과해야 한다. 기준선보다 테스트 수가 줄면 무언가를 지운 것이다 — 되돌린다. + +## 3. 증거 — 테스트로 부족한 것 + +UI·플랫폼 동작을 바꿨다면 테스트 GREEN 만으로는 "된다"는 근거가 아니다. + +| 바꾼 것 | 확인 방법 | +|---|---| +| WPF 화면·상호작용 | `dotnet run --project VideoDownloader.App` 로 실제 창을 띄워 확인 | +| WebView2 감지·쿠키 | 실제 사이트를 열어 미디어가 목록에 잡히는지 확인 | +| 서버 API | 앱 기동 후 해당 엔드포인트 호출 | +| MAUI 화면 | Android 에뮬레이터에 배포해 확인 | +| 다운로드 엔진 | `dotnet run --project VideoDownloader.Cli -- <검증 옵션>` | + +무엇을 어떻게 확인했는지 보고에 쓴다. "확인했습니다"만 쓰지 말고 관측한 내용을 쓴다. + +## 4. 마무리 + +- `docs/BACKLOG.md` 해당 항목 `- [ ]` → `- [x]` +- 계획 대비 구현 상태가 바뀌었으면 `docs/IMPLEMENTATION_AUDIT.md` 갱신 +- 보고에는 **실제 테스트 출력의 숫자**를 인용한다 (예: "164/164 통과"). 기억으로 쓰지 않는다. + +## 막혔을 때 + +- 원인을 모르겠으면 추측으로 코드를 바꾸지 말고, 먼저 재현 범위를 좁히는 테스트를 더 쓴다. +- 그래도 안 되면 무엇을 시도했고 무엇이 관측됐는지 정리해 사용자에게 보고한다. + 통과하지 못한 것을 통과한 것처럼 쓰지 않는다. diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..7a0daa4 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,78 @@ +--- +name: verify +description: VideoDownloader 변경사항이 완료 기준(Definition of Done)을 만족하는지 전수 점검한다. 빌드·테스트·경고·문서 동기화·산출물 오염까지 확인하고 통과/미통과를 판정한다. "다 됐나 확인", "검증해줘", "완료 기준 점검", 작업을 마무리하기 직전에 사용. +--- + +# 완료 검증 + +작업을 "완료"로 보고하기 전에 아래를 순서대로 실행하고, 각 항목의 **실제 출력**을 근거로 판정한다. +하나라도 실패하면 완료가 아니다. 고치거나, 못 고치는 이유를 명시한다. + +## 1. 테스트 + +```powershell +dotnet test VideoDownloader.Tests +``` + +- 실패 0건인가? +- 통과 수가 작업 전보다 줄지 않았는가? (현재 기준선 164) +- 새로 추가한 동작을 실제로 검증하는 테스트가 있는가? 없으면 지금 쓴다. + +## 2. 빌드 · 경고 + +```powershell +dotnet build VideoDownloader.App +``` + +- 오류 0인가? +- **내가 새로 만든 경고가 있는가?** 기존 경고(`MainWindow.xaml.cs`·테스트의 CS8602 몇 건, + xUnit 분석기 경고)는 알려진 것이다. 개수가 늘었으면 내가 늘린 것이다 — 고친다. + +모바일을 건드렸다면 (MAUI 워크로드가 설치돼 있을 때): + +```powershell +dotnet build VideoDownloader.Mobile -f net10.0-android +``` + +## 3. 실행 증거 + +UI·플랫폼 동작을 바꿨는데 실행해 보지 않았다면 여기서 멈추고 실행한다. +`/tdd` 스킬의 "3. 증거" 표를 따른다. 무엇을 관측했는지 기록한다. + +## 4. 저장소 위생 + +```powershell +git status --short +``` + +- 산출물(`out/`, `publish/`, `bin/`, `obj/`)이 변경 목록에 보이는가? → `.gitignore` 가 뚫린 것이다. +- `.env` 가 보이는가? → **즉시 멈추고 사용자에게 알린다.** 실제 자격 증명이 들어 있다. +- 의도하지 않은 파일이 섞였는가? + +```powershell +git diff --stat +``` + +- 변경 규모가 작업 범위와 맞는가? 요청하지 않은 리팩터링이 섞이지 않았는가? + +## 5. 문서 동기화 + +- `docs/BACKLOG.md` — 완료 항목 체크박스 반영했는가? +- `docs/IMPLEMENTATION_AUDIT.md` — 계획 대비 구현 상태가 바뀌었으면 갱신했는가? +- 명령·구조·규약이 바뀌었으면 `AGENTS.md` 와 `README.md` 도 갱신했는가? + +## 6. 판정 보고 + +아래 형식으로 사용자에게 보고한다. **실제 출력에서 숫자를 인용한다.** + +``` +검증 결과 +- 테스트: <통과>/<전체> 통과, 실패 건 +- 빌드: 오류 , 신규 경고 +- 실행 증거: <무엇을 어떻게 확인했는지 / 해당 없음> +- 저장소: 변경 건, 산출물 오염 없음 +- 문서: <갱신한 파일 / 갱신 불필요> +판정: 완료 | 미완료(사유: …) +``` + +미통과 항목을 숨기거나 완곡하게 쓰지 않는다. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bf9fea2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,83 @@ +# VideoDownloader 코드 스타일 +# 기존 코드베이스 실측 기준: 4스페이스 · file-scoped namespace · Allman 중괄호 · UTF-8 한글 주석 +root = true + +[*] +charset = utf-8 +end_of_line = crlf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml,json,csproj,props,targets,xml,plist}] +indent_size = 2 + +[*.md] +# 마크다운은 줄 끝 공백 2칸이 강제 개행이라 자르지 않는다. +trim_trailing_whitespace = false + +[*.sh] +end_of_line = lf + +[*.cs] +# ── using ── +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_using_directive_placement = outside_namespace:warning + +# ── 네임스페이스/중괄호 ── +csharp_style_namespace_declarations = file_scoped:warning +csharp_prefer_braces = when_multiline:silent +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true + +# ── 최신 C# 문법 (LangVersion latest 전제) ── +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:silent +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_index_operator = true:suggestion + +# ── this. 미사용 (기존 코드 관행) ── +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# ── 명명 규칙: private 필드 _camelCase ── +dotnet_naming_rule.private_fields_underscore.severity = suggestion +dotnet_naming_rule.private_fields_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_underscore.style = underscore_prefix +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_prefix.required_prefix = _ +dotnet_naming_style.underscore_prefix.capitalization = camel_case + +# ── 상수/공개 멤버 PascalCase ── +dotnet_naming_rule.public_members_pascal.severity = suggestion +dotnet_naming_rule.public_members_pascal.symbols = public_members +dotnet_naming_rule.public_members_pascal.style = pascal_case +dotnet_naming_symbols.public_members.applicable_kinds = property,method,event,class,struct,interface,enum +dotnet_naming_symbols.public_members.applicable_accessibilities = public,internal,protected +dotnet_naming_style.pascal_case.capitalization = pascal_case + +# ── 실수 방지: 경고로 올려두는 항목 ── +# 사용하지 않는 지역 변수 / 도달 불가 코드 / 비동기 메서드 await 누락 +dotnet_diagnostic.CS4014.severity = warning +dotnet_diagnostic.CS1998.severity = suggestion +dotnet_diagnostic.IDE0005.severity = suggestion + +[*.{xaml,axaml}] +indent_size = 4 +# XAML 은 IDE 포매터가 후행 공백을 남기는 경우가 잦아 강제하지 않는다. +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f894ca4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,41 @@ +# 저장소에는 LF 로 저장하고, 체크아웃 시 플랫폼 기본 규칙을 따른다. +# (현재 저장소는 이미 LF 정규화 상태이므로 재정규화 diff 가 발생하지 않는다.) +* text=auto + +# ── 줄바꿈을 강제해야 하는 파일 ───────────────────────────────────── +# 원격 리눅스/맥 빌드머신에서 bash 로 실행 → CRLF 면 즉시 깨진다. +*.sh text eol=lf +# Windows 전용 스크립트 · Inno Setup +*.ps1 text eol=crlf +*.psm1 text eol=crlf +*.cmd text eol=crlf +*.bat text eol=crlf +*.iss text eol=crlf + +# ── 텍스트로 다뤄야 하는 프로젝트 파일 ────────────────────────────── +*.cs text diff=csharp +*.csproj text +*.slnx text +*.props text +*.targets text +*.xaml text +*.axaml text +*.plist text +*.json text +*.md text + +# ── 바이너리 (diff/머지 시도 금지) ────────────────────────────────── +*.png binary +*.jpg binary +*.ico binary +*.ttf binary +*.otf binary +*.dll binary +*.exe binary +*.mp4 binary + +# ── 서드파티 번들: 언어 통계·diff 노이즈에서 제외 ─────────────────── +VideoDownloader.App/Assets/hls.min.js linguist-vendored +VideoDownloader.App/Assets/dash.all.min.js linguist-vendored +VideoDownloader.App/Assets/readability.js linguist-vendored +*.min.js -diff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..969703e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,186 @@ +# DEPLOYMENT_PLAN §5 — 태그(v*) 푸시 → 전 아티팩트 매트릭스 빌드 → Draft Release +# D1: test → Windows 설치자 · D2: Android APK · D3: Linux tar.gz +# D4(macOS 서명·공증)/D5(iOS TestFlight)는 Apple Developer Program 시크릿 등록 후 활성화. +name: release + +on: + push: + tags: ["v*"] + workflow_dispatch: # 수동 실행(리허설) + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + # ── 관문: 전 플랫폼 빌드 전에 단위+E2E 통과 ── + test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + - name: dotnet test + run: dotnet test VideoDownloader.Tests -c Release + + # ── Windows 설치자 (x64 / arm64 교차 publish + Inno Setup) ── + windows: + needs: test + runs-on: windows-latest + strategy: + matrix: + arch: [x64, arm64] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Inno Setup 설치 + run: choco install innosetup -y --no-progress + - name: 버전 결정 (태그 v1.2.3 → 1.2.3) + shell: pwsh + run: | + $v = "${{ github.ref_name }}".TrimStart("v") + if ($v -notmatch '^\d+\.\d+\.\d+') { $v = "0.0.0" } # workflow_dispatch 리허설 + "APP_VERSION=$v" >> $env:GITHUB_ENV + - name: publish + 설치자 + shell: pwsh + run: ./build/publish.ps1 -Config Release -Version $env:APP_VERSION -Archs ${{ matrix.arch }} + - uses: actions/upload-artifact@v4 + with: + name: win-${{ matrix.arch }} + path: out/VideoDownloader-Setup-${{ matrix.arch }}.exe + + # ── Linux 셸 (tar.gz self-contained — Avalonia + Cli) ── + linux: + needs: test + strategy: + matrix: + include: + - { os: ubuntu-latest, rid: linux-x64 } + - { os: ubuntu-24.04-arm, rid: linux-arm64 } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + - name: publish (Cli + Avalonia) + run: | + dotnet publish VideoDownloader.Cli -c Release -r ${{ matrix.rid }} --self-contained -o out/cli-${{ matrix.rid }} + dotnet publish VideoDownloader.Avalonia -c Release -r ${{ matrix.rid }} --self-contained -o out/avalonia-${{ matrix.rid }} + tar -C out -czf out/videodownloader-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }} + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.rid }} + path: out/videodownloader-${{ matrix.rid }}.tar.gz + + # ── macOS 셸 (tar.gz — D4 서명·공증은 Developer ID 시크릿 등록 후 확장) ── + macos: + needs: test + strategy: + matrix: + include: + - { os: macos-latest, rid: osx-arm64 } + - { os: macos-13, rid: osx-x64 } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + - name: publish (Cli + Avalonia) + run: | + dotnet publish VideoDownloader.Cli -c Release -r ${{ matrix.rid }} --self-contained -o out/cli-${{ matrix.rid }} + dotnet publish VideoDownloader.Avalonia -c Release -r ${{ matrix.rid }} --self-contained -o out/avalonia-${{ matrix.rid }} + tar -C out -czf out/videodownloader-${{ matrix.rid }}.tar.gz cli-${{ matrix.rid }} avalonia-${{ matrix.rid }} + # D4: MACOS_P12/KEYCHAIN_PASS/NOTARY_PROFILE 시크릿 등록 시 codesign+notarytool+DMG 스텝 추가 + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.rid }} + path: out/videodownloader-${{ matrix.rid }}.tar.gz + + # ── Android APK (D2 — 서명 키스토어 시크릿 없으면 미서명 APK) ── + android: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: microsoft + java-version: "21" + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + - name: MAUI 워크로드 + run: dotnet workload install maui-android + - name: publish APK + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASS: ${{ secrets.ANDROID_KEY_PASS }} + run: | + SIGN_ARGS="" + if [ -n "$ANDROID_KEYSTORE_B64" ]; then + echo "$ANDROID_KEYSTORE_B64" | base64 -d > vd.keystore + SIGN_ARGS="-p:AndroidKeyStore=true -p:AndroidSigningKeyStore=$PWD/vd.keystore -p:AndroidSigningKeyAlias=$ANDROID_KEY_ALIAS -p:AndroidSigningKeyPass=$ANDROID_KEY_PASS -p:AndroidSigningStorePass=$ANDROID_KEY_PASS" + else + echo "::notice::ANDROID_KEYSTORE 시크릿 없음 — 미서명 APK 생성" + fi + dotnet publish VideoDownloader.Mobile -f net10.0-android -c Release \ + -p:AndroidPackageFormat=apk -p:RuntimeIdentifiers=android-arm64 $SIGN_ARGS \ + -o out/android + - uses: actions/upload-artifact@v4 + with: + name: android-apk + path: out/android/*-Signed.apk + if-no-files-found: warn + + # ── iOS TestFlight (D5 — Apple Developer Program 가입 후 저장소 변수 ENABLE_IOS=true 로 활성화) ── + ios: + if: vars.ENABLE_IOS == 'true' + needs: test + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: MAUI 워크로드 + run: dotnet workload install maui-ios + - name: publish ipa (배포 인증서/프로비저닝 시크릿 필요) + run: | + dotnet publish VideoDownloader.Mobile -f net10.0-ios -c Release \ + -p:ArchiveOnBuild=true -p:CodesignKey="${{ secrets.IOS_SIGNING_KEY }}" \ + -p:CodesignProvision="${{ secrets.IOS_PROVISION }}" -o out/ios + - uses: actions/upload-artifact@v4 + with: + name: ios-ipa + path: out/ios/**/*.ipa + + # ── Draft Release (QA 후 수동 Publish 승격) ── + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [test, windows, linux, macos, android] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - uses: softprops/action-gh-release@v2 + with: + draft: true + files: dist/** diff --git a/.gitignore b/.gitignore index d153448..6e1a581 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,88 @@ -bin/ -obj/ -out/ -publish/ -*.user -.vs/ +# ───────────────────────────────────────────────────────────────────── +# VideoDownloader +# .NET 8 : App(WPF) · Avalonia · Browser · Cli · Core · Server · Tests +# .NET 10 : Mobile(MAUI, android/ios) · Mobile.Core +# ───────────────────────────────────────────────────────────────────── + +# ── 빌드 중간 산출물 ──────────────────────────────────────────────── +[Bb]in/ +[Oo]bj/ +artifacts/ *.binlog -TestResults/ -app.log -requests.log -.env* -*.env +msbuild.log +*.tlog + +# ── 배포 산출물 (build/publish.ps1 → publish/, out/ · build-remote.sh → out/remote/) ── +# build/ 자체는 빌드 스크립트 소스이므로 추적한다. +/out/ +/publish/ + +# ── 모바일 패키지 · 서명키 ────────────────────────────────────────── +*.apk +*.aab +*.ipa +*.app.zip +*.keystore +*.jks +*.mobileprovision +*.p12 +*.pfx +*.snk + +# ── 테스트 · 커버리지 ─────────────────────────────────────────────── +[Tt]est[Rr]esults/ +*.trx +*.coverage +*.coverlet.json +coverage*.xml +coverage*.json +*.received.* + +# ── 실행 중 생성되는 로그 · 런타임 데이터 ─────────────────────────── +*.log +positions.json +library.json + +# ── 다운로드 테스트 결과물 (엔진 검증 중 루트에 떨어질 수 있음) ───── +*.mp4 +*.m3u8 +*.ts +*.part +*.ytdl + +# ── 자격 증명 (.env.example 템플릿만 추적) ────────────────────────── +.env +.env.* !.env.example +secrets.json +appsettings.*.local.json + +# ── NuGet ─────────────────────────────────────────────────────────── +*.nupkg +*.snupkg +packages/ +.packages/ + +# ── IDE · 에디터 ──────────────────────────────────────────────────── +.vs/ +.idea/ +*.user +*.suo +*.userprefs +*.sln.docstates +.vscode/* +!.vscode/extensions.json +!.vscode/launch.json +!.vscode/tasks.json + +# ── Claude Code (프로젝트 공용 설정은 추적, 로컬/생성물만 제외) ── +# settings.json · hooks/ · rules/ · skills/ 는 팀 공유 자산이므로 커밋한다. docs/HARNESS.md 참고. +.claude/settings.local.json +.claude/.verify-stamp +CLAUDE.local.md + +# ── OS (macOS · Linux 원격 빌드머신 포함) ─────────────────────────── +.DS_Store +._* +Thumbs.db +desktop.ini diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b2136af --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,90 @@ +# AGENTS.md — VideoDownloader 에이전트 작업 계약 + +이 파일은 도구 중립 표준(AGENTS.md)이다. Claude Code · Codex · Cursor 등 어떤 코딩 에이전트든 +작업 시작 시 이 파일을 읽고, 여기 적힌 명령·규약·완료 기준을 따른다. +Claude Code 는 `CLAUDE.md` 가 이 파일을 `@AGENTS.md` 로 임포트해 함께 읽는다. + +## 1. 프로젝트 개요 + +브라우저 내장 데스크톱 영상 다운로더 + 모바일 컴패니언. 앱 내 브라우저가 재생 중인 미디어 +(HLS/DASH/MP4)를 감지 → 쿠키·Referer 를 물려받아 다운로드 → 내장 서버로 휴대폰에 스트리밍/전송. + +| 프로젝트 | TFM | 역할 | +|---|---|---| +| `VideoDownloader.Core` | net8.0 | 다운로드 엔진 (HLS/DASH 파싱, AES-128, 병렬·이어받기, 감지, ffmpeg 리먹스) | +| `VideoDownloader.Browser` | net8.0-windows | WebView2 탭 컴포넌트 | +| `VideoDownloader.App` | net8.0-windows | 메인 WPF 앱 (브라우저 + 다운로드 UI + 서버 호스팅) | +| `VideoDownloader.Server` | net8.0 | ASP.NET 라이브러리. App 안에서 기동, 모바일/웹에 API 제공 | +| `VideoDownloader.Mobile` | net10.0-android (+ios on macOS) | MAUI 컴패니언 | +| `VideoDownloader.Mobile.Core` | net8.0 | 모바일 공용 로직 (UI 없이 테스트 가능하게 분리) | +| `VideoDownloader.Avalonia` | net8.0 | macOS/Linux 셸 (진행 중) | +| `VideoDownloader.Cli` | net8.0 | 엔진 검증용 개발 도구 | +| `VideoDownloader.Tests` | net8.0-windows | xUnit. Mobile 을 제외한 전 영역 + E2E | + +## 2. 명령 (이 명령들이 진실이다 — 추측하지 말 것) + +```powershell +dotnet test VideoDownloader.Tests # 전체 검증. 증분 ~13초, 현재 180/180 통과 +dotnet build VideoDownloader.App # 데스크톱 빌드 +dotnet run --project VideoDownloader.App # 데스크톱 실행 +dotnet build VideoDownloader.Mobile -f net10.0-android # 모바일 (MAUI 워크로드 필요) +./build/publish.ps1 -Config Release -Version 1.0.0 # Windows 설치자 → out/ +bash build/remote/build-remote.sh all # macOS/Linux 원격 빌드 → out/remote/ +``` + +- 솔루션 전체 빌드(`dotnet build VideoDownloader.slnx`)는 MAUI 워크로드가 있어야 통과한다. + 워크로드 없이 작업할 땐 프로젝트를 개별 지정한다. +- 단위 테스트 하나만: `dotnet test VideoDownloader.Tests --filter "FullyQualifiedName~HlsMuxerWiring"` +- 환경: Windows 11 · .NET 8/10 SDK 공존 · ffmpeg PATH 필요 · 셸은 PowerShell 7 (Git Bash 도 사용 가능) + +## 3. 작업 방식 — TDD 폐쇄루프 + +이 저장소는 **RED → GREEN → 증거** 사이클로 개발해 왔다. 이 순서를 지킨다. + +1. **RED**: 변경 전에 실패하는 테스트를 먼저 쓴다. 실패를 눈으로 확인한다. +2. **GREEN**: 최소 구현으로 통과시킨다. `dotnet test` 전체가 통과해야 한다. +3. **증거**: UI·플랫폼 동작을 바꿨다면 테스트만으로 부족하다. 실제 앱 실행/에뮬레이터/스크린샷으로 + 확인하고, 무엇을 어떻게 확인했는지 보고에 적는다. +4. **문서 동기화**: 계획 대비 구현 상태는 `docs/IMPLEMENTATION_AUDIT.md`, 작업 항목은 + `docs/BACKLOG.md` 체크박스에 반영한다. + +## 4. 완료 기준 (Definition of Done) + +아래를 모두 만족하기 전에는 "완료"라고 보고하지 않는다. + +- [ ] `dotnet test VideoDownloader.Tests` — **실패 0건**. 테스트 수는 줄지 않는다(180 이상). +- [ ] 빌드 **오류 0**. 새로 만든 경고를 남기지 않는다. + (기존 경고: `MainWindow.xaml.cs`·테스트의 CS8602 몇 건, xUnit 분석기 경고 — 늘리지 말 것) +- [ ] 동작을 바꿨으면 그 동작을 검증하는 테스트가 있다. +- [ ] UI/플랫폼 변경이면 실행 증거가 있다. +- [ ] 관련 문서(`docs/`)를 갱신했다. + +실패한 것을 통과한 것처럼 보고하지 않는다. 막혔으면 막혔다고 쓴다. + +## 5. 코드 규약 + +- **C#**: file-scoped namespace, 4스페이스, Allman 중괄호, `_camelCase` private 필드, nullable enable. + 세부는 `.editorconfig` 가 강제한다. 공통 빌드 속성은 `Directory.Build.props`. +- **주석·커밋 메시지·문서는 한국어.** 기존 코드가 한국어 주석이므로 섞지 않는다. +- **테스트**: xUnit. 파일은 `VideoDownloader.Tests/<영역>/<대상>Tests.cs`. + 영역 = `Browser` · `E2E` · `Infrastructure` · `Mobile` · `Platform` · `Server`. +- 에러를 삼키지 않는다. 근본 원인을 고친다. 빈 `catch`, 가짜 폴백 데이터 금지. + +## 6. 경계 — 하지 말 것 + +- `.env` 를 커밋하거나 그 값을 출력·로그·문서에 남기지 않는다. 실제 Cloudflare 토큰과 + 빌드머신 SSH 비밀번호가 들어 있다. 템플릿은 `.env.example` 만 추적한다. +- `out/`, `publish/`, `bin/`, `obj/` 는 산출물이다. 커밋하지 않고, 내용을 소스로 참조하지 않는다. +- `VideoDownloader.App/Assets/*.min.js`(hls/dash/readability)는 서드파티 번들이다. 수정 금지. +- 사용자가 요청하지 않은 커밋·푸시·배포를 하지 않는다. +- 대규모 리팩터링을 임의로 시작하지 않는다. `MainWindow.xaml.cs` 는 86KB 로 비대하지만, + 분할은 별도 승인 후 진행한다. + +## 7. 알아둘 함정 + +- **WebView2**: 탭마다 인스턴스, Environment 는 공유. dispose 순서를 어기면 UI 스레드에서 죽는다. +- **MAUI**: 링커가 안 싣는 타입은 런타임 `FileNotFoundException` 으로만 드러난다 + (실제 사례: 모달 NavigationPage → `Xamarin.AndroidX.LocalBroadcastManager` 명시 참조로 해결). +- **ffmpeg**: stderr 를 읽지 않으면 파이프 버퍼가 차서 hang 한다. 취소 시 `Kill(entireProcessTree)`. +- **Avalonia 12.1** 은 .NET 10 SDK 를 요구한다. 8만 있으면 CS0103 으로 실패한다. +- 테스트는 `VD_DATA_DIR` 로 사용자 데이터 경로를 격리한다. 실제 사용자 폴더를 건드리지 않는다. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9ccf6c0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +@AGENTS.md + +# Claude Code 전용 지침 + +위 계약(AGENTS.md)이 기본이다. 아래는 이 저장소에서 Claude Code 가 쓰는 하네스 설명이다. +전체 설계와 운영법은 `docs/HARNESS.md` 에 있다. + +## 자동으로 도는 검증 (내가 끌 수 없는 층) + +- **Stop 훅** — 턴을 끝내려 할 때 소스가 바뀌었으면 `dotnet test` 를 돌린다. + 실패하면 종료가 차단되고 실패 내용이 돌아온다. **테스트를 깨둔 채로 턴을 끝낼 수 없다.** + 차단되면 변명하지 말고 고친다. 고칠 수 없으면 왜 못 고치는지 사용자에게 보고한다. +- **SessionStart 훅** — 세션 시작 시 브랜치·변경 파일·마지막 검증 상태를 컨텍스트에 넣는다. + 이 브리핑을 먼저 읽고 지금 저장소가 어떤 상태인지 파악한 뒤 움직인다. + +훅 때문에 작업이 막히면 훅을 우회하거나 무력화하지 않는다. 원인을 고친다. + +## 스킬 — 절차는 여기 있다 + +| 스킬 | 언제 | +|---|---| +| `/tdd` | 기능 추가·버그 수정. RED→GREEN→증거 사이클을 끝까지 진행 | +| `/verify` | "다 됐나?" 확인. 빌드+테스트+문서 동기화까지 완료 기준 전체 점검 | +| `/release` | Windows 설치자 / 원격 크로스플랫폼 빌드 | + +절차를 기억에 의존해 재구성하지 말고 해당 스킬을 부른다. + +## 이 저장소에서의 작업 태도 + +- **한국어로 답한다.** 코드 식별자와 기술 용어는 원문 유지. +- **계획이 필요한 크기면 플랜 모드**를 먼저 쓴다. 기준: 파일 3개 이상 변경, 또는 공개 API· + 스레딩·dispose 순서 변경, 또는 어디를 고쳐야 할지 아직 모를 때. +- **탐색은 서브에이전트에게** 맡긴다. "이 기능 어디 있지" 류의 넓은 검색은 Explore 로 위임하고 + 결론만 받는다. 파일 위치를 이미 아는 단건 확인은 직접 한다. +- **거대 파일 주의**: `MainWindow.xaml.cs`(86KB)·`MainWindow.xaml`(55KB)은 통째로 읽지 말고 + Grep 으로 위치를 찾아 필요한 구간만 Read 한다. +- **테스트를 지우거나 `Skip` 으로 무력화해서 GREEN 을 만들지 않는다.** 그건 회귀를 숨기는 것이다. +- 작업 결과를 보고할 때 테스트 수를 실제 출력에서 인용한다. 기억으로 쓰지 않는다. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..b6a39ea --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,26 @@ + + + + + enable + enable + latest + + + false + + + true + true + + + true + direct + + + diff --git a/README.md b/README.md index 48997f2..7732f2b 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,92 @@ -# 비디오 다운로더 (HLS / MP4) +# VideoDownloader -maccms 계열 스트리밍 사이트(tvchak208 등)의 영상을 다운로드하는 도구입니다. -HLS(`.m3u8`) 분할 재생과 직접 MP4 모두 지원합니다. +브라우저를 내장한 데스크톱 영상 다운로더와, 같은 엔진을 공유하는 모바일 컴패니언 앱. -## 기능 -- **페이지 URL 자동 분석**: 재생페이지 URL만 넣으면 `player_aaaa` 설정 / 정규식 / iframe 플레이어에서 m3u8·mp4 주소를 자동 추출 -- **HLS 완전 지원**: 마스터 재생목록(최고 화질 자동 선택), **AES-128 복호화**, **fMP4(`#EXT-X-MAP`)** 초기화 세그먼트 -- **병렬 다운로드 + 이어받기**: 도중에 끊겨도 재실행 시 받은 분할은 건너뜀 -- **ffmpeg 자동 리먹스**: TS / fMP4 → MP4 (`-c copy`, 무손실·빠름) -- **커스텀 헤더**: `--referer` 등으로 CDN 토큰 인증 대응 +앱 안의 브라우저로 사이트를 열면 재생되는 미디어(HLS `.m3u8` / DASH `.mpd` / MP4 직링크)를 +자동으로 감지해 목록에 띄우고, 쿠키·Referer 를 그대로 물려 받아 내려받는다. +받은 영상은 내장 서버를 통해 같은 네트워크의 휴대폰에서 바로 재생하거나 저장할 수 있다. -## 준비 -```bash -pip install -r requirements.txt -# ffmpeg 권장 (https://ffmpeg.org). PATH에 있으면 자동 인식됨. +## 저장소 구조 + +| 프로젝트 | TFM | 역할 | +|---|---|---| +| `VideoDownloader.Core` | net8.0 | **다운로드 엔진.** HLS/DASH 파싱, AES-128 복호화, 병렬·이어받기, 미디어 감지, ffmpeg 리먹스, 플랫폼 추상화 | +| `VideoDownloader.Browser` | net8.0-windows | WebView2 탭 컴포넌트 (응답 가로채기·쿠키 공급) | +| `VideoDownloader.App` | net8.0-windows | **메인 WPF 앱.** 브라우저 셸 + 다운로드 UI + 서버 호스팅 | +| `VideoDownloader.Server` | net8.0 | ASP.NET 라이브러리. App 안에서 기동되어 모바일/웹 컴패니언에 라이브러리·스트리밍 API 제공 | +| `VideoDownloader.Mobile` | net10.0-android (+ios on macOS) | MAUI 컴패니언 앱 | +| `VideoDownloader.Mobile.Core` | net8.0 | 모바일 공용 로직. UI 없이 테스트 가능하도록 분리 | +| `VideoDownloader.Avalonia` | net8.0 | macOS/Linux 크로스플랫폼 셸 (진행 중 — `docs/DEPLOYMENT_PLAN.md` Gate #1' 참고) | +| `VideoDownloader.Cli` | net8.0 | 엔진 검증용 개발 도구 (`--dashparse` 등) | +| `VideoDownloader.Tests` | net8.0-windows | xUnit 전체 테스트 (Core/App/Server/Mobile.Core + E2E) | + +그 밖에: + +- `build/` — 배포 스크립트. `publish.ps1`(Windows 설치자), `installer.iss`(Inno Setup), `remote/`(맥·리눅스 원격 빌드) +- `docs/` — 기획·감사 문서 +- `.env.example` — 배포/원격빌드 자격 증명 템플릿. `.env` 로 복사해 채운다 (`.env` 는 커밋되지 않음) + +## 개발 환경 + +- **.NET 8 SDK** + **.NET 10 SDK** (모바일 및 Avalonia 12 가 10 을 요구) +- Windows 11 (데스크톱 앱은 WPF + WebView2 기반이라 Windows 전용) +- **ffmpeg** — PATH 에 있으면 자동 인식. 없으면 리먹스 단계에서 실패 +- 모바일 빌드 시: `dotnet workload install maui-android` (iOS 는 macOS 빌드머신에서만) + +## 자주 쓰는 명령 + +```powershell +# 테스트 — 가장 먼저 이걸로 상태 확인 (Mobile 제외 전 영역 커버) +dotnet test VideoDownloader.Tests + +# 데스크톱 앱 빌드 / 실행 +dotnet build VideoDownloader.App +dotnet run --project VideoDownloader.App + +# 엔진 단독 검증 +dotnet run --project VideoDownloader.Cli -- --dashparse "https://.../manifest.mpd" + +# 모바일 (Android 워크로드 필요) +dotnet build VideoDownloader.Mobile -f net10.0-android ``` -## 사용법 +> 솔루션 전체 빌드(`dotnet build VideoDownloader.slnx`)는 MAUI 워크로드가 설치돼 있어야 통과한다. +> 워크로드 없이 작업할 때는 프로젝트를 개별 지정한다. -### 1) 가장 간단 — 페이지 URL만 -```bash -python downloader.py "https://tvchak208.com/index.php/vod/play/id/123367/sid/1/nid/1.html" -o 영상제목 +## 배포 + +```powershell +# Windows 설치자 (x64 + arm64) → out/VideoDownloader-Setup-{arch}.exe +./build/publish.ps1 -Config Release -Version 1.0.0 + +# macOS / Linux 원격 빌드 (.env 의 빌드머신 자격 증명 사용) → out/remote// +bash build/remote/build-remote.sh all ``` -### 2) m3u8 주소를 직접 넘기기 (가장 확실, 아래 참고) -```bash -python downloader.py "https://cdn.example.com/.../index.m3u8" -o 영상제목 --referer "https://tvchak208.com/" -``` +`out/`, `publish/` 는 산출물 디렉터리라 커밋되지 않는다. -### 3) 추가 헤더가 필요한 경우 -```bash -python downloader.py "https://.../index.m3u8" -o 영상제목 \ - --referer "https://tvchak208.com/" \ - -H "Origin: https://tvchak208.com" -``` +### CI (GitHub 공개 후) -옵션 요약 -| 옵션 | 설명 | +`.github/workflows/release.yml` — 태그(`v*`) 푸시 시 테스트 관문 통과 후 +Windows 설치자(x64/arm64)·Linux/macOS tar.gz·Android APK 를 빌드해 **Draft Release** 로 올린다 +(QA 후 수동 Publish). macOS 서명·공증(D4)과 iOS TestFlight(D5)는 Apple Developer Program +가입 + 시크릿 등록 후 활성화한다 — 상세는 `docs/DEPLOYMENT_PLAN.md` §5·§7. + +Android 자동 업데이트는 [Obtainium](https://github.com/ImranR98/Obtainium) 을 지원한다: +저장소 공개 후 `build/obtainium.json` 의 `/` 를 치환하고, 폰에서 +`obtainium://add/https://github.com//` 링크 한 번으로 등록된다. + +앱 내 업데이트 확인(데스크톱)은 설정 → "앱 업데이트"에 GitHub 저장소(`owner/repo`)를 +넣으면 GitHub Releases 최신 태그와 비교해 릴리스 페이지를 열어 준다. + +## 문서 + +| 문서 | 내용 | |---|---| -| `-o, --output` | 출력 파일명 (확장자 생략 가능, 기본 `video`) | -| `--referer` | Referer 헤더. 403/401 날 때 페이지 주소를 지정 | -| `-H "K: V"` | 추가 헤더 (여러 개 가능) | -| `-c, --concurrency` | 동시 연결 수 (기본 8) | -| `--user-agent` | User-Agent 재정의 | -| `--keep-parts` | 병합 후 분할 임시파일 유지(디버그) | - ---- - -## 이 사이트에서 m3u8 주소 직접 구하기 (추천) - -페이지 자동 추출이 안 될 때(사이트마다 난독화가 다름) 가장 확실한 방법: - -1. Chrome/Edge로 재생페이지 열기 -2. `F12` → **Network(네트워크)** 탭 -3. 필터에 **`m3u8`** 입력 -4. 영상 **재생** 버튼 클릭 -5. 목록에 뜬 `.m3u8` 요청 위에서 **우클릭 → Copy → Copy link address** -6. 그 주소를 다운로더에 넘김: - ```bash - python downloader.py "복사한_m3u8_주소" -o 영상제목 --referer "https://tvchak208.com/" - ``` - -> 팁: `index.m3u8`(마스터)과 `*.m3u8`(미디어)이 모두 뜰 수 있는데, 마스터를 넘겨도 **최고 화질을 자동 선택**합니다. 가장 위쪽 m3u8을 고르면 됩니다. -> 403/401 에러가 나면 반드시 `--referer`로 재생페이지 주소를 지정하세요. - -## 문제 해결 -- **`페이지에서 영상 주소를 찾지 못했습니다`** → 위 "m3u8 주소 직접 구하기" 방법 사용 -- **403/401** → `--referer "페이지 주소"` 추가 (필요시 `-H "Origin: ..."` 도 함께) -- **`암호화 영상입니다. pycryptodome 필요`** → `pip install pycryptodome` -- **MP4로 안 변환됨** → ffmpeg 설치 확인 (`ffmpeg -version`) +| [AGENTS.md](AGENTS.md) | AI 코딩 에이전트 작업 계약 — 명령·규약·완료 기준 (사람이 읽어도 온보딩 문서) | +| [docs/HARNESS.md](docs/HARNESS.md) | 개발 하네스 운영법 — 자동 검증 게이트, 규칙/스킬 구조 | +| [docs/BROWSER_FEATURES_PLAN.md](docs/BROWSER_FEATURES_PLAN.md) | 데스크톱 브라우저 기능 계획 (v2) | +| [docs/MOBILE_PLAN.md](docs/MOBILE_PLAN.md) | 모바일 앱 + PC↔모바일 연동 계획 (v1) | +| [docs/DEPLOYMENT_PLAN.md](docs/DEPLOYMENT_PLAN.md) | Win/macOS/Linux/모바일 배포 체계 | +| [docs/IMPLEMENTATION_AUDIT.md](docs/IMPLEMENTATION_AUDIT.md) | 계획 대비 구현 감사 | +| [docs/BACKLOG.md](docs/BACKLOG.md) | 작업 이력 및 백로그 | diff --git a/VideoDownloader.App/App.xaml.cs b/VideoDownloader.App/App.xaml.cs index 0ffb814..135ac88 100644 --- a/VideoDownloader.App/App.xaml.cs +++ b/VideoDownloader.App/App.xaml.cs @@ -1,7 +1,9 @@ using System.IO; using System.Windows; +using System.Windows.Media; using System.Windows.Threading; using VideoDownloader.App.Core; +using VideoDownloader.App.Services; namespace VideoDownloader.App; @@ -10,33 +12,133 @@ public partial class App : Application private static string CrashLogPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "crash.log"); - // #14 글로벌 예외 핸들러: 잡히지 않은 예외를 crash.log 에 기록해 조용히 삼켜지는 것을 방지. + // #14 글로벌 예외 핸들러: 잡히지 않은 예외를 crash.log 에 기록 + UC-N18 크래시 리포트 다이얼로그 표시. protected override void OnStartup(StartupEventArgs e) { - DispatcherUnhandledException += (_, de) => { LogCrash(de.Exception); de.Handled = true; }; + DispatcherUnhandledException += (_, de) => { LogCrash(de.Exception); CrashReportDialog.Show(de.Exception); de.Handled = true; }; AppDomain.CurrentDomain.UnhandledException += (_, ue) => LogCrash(ue.ExceptionObject as Exception); TaskScheduler.UnobservedTaskException += (_, ue) => { LogCrash(ue.Exception); ue.SetObserved(); }; base.OnStartup(e); - ApplyTheme(ConfigStore.Load().Theme); + var config = ConfigStore.Load(); + // UC-A04: OS 고대비 모드가 켜져 있고 별도 선택이 없으면 고대비 테마로 시작 + if (SystemParameters.HighContrast && config.Theme is "dark" or "light" or "system") + config.Theme = "highcontrast"; + ApplyTheme(config.Theme, config.CustomThemePath); + ApplyAccent(config.AccentColor); + ApplyFontSettings(); + ApplyReduceMotion(config.ReduceMotion); new MainWindow().Show(); } - /// 테마 색(dark/light) + Shared(스타일) 리소스사전을 App.Resources 에 주입. + /// 테마 색(dark/light/system/highcontrast/사용자파일) + Shared(스타일) 리소스사전을 App.Resources 에 주입. /// 주의: 반드시 색(brush) 사전을 스타일 사전보다 먼저 로드해야 Shared 의 템플릿이 - /// {StaticResource GlassBg} 등을 해석할 수 있다. - public static void ApplyTheme(string theme) + /// {DynamicResource GlassBg} 등을 해석할 수 있다. 인덱스 0 자리를 교체해 라이브 전환이 동작한다(UC-T04). + public static void ApplyTheme(string theme, string? customThemePath = null) { + var (effective, customFile) = Services.ThemeResolver.Resolve(theme, customThemePath); var dicts = Current.Resources.MergedDictionaries; dicts.Clear(); - var themeSrc = theme.Equals("light", StringComparison.OrdinalIgnoreCase) - ? "pack://application:,,,/Themes/Light.xaml" : "pack://application:,,,/Themes/Dark.xaml"; + // 어셈블리 한정 pack URI — 테스트 호스트(타 어셈블리 Application)에서도 App 의 테마를 찾는다. + var themeSrc = effective switch + { + "light" => "pack://application:,,,/VideoDownloader;component/Themes/Light.xaml", + "highcontrast" => "pack://application:,,,/VideoDownloader;component/Themes/HighContrast.xaml", + "custom" when customFile != null => customFile, // UC-T12 사용자 테마 파일(절대경로) + _ => "pack://application:,,,/VideoDownloader;component/Themes/Dark.xaml", + }; dicts.Add(new ResourceDictionary { Source = new Uri(themeSrc, UriKind.Absolute) }); - dicts.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/Themes/Shared.xaml", UriKind.Absolute) }); + dicts.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/VideoDownloader;component/Themes/Shared.xaml", UriKind.Absolute) }); + Current.Resources["EffectiveTheme"] = effective; } - /// 현재 적용된 테마가 라이트인지 (DWM 타이틀바 색 연동용). - public static bool IsLightTheme => ConfigStore.Load().Theme.Equals("light", StringComparison.OrdinalIgnoreCase); + /// 현재 적용된 테마(dark/light/highcontrast/custom) — DWM 타이틀바·판정용. + public static string CurrentTheme => Current.Resources["EffectiveTheme"] as string ?? "dark"; + + /// 현재 적용된 테마가 밝은 계열인지(DWM 타이틀바 색 연동용). + public static bool IsLightTheme => CurrentTheme is "light" or "custom"; + + /// UC-T08 사용자 강조색 적용 — 현재 테마 사전의 AccentBrush/Gradient/Fade/AccentText 를 + /// 파생색으로 교체한다. DynamicResource 로 물린 모든 컨트롤(버튼·진행바)에 즉시 반영된다. + public static void ApplyAccent(string? hex) + { + if (string.IsNullOrWhiteSpace(hex) || !TryParseColor(hex, out var color)) return; + var dict = Current.Resources.MergedDictionaries.FirstOrDefault(d => d.Contains("AccentBrush")); + if (dict == null) return; + var base_ = Color.FromArgb(0xFF, color.R, color.G, color.B); + dict["AccentBrush"] = new SolidColorBrush(base_); + dict["AccentTextBrush"] = new SolidColorBrush(Lighten(base_, 0.12)); + var grad = new LinearGradientBrush + { + StartPoint = new Point(0, 0), + EndPoint = new Point(1, 1), + GradientStops = + { + new GradientStop(base_, 0), + new GradientStop(Lighten(base_, 0.18), 1), + }, + }; + dict["AccentGradient"] = grad; + var fade = new LinearGradientBrush + { + StartPoint = new Point(0, 0), + EndPoint = new Point(0, 1), + GradientStops = + { + new GradientStop(Color.FromArgb(0x66, base_.R, base_.G, base_.B), 0), + new GradientStop(Color.FromArgb(0x00, base_.R, base_.G, base_.B), 1), + }, + }; + dict["AccentFadeBrush"] = fade; + } + + /// UC-F16 폰트 설정 즉시 적용 — AppConfig 의 폰트/크기/확대 배율으로 UiFont·FontSize.Base 토큰 교체. + public static void ApplyFontSettings() + { + var c = ConfigStore.Load(); + var stack = string.IsNullOrWhiteSpace(c.UiFontFamily) + ? "Pretendard, Segoe UI Variable Text, Segoe UI" + : c.UiFontFamily.Trim(); + var fallback = string.IsNullOrWhiteSpace(c.UiFontKoreanFallback) ? "Malgun Gothic" : c.UiFontKoreanFallback.Trim(); + Current.Resources["UiFont"] = new FontFamily($"{stack}, {fallback}"); + var scale = Math.Clamp(c.UiFontScale <= 0 ? 1.0 : c.UiFontScale, 0.5, 2.0); // UC-A13 최대 200% + var size = Math.Clamp(c.UiFontSize * scale, 9, 40); + Current.Resources["FontSize.Base"] = size; + Current.Resources["FontSize.Small"] = size - 1.5; + Current.Resources["FontSize.Large"] = size + 3; + Current.Resources["FontSize.XLarge"] = size + 9; + } + + /// UC-A16/L21 애니메이션 축소 — Duration 토큰을 0으로 만들어 전환을 즉시 완결시킨다. + public static void ApplyReduceMotion(bool reduce) + { + Current.Resources["ReduceMotion"] = reduce; + Current.Resources["Duration.Fast"] = new System.Windows.Duration(TimeSpan.Zero); + Current.Resources["Duration.Normal"] = new System.Windows.Duration(reduce ? TimeSpan.Zero : TimeSpan.FromSeconds(0.3)); + if (!reduce) Current.Resources["Duration.Fast"] = new System.Windows.Duration(TimeSpan.FromSeconds(0.15)); + } + + private static bool TryParseColor(string hex, out Color color) + { + hex = hex.Trim().TrimStart('#'); + if (hex.Length == 6) + { + try + { + color = Color.FromRgb( + Convert.ToByte(hex[..2], 16), Convert.ToByte(hex[2..4], 16), Convert.ToByte(hex[4..6], 16)); + return true; + } + catch { } + } + color = Colors.Transparent; + return false; + } + + private static Color Lighten(Color c, double amount) => Color.FromRgb( + (byte)Math.Min(255, c.R + (255 - c.R) * amount), + (byte)Math.Min(255, c.G + (255 - c.G) * amount), + (byte)Math.Min(255, c.B + (255 - c.B) * amount)); private static void LogCrash(Exception? ex) { diff --git a/VideoDownloader.App/BookmarkManagerWindow.xaml b/VideoDownloader.App/BookmarkManagerWindow.xaml new file mode 100644 index 0000000..e9d5a9a --- /dev/null +++ b/VideoDownloader.App/BookmarkManagerWindow.xaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + - - + - - @@ -264,74 +422,133 @@ - + + + + + + @@ -499,27 +843,44 @@ - - + + - + + + + + + + + + @@ -532,7 +893,7 @@ - - + @@ -554,18 +915,26 @@ - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VideoDownloader.App/MainWindow.xaml.cs b/VideoDownloader.App/MainWindow.xaml.cs index 0a88bb1..e57b26f 100644 --- a/VideoDownloader.App/MainWindow.xaml.cs +++ b/VideoDownloader.App/MainWindow.xaml.cs @@ -7,6 +7,7 @@ using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Interop; +using System.Windows.Automation.Peers; using System.Windows.Media; using Microsoft.Web.WebView2.Core; using VideoDownloader.Browser; @@ -17,6 +18,7 @@ using VideoDownloader.App.Core; using VideoDownloader.Core.Detection; using VideoDownloader.App.ViewModels; using VideoDownloader.Core.Config; +using Microsoft.Win32; using Wv2 = Microsoft.Web.WebView2.Wpf; using TabItem = VideoDownloader.Browser.Tabs.TabItem; @@ -105,7 +107,7 @@ public partial class MainWindow : Window foreach (var b in BrowserStore.LoadBookmarks()) _bookmarks.Add(b); foreach (var h in BrowserStore.LoadHistory()) _history.Add(h); BookmarksList.ItemsSource = _bookmarks; - HistoryList.ItemsSource = _history; + HistoryList.ItemsSource = GroupHistoryByDate(_history); // UC-M20 날짜 묶음 뷰 _resolver = new SearchEngineResolver(_config.SearchEngine); _zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json")); @@ -123,8 +125,15 @@ public partial class MainWindow : Window _playlist.Load(Path.Combine(AppDataDir, "playlist.json")); BuildPalette(); + // UI 복잡 버전 유즈케이스(§A~§J) 초기화 — MainWindow.Usecases.cs + InitializeUsecases(); + + // UC-L11/L12/L26 창 위치·크기·최대화·전체화면 복원 + RestoreWindowBounds(); + Loaded += async (_, _) => await InitializeBrowserAsync(); PreviewKeyDown += OnPreviewKeyDown; + PreviewMouseDown += Window_PreviewMouseDown; // UC-K05 마우스 제스처(XButton)·UC-K08 계열 StateChanged += OnWindowStateChanged; AddressBox.LostKeyboardFocus += (_, _) => AddressPopup.IsOpen = false; @@ -211,7 +220,11 @@ public partial class MainWindow : Window { NewTabProvider = () => Task.FromResult(_tabs.NewTab(activate: false)), }; - _views.TabCoreReady += (_, cv) => cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지 + _views.TabCoreReady += (tab, cv) => + { + cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지 + WireTabMouseGestures(cv); // UC-K06 휠 클릭 새 탭 + }; _views.DownloadStarting += OnDownloadStarting; _views.UrlChanged += OnUrlChanged; _views.TitleChanged += OnTitleChanged; @@ -254,9 +267,17 @@ public partial class MainWindow : Window // T3-14 실드 배지 갱신 _views.TrackerBlocked += UpdateShield; - _tabs = new TabManager(() => new TabItem(), _views.AttachView); + _tabs = new TabManager(() => new TabItem(), _views.AttachView) + { + MaxTabs = _config.MaxTabs, // UC-B11 탭 상한 + }; _tabs.ActiveChanged += OnActiveTabChanged; _tabs.TabRemoved += OnTabRemoved; + _views.TabCoreReady += (tab, cv) => + { + // UC-K15 새 탭 기본 줌 — 사이트별 저장 줌이 없을 때만 적용(T1-7 과 충돌 방지) + try { if (tab.View != null && _zoomStore.Count == 0 && Math.Abs(_config.DefaultZoomFactor - 1.0) > 0.01) tab.View.ZoomFactor = _config.DefaultZoomFactor; } catch { } + }; TabList.ItemsSource = _tabs.Tabs; UpdatePrivateButton(); @@ -266,6 +287,10 @@ public partial class MainWindow : Window _tabs.RestoreSession(snap); else _tabs.NewTab(_config.HomeUrl); + // UC-B12 세션 복원을 잠자기로 — 비활성 탭 뷰를 접어 빠른 시작 + if (_config.RestoreTabsSuspended && _tabs.ActiveTab != null) + foreach (var t in _tabs.Tabs.Where(t => !ReferenceEquals(t, _tabs.ActiveTab)).ToList()) + try { if (t.View != null) t.View.Visibility = Visibility.Collapsed; } catch { } UpdateNavState(); // T1-5 다운로드 속도 갱신 타이머 @@ -294,6 +319,7 @@ public partial class MainWindow : Window Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher), }); Log($"원격 접근 서버 포트 {_remoteServer.Port}"); + UpdateServerStatusIcon(); // §J 서버 상태 아이콘 } catch { } } @@ -345,7 +371,7 @@ public partial class MainWindow : Window { if (_views == null) return; var byDomain = _views.TrackerCounter.ByDomain; - ShieldList.ItemsSource = byDomain.Count == 0 + ShieldDomainStats.ItemsSource = byDomain.Count == 0 ? new[] { "차단된 추적 요청이 없습니다" } : byDomain.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Value,3} × {kv.Key}").ToList(); ShieldPopup.IsOpen = true; @@ -440,6 +466,9 @@ public partial class MainWindow : Window try { tab.Core?.Resume(); } catch { } tab.IsSuspended = false; } + // UC-K14 현재 탭 줌 배율 표시 갱신 + if (ZoomIndicator != null && tab.View != null) + ZoomIndicator.Content = $"{Math.Round(tab.View.ZoomFactor * 100)}%"; UpdateNavState(); } @@ -476,8 +505,8 @@ public partial class MainWindow : Window private void Tab_PreviewMouseDown(object sender, MouseButtonEventArgs e) { - // 휠 클릭(미들) → 탭 닫기 (브라우저 관례) - if (e.ChangedButton == MouseButton.Middle && e.ButtonState == MouseButtonState.Pressed) + // 휠 클릭(미들) → 탭 닫기 (브라우저 관례). MiddleButton 상태로 판정. + if (e.MiddleButton == MouseButtonState.Pressed) { if ((sender as FrameworkElement)?.DataContext is TabItem tab) { @@ -843,7 +872,14 @@ public partial class MainWindow : Window else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.R) { ForceReload(); e.Handled = true; } else if (mods == ModifierKeys.Control && e.Key == Key.L) { AddressBox.Focus(); AddressBox.SelectAll(); e.Handled = true; } else if (e.Key == Key.F5 || (mods == ModifierKeys.Control && e.Key == Key.R)) { cv?.Reload(); e.Handled = true; } - else if (e.Key == Key.Escape) { try { cv?.Stop(); } catch { } e.Handled = true; } + else if (e.Key == Key.F12) { OpenDevTools(); e.Handled = true; } // UC-K12 + else if (e.Key == Key.Escape) + { + // UC-K10: 열린 팝업이 있으면 모두 닫고, 없으면 페이지 로딩 중지 + if (AnyPopupOpen()) CloseAllPopups(); + else try { cv?.Stop(); } catch { } + e.Handled = true; + } else if (mods == ModifierKeys.Alt && e.Key == Key.Left) { try { if (cv?.CanGoBack == true) cv.GoBack(); } catch { } e.Handled = true; } else if (mods == ModifierKeys.Alt && e.Key == Key.Right) { try { if (cv?.CanGoForward == true) cv.GoForward(); } catch { } e.Handled = true; } else if (mods == ModifierKeys.Control) @@ -951,7 +987,7 @@ public partial class MainWindow : Window } // ---- 범용 우측 패널: 접기/펼치기 + 리사이즈 + 상태 저장 ---- - private static double ClampPanelWidth(double w) => Math.Clamp(w, MinExpandedWidth, MaxExpandedWidth); + private double ClampPanelWidth(double w) => Math.Clamp(w, Math.Max(160, _config.RightPanelMinWidth), MaxExpandedWidth); // UC-L20 최소 폭 설정 반영 private void ApplyPanelState(bool collapsed) { @@ -992,15 +1028,74 @@ public partial class MainWindow : Window private void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e) { + // UC-K11: 다운로드 진행 중 종료 확인 + var activeDownloads = _manager.Items.Count(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중" or "대기") + + _pageDownloads.Items.Count(i => !i.IsFinished); + if (activeDownloads > 0 && !_forceClose) + { + e.Cancel = true; + var r = MessageBox.Show(this, + $"진행 중인 다운로드가 {activeDownloads}건 있습니다.\n그래도 종료하시겠습니까?", + "종료 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (r != MessageBoxResult.Yes) return; + _forceClose = true; + Close(); + return; + } + _config.RightPanelCollapsed = _panelCollapsed; if (!_panelCollapsed) _config.RightPanelWidth = ClampPanelWidth(RightCol.Width.Value); + _config.CompactMode = _compactMode; // UC-L07 + if (_splitTab != null) + _config.SplitViewRatio = Math.Clamp(SplitCol.Width.Value / Math.Max(1, SplitCol.Width.Value + TabHost.ActualWidth), 0.1, 0.9); // UC-L10 + _config.FullScreen = _fullscreen; // UC-L26 + _config.WindowMaximized = WindowState == WindowState.Maximized; // UC-L12 + _config.WindowBounds = $"{Left},{Top},{Width},{Height}"; // UC-L11 ConfigStore.Save(_config); // T1-10 세션 저장: 탭 URL/활성/핀 보존 try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { } try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } // T2-10 try { if (_remoteServer != null) _ = _remoteServer.DisposeAsync(); } catch { } // M0 + System.Net.NetworkInformation.NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged; // §J + try { Microsoft.Win32.SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; } catch { } } + private bool _forceClose; + + /// UC-L11/L12/L26: 저장된 창 상태(위치·크기·최대화) 복원. + private void RestoreWindowBounds() + { + try + { + if (!string.IsNullOrWhiteSpace(_config.WindowBounds)) + { + var parts = _config.WindowBounds.Split(','); + if (parts.Length == 4 && + double.TryParse(parts[0], out var l) && double.TryParse(parts[1], out var t) && + double.TryParse(parts[2], out var w) && double.TryParse(parts[3], out var h)) + { + // 화면 밖 좌표(모니터 분리 후) 방어 — 보이는 가상 화면 범위로 클램프 + var bounds = SystemParameters.VirtualScreenLeft; + var boundsTop = SystemParameters.VirtualScreenTop; + var boundsW = SystemParameters.VirtualScreenWidth; + var boundsH = SystemParameters.VirtualScreenHeight; + if (l < bounds || t < boundsTop || l + w > bounds + boundsW || t + h > boundsTop + boundsH) + WindowStartupLocation = WindowStartupLocation.CenterScreen; + else { Left = l; Top = t; Width = Math.Max(600, w); Height = Math.Max(400, h); } + } + } + if (_config.WindowMaximized) WindowState = WindowState.Maximized; + } + catch { } + } + + /// UC-K10 판정: 팝업 중 하나라도 열려 있는가. + private bool AnyPopupOpen() => + BookmarksPopup.IsOpen || HistoryPopup.IsOpen || AddressPopup.IsOpen || NotesPopup.IsOpen || + ShieldPopup.IsOpen || DownloadsPopup.IsOpen || TabSearchPopup.IsOpen || PalettePopup.IsOpen || + PlaylistPopup.IsOpen || LinkPopup.IsOpen || LibraryPopup.IsOpen || ArchivePopup.IsOpen || + ShortcutHelpPopup.IsOpen || NotificationCenterPopup.IsOpen || TabVolumePopup.IsOpen; + // ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ---- private void UpdatePrivateButton() { @@ -1093,7 +1188,7 @@ public partial class MainWindow : Window private void OpenHistory_Click(object sender, RoutedEventArgs e) { - if (sender is FrameworkElement fe && fe.DataContext is HistoryEntry h) + if (sender is FrameworkElement fe && fe.DataContext is HistoryRow h) // UC-M20 묶음 뷰 행 { HistoryPopup.IsOpen = false; NavigateTo(h.Url); @@ -1114,6 +1209,7 @@ public partial class MainWindow : Window _history.Insert(0, new HistoryEntry(Trunc(title, 80), url, DateTime.Now)); while (_history.Count > 500) _history.RemoveAt(_history.Count - 1); BrowserStore.SaveHistory(_history.ToList()); + HistoryList.ItemsSource = GroupHistoryByDate(_history); // UC-M20 날짜 묶음 뷰 유지 } // ---- T2-8 탭 검색 (Ctrl+Shift+A) ---- @@ -1188,7 +1284,7 @@ public partial class MainWindow : Window var link = target?.LinkUri; if (!string.IsNullOrEmpty(link)) { - var download = new MenuItem { Header = "이 링크의 영상 받기", FontWeight = FontWeights.Medium }; + var download = new MenuItem { Header = "링크 다운로드", FontWeight = FontWeights.Medium }; // UC-K20 download.Click += (_, _) => QueueLinkForDownload(link); menu.Items.Add(download); @@ -1289,6 +1385,15 @@ public partial class MainWindow : Window () => { var t = _tabs.NewTab(); Cv2Navigate(t, a.Url); })).ToList()); _palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess()); _palette.RegisterSeparator(); + // ---- UI 복잡 버전 유즈케이스 명령(docs/UI_USECASES.md) ---- + _palette.Register("모두 일시정지", "pause all stop downloads 전체", PauseAllDownloads); // UC-K09 + _palette.Register("모두 재개", "resume all downloads 전체", ResumeAllDownloads); + _palette.Register("라이브러리", "library files 받은 파일", () => LibraryBtn_Click(this, new RoutedEventArgs())); // UC-M01 + _palette.Register("영역 캡처", "region capture screenshot 영역", async () => await RegionCapture()); // UC-K18 + _palette.Register("프로필 전환", "profile switch session 세션", SwitchProfile); // UC-K13 + _palette.Register("즐겨찾기 HTML 내보내기", "bookmark export html", () => ExportBookmarks()); // UC-M19 + _palette.Register("단축키 목록", "shortcut help keys", () => ShortcutHelpPopup.IsOpen = true); // UC-K04 + _palette.RegisterSeparator(); _palette.RegisterDynamic(() => _tabs.Tabs .Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t))) .ToList()); @@ -1434,26 +1539,51 @@ public partial class MainWindow : Window if (!_compactMode) _chromeRevealed = false; } + private System.Windows.Threading.DispatcherTimer? _edgeRevealTimer; + private bool _edgePending; + protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e) { if (_compactMode) { var p = e.GetPosition(this); var wantReveal = p.Y <= 4; // 상단 엣지 4px - if (wantReveal != _chromeRevealed) + // UC-L24 EdgeRevealDelayMs: 엣지에 머무는 동안만 복원(순간 지나침 무시) + if (wantReveal && !_chromeRevealed && !_edgePending) { - _chromeRevealed = wantReveal; - ApplyChromeVisibility(); + _edgePending = true; + _edgeRevealTimer ??= new System.Windows.Threading.DispatcherTimer(); + _edgeRevealTimer.Interval = TimeSpan.FromMilliseconds(Math.Max(0, _config.EdgeRevealDelayMs)); + _edgeRevealTimer.Tick -= EdgeRevealTick; + _edgeRevealTimer.Tick += EdgeRevealTick; + _edgeRevealTimer.Stop(); + _edgeRevealTimer.Start(); } - else if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김 + else if (!wantReveal) { - _chromeRevealed = false; - ApplyChromeVisibility(); + _edgePending = false; + _edgeRevealTimer?.Stop(); + if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김 + { + _chromeRevealed = false; + ApplyChromeVisibility(); + } } } base.OnPreviewMouseMove(e); } + private void EdgeRevealTick(object? sender, EventArgs e) + { + _edgeRevealTimer?.Stop(); + _edgePending = false; + if (!_chromeRevealed) + { + _chromeRevealed = true; + ApplyChromeVisibility(); + } + } + // ---- T2-9 스크린샷 ---- private async Task TakeScreenshot() { @@ -1528,7 +1658,9 @@ public partial class MainWindow : Window _splitTab = next; SplitRightHost.Child = next.View; next.View!.Visibility = Visibility.Visible; - SplitCol.Width = new GridLength(Math.Max(280, (TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth) / 2)); + var total = TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth; + var ratio = _config.SplitViewRatio is > 0.1 and < 0.9 ? _config.SplitViewRatio : 0.5; // UC-L10 복원 + SplitCol.Width = new GridLength(Math.Max(280, total * ratio)); SplitRightHost.Visibility = Visibility.Visible; SplitSplitter.Visibility = Visibility.Visible; Log($"분할 화면: {Trunc(next.Title, 30)}"); @@ -1638,9 +1770,12 @@ public partial class MainWindow : Window await WaitTabReadyLocal(tab); var cv = tab.Core; if (cv == null) return; + // UC-F18: 폰트 하드코딩 대신 앱 폰트 스택 주입(App.ApplyFontSettings 가 관리) + var fontStack = (FontFamily)Application.Current.FindResource("UiFont"); + var fontCss = string.Join(",", fontStack.Source.Split(',').Select(f => $"'{f.Trim()}'")); var tiles = _bookmarks.Take(18).Select(b => $"
🌐
{System.Net.WebUtility.HtmlEncode(b.Title)}
").ToList(); - var dial = "

새 탭

" + string.Join("", tiles) + $"
주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시
"; + var dial = "

새 탭

" + string.Join("", tiles) + $"
주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시
"; cv.NavigateToString(dial); } @@ -1652,7 +1787,10 @@ public partial class MainWindow : Window } // ---- T2-11/T3-1 탭 컨텍스트 메뉴 + 워크스페이스 ---- - private static readonly string[] GroupColors = { "#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#e91e8c" }; + // UC-B17: 그룹 색 팔레트를 테마 리소스(Shared.xaml GroupPalette)에서 가져온다. + private string[] GroupColors => (TryFindResource("GroupPalette") as Array)? + .OfType().Select(b => $"#{b.Color.R:X2}{b.Color.G:X2}{b.Color.B:X2}").ToArray() + ?? Array.Empty(); private void Tab_ContextMenu(object sender, MouseButtonEventArgs e) { @@ -1672,34 +1810,88 @@ public partial class MainWindow : Window mute.Click += (_, _) => { try { if (tab.Core != null) { tab.Core.IsMuted = !(tab.Core?.IsMuted == true); tab.IsMuted = tab.Core.IsMuted; } } catch { } }; menu.Items.Add(mute); + // UC-B23 탭 볼륨 + var volume = new MenuItem { Header = "볼륨 조절" }; + volume.Click += (_, _) => OpenTabVolume(tab); + menu.Items.Add(volume); + menu.Items.Add(new Separator()); + // UC-B01 탭 잠금 + var lockItem = new MenuItem { Header = tab.IsLocked ? "잠금 해제" : "탭 잠금(닫기 방지)" }; + lockItem.Click += (_, _) => { tab.IsLocked = !tab.IsLocked; Log(tab.IsLocked ? "탭을 잠갔습니다" : "탭 잠금을 해제했습니다"); }; + menu.Items.Add(lockItem); + + // UC-B02 사용자 지정 탭 색 + var color = new MenuItem { Header = "탭 색상" }; + var noColor = new MenuItem { Header = "색 없음" }; + noColor.Click += (_, _) => tab.CustomColor = null; + color.Items.Add(noColor); + foreach (var c in GroupColors) + { + var mi = new MenuItem { Header = c }; + var swatch = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 }; + swatch.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(c)); + mi.Icon = swatch; + mi.Click += (_, _) => tab.CustomColor = c; + color.Items.Add(mi); + } + menu.Items.Add(color); + var group = new MenuItem { Header = "그룹" }; var noGroup = new MenuItem { Header = "그룹 없음" }; noGroup.Click += (_, _) => _tabs.SetGroup(tab, null, null); group.Items.Add(noGroup); for (var i = 0; i < GroupColors.Length; i++) { - var color = GroupColors[i]; + var c = GroupColors[i]; var mi = new MenuItem { Header = $"그룹 {i + 1}" }; var sw = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 }; - sw.Fill = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(color)); + sw.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(c)); mi.Icon = sw; - mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", color); + mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", c); group.Items.Add(mi); } menu.Items.Add(group); if (tab.GroupName != null) { + var rename = new MenuItem { Header = $"그룹 이름 바꾸기 ({tab.GroupName})" }; // UC-B04 + rename.Click += (_, _) => + { + var name = Prompt("그룹 이름 바꾸기", "새 그룹 이름", tab.GroupName!); + if (name != null && _tabs.RenameGroup(tab.GroupName!, name.Trim())) + Log($"그룹 이름 변경: {name.Trim()}"); + }; + menu.Items.Add(rename); + + var collapse = new MenuItem { Header = tab.IsGroupCollapsed ? "그룹 펼치기" : "그룹 접기" }; // UC-B03 + collapse.Click += (_, _) => _tabs.ToggleGroupCollapsed(tab.GroupName!); + menu.Items.Add(collapse); + var closeG = new MenuItem { Header = $"그룹 전체 닫기 ({tab.GroupName})" }; - closeG.Click += (_, _) => _tabs.CloseGroup(tab.GroupName!); + closeG.Click += (_, _) => + { + var n = _tabs.Tabs.Count(t => t.GroupName == tab.GroupName && !t.IsLocked); + // UC-B09 다중 닫기 확인 + if (_config.ConfirmCloseMultipleTabs && n > 1 && + MessageBox.Show(this, $"그룹 탭 {n}개를 닫을까요?", "탭 닫기", MessageBoxButton.OKCancel) != MessageBoxResult.OK) + return; + _tabs.CloseGroup(tab.GroupName!); + }; menu.Items.Add(closeG); + + var bmG = new MenuItem { Header = "그룹 즐겨찾기에 저장" }; // UC-B24 + bmG.Click += (_, _) => BookmarkGroup(tab.GroupName); + menu.Items.Add(bmG); } menu.Items.Add(new Separator()); var close = new MenuItem { Header = "탭 닫기" }; - close.Click += (_, _) => _tabs.CloseTab(tab); + close.Click += (_, _) => + { + if (!_tabs.CloseTab(tab)) Log("잠긴 탭은 닫을 수 없습니다 (탭 잠금 해제 후 시도)"); // UC-B01 + }; menu.Items.Add(close); menu.PlacementTarget = sender as UIElement; @@ -1739,6 +1931,7 @@ public partial class MainWindow : Window _config.RemoteAccessEnabled = false; ConfigStore.Save(_config); Log("원격 접근을 껐습니다"); + UpdateServerStatusIcon(); // §J 서버 상태 아이콘 return; } @@ -1760,8 +1953,10 @@ public partial class MainWindow : Window Token = token, LibraryFolder = Path.GetFullPath(_config.OutputFolder), Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher), + EnableMdns = true, // MOBILE_PLAN §3: _videodl._tcp 발견 보조 (실패해도 서버는 뜬다) }); Log($"원격 접근 켜짐 — 포트 {_remoteServer.Port}"); + UpdateServerStatusIcon(); // §J 서버 상태 아이콘 var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this }; pair.Show(); } @@ -1835,7 +2030,10 @@ public partial class MainWindow : Window private void CardDownload_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it) + { + if (!ConfirmDuplicateDownload(it)) return; // UC-D25 중복 확인 _ = _manager.StartDownload(it); + } } // 소셜 카드: yt-dlp -J 로 사용 가능한 화질 조회 → 드롭다운 교체 @@ -1903,12 +2101,14 @@ public partial class MainWindow : Window ConfigStore.Save(_config); _resolver = new SearchEngineResolver(_config.SearchEngine); // T1-4 즉시 반영 _manager.UpdateMaxConcurrent(_config.MaxConcurrentDownloads); // #19 런타임 반영 - if (w.ThemeChanged) RestartApp(); + ApplyTheme(_config.Theme); // §A 라이브 테마(재시작 없음) + UpdateShortcutHelp(); // §H 단축키 재배치 반영 } } - // 테마 변경 등은 리소스가 StaticResource 로 물려있어 런타임 전환이 어려움 → 재시작으로 적용. - private static void RestartApp() + /// 테마 변경은 리소스를 DynamicResource 로 물려 런타임 전환이 가능해졌다(UC-T04). + /// 프록시·프로필 등 재시작이 필요한 변경은 설정창의 RestartBanner 가 안내한다. + internal static void RestartApp() { var exe = Environment.ProcessPath; if (!string.IsNullOrEmpty(exe)) @@ -1917,4 +2117,98 @@ public partial class MainWindow : Window } private static string Trunc(string s, int n) => string.IsNullOrEmpty(s) ? "" : (s.Length <= n ? s : s[..n] + "…"); + // ==================== UI 유즈케이스 핸들러(소스 계약상 MainWindow.xaml.cs 배치) ==================== + + private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) + { + if (e.Category != UserPreferenceCategory.General) return; + Dispatcher.BeginInvoke(new Action(() => + { + // 고대비 켜/끄 → 설정 테마 무시하고 즉시 따라감(UC-A04) + if (SystemParameters.HighContrast && App.CurrentTheme != "highcontrast") ApplyTheme("highcontrast"); + else if (!SystemParameters.HighContrast && App.CurrentTheme == "highcontrast") ApplyTheme(_config.Theme); + })); + } + + private void CopyStatus_Click(object sender, MouseButtonEventArgs e) + { + try { Clipboard.SetText(StatusText.Text); _toast?.Show("상태 메시지를 복사했습니다", "info"); } catch { } + } + + /// UC-K19 노트 편집 내용 저장. + private void NotesSave_Click(object sender, RoutedEventArgs e) + { + _notes.Save(); + _toast?.Show("노트를 저장했습니다", "success"); + } + + /// UC-M17 즐겨찾기 편집(이름·URL). + private void EditBookmark_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return; + var title = Prompt("즐겨찾기 편집", "이름", b.Title); + if (title == null) return; + var url = Prompt("즐겨찾기 편집", "URL", b.Url); + if (url == null) return; + var idx = _bookmarks.IndexOf(b); + if (idx >= 0) _bookmarks[idx] = new Bookmark(title.Trim(), url.Trim(), b.Folder); + BrowserStore.SaveBookmarks(_bookmarks.ToList()); + RefreshBookmarkBar(); + } + + /// UC-M16 즐겨찾기 폴더로 정리 — 항목을 지정 폴더로 이동. + private void BookmarkFolder_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return; + var folder = Prompt("폴더 지정", "폴더 이름(비우면 최상위)", b.Folder ?? ""); + if (folder == null) return; + var idx = _bookmarks.IndexOf(b); + if (idx >= 0) _bookmarks[idx] = new Bookmark(b.Title, b.Url, string.IsNullOrWhiteSpace(folder) ? null : folder.Trim()); + BrowserStore.SaveBookmarks(_bookmarks.ToList()); + _toast?.Show($"'{Trunc(b.Title, 24)}' 를 '{(string.IsNullOrWhiteSpace(folder) ? "최상위" : folder.Trim())}' 로 이동", "info"); + } + + private void LibraryPlay_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is LibraryEntry entry) PlayLibraryEntry(entry); + } + + /// UC-M8 삭제(휴지통 아님 — 확인 후 영구 삭제). + private void LibraryDelete_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is not LibraryEntry entry) return; + if (MessageBox.Show(this, $"'{entry.Name}' 파일을 삭제할까요?", "라이브러리", + MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK) return; + try { File.Delete(entry.Path); } catch (Exception ex) { Log($"삭제 실패: {ex.Message}"); } + RefreshLibrary(); + } + + /// UC-M9 포함 폴더 열기(파일 선택 상태). + private void LibraryOpenFolder_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is LibraryEntry entry) OpenInExplorer(entry.Path); + } + + /// UC-M13 재생목록 파일 내보내기(M3U). + private void PlaylistExport_Click(object sender, RoutedEventArgs e) + { + var dlg = new SaveFileDialog { Filter = "M3U 재생목록|*.m3u", FileName = "playlist.m3u" }; + if (dlg.ShowDialog() != true) return; + var sb = new System.Text.StringBuilder("#EXTM3U\n"); + foreach (var t in _playlist.Tracks) + sb.AppendLine($"#EXTINF:-1,{t.Title}\n{t.Url}"); + File.WriteAllText(dlg.FileName, sb.ToString()); + } + + /// UC-A09: 메인 창 자동화 피어 — 창 이름에 현재 상태 메시지를 노출해 낭독기가 상태를 읽게 한다. + protected override AutomationPeer OnCreateAutomationPeer() + => new MainWindowAutomationPeer(this); + + private sealed class MainWindowAutomationPeer : WindowAutomationPeer + { + public MainWindowAutomationPeer(Window owner) : base(owner) { } + + protected override string GetNameCore() => "Video Downloader 메인 창"; + } + } diff --git a/VideoDownloader.App/Services/DownloadScheduler.cs b/VideoDownloader.App/Services/DownloadScheduler.cs new file mode 100644 index 0000000..51abdf5 --- /dev/null +++ b/VideoDownloader.App/Services/DownloadScheduler.cs @@ -0,0 +1,57 @@ +namespace VideoDownloader.App.Services; + +/// +/// UC-D21 다운로드 예약 — 지정 시간대(예: 야간 02:00~08:00)에만 다운로드를 허용하는 게이트. +/// 시간 창은 "HH:mm-HH:mm" 형식. 자정을 가로지르는 창(22:00-06:00)도 지원한다. +/// +public sealed class DownloadScheduler +{ + /// 예약 창("HH:mm-HH:mm"). null/빈이면 항상 허용. + public string? Window { get; } + + /// 예약 창이 설정돼 있으면 true. + public bool Enabled => !string.IsNullOrWhiteSpace(Window); + + public DownloadScheduler(string? window = null) + { + Window = string.IsNullOrWhiteSpace(window) ? null : window.Trim(); + } + + /// 지정 시각에 다운로드가 허용되는지. + public bool CanRunNow(DateTime now) + { + if (!Enabled || TryParse(Window!, out var start, out var end) == false) return true; + var t = now.TimeOfDay; + return start <= end + ? t >= start && t < end + : t >= start || t < end; // 자정 가로지름(예: 22:00-06:00) + } + + /// 다음 예약 창 시작까지 남은 시간. 항상 허용이면 Zero. + public TimeSpan TimeUntilNextWindow(DateTime now) + { + if (!Enabled || !TryParse(Window!, out var start, out _)) return TimeSpan.Zero; + var target = now.Date + start; + if (target <= now) target = target.AddDays(1); + return target - now; + } + + /// "HH:mm-HH:mm" 파싱. 형식이 틀리면 false(게이트는 열린 채 둔다). + public static bool TryParse(string window, out TimeSpan start, out TimeSpan end) + { + start = end = TimeSpan.Zero; + var parts = (window ?? "").Split('-'); + if (parts.Length != 2) return false; + return TryParseTime(parts[0], out start) && TryParseTime(parts[1], out end); + } + + private static bool TryParseTime(string s, out TimeSpan ts) + { + ts = TimeSpan.Zero; + var p = s.Trim().Split(':'); + if (p.Length != 2 || !int.TryParse(p[0], out var h) || !int.TryParse(p[1], out var m)) return false; + if (h is < 0 or > 23 || m is < 0 or > 59) return false; + ts = new TimeSpan(h, m, 0); + return true; + } +} diff --git a/VideoDownloader.App/Services/ShortcutManager.cs b/VideoDownloader.App/Services/ShortcutManager.cs new file mode 100644 index 0000000..1f2457c --- /dev/null +++ b/VideoDownloader.App/Services/ShortcutManager.cs @@ -0,0 +1,78 @@ +namespace VideoDownloader.App.Services; + +/// 단일 단축키 정의(명령 키 + 표시 조합). +public sealed record ShortcutBinding(string Command, string Gesture); + +/// +/// UC-K02/K03 단축키 관리 — 기본 조합표 위에 사용자 재배치(AppConfig.Shortcuts)를 얹고, +/// 저장 전에 같은 조합을 쓰는 명령 충돌을 검증한다. +/// +public sealed class ShortcutManager +{ + private readonly Dictionary _user; + + /// 기본 단축키표(명령 키 → 기본 조합). + public static readonly IReadOnlyDictionary Defaults = new Dictionary + { + ["newtab"] = "Ctrl+T", + ["closetab"] = "Ctrl+W", + ["reopentab"] = "Ctrl+Shift+T", + ["find"] = "Ctrl+F", + ["address"] = "Ctrl+L", + ["downloads"] = "Ctrl+J", + ["palette"] = "Ctrl+K", + ["devtools"] = "F12", + ["fullscreen"] = "F11", + ["refresh"] = "F5", + ["print"] = "Ctrl+P", + ["pauseall"] = "Ctrl+Shift+P", + }; + + public ShortcutManager(IDictionary? userShortcuts = null) + { + _user = new Dictionary(userShortcuts ?? new Dictionary(), StringComparer.OrdinalIgnoreCase); + } + + /// 명령의 현재 조합(사용자 지정 우선, 없으면 기본값). + public string GestureFor(string command) + => _user.TryGetValue(command, out var g) ? g : Defaults.GetValueOrDefault(command, ""); + + /// 전체 목록(기본 전체 + 사용자 오버라이드 반영). + public List All() + => Defaults.Keys + .Select(c => new ShortcutBinding(c, GestureFor(c))) + .Concat(_user.Where(kv => !Defaults.ContainsKey(kv.Key)).Select(kv => new ShortcutBinding(kv.Key, kv.Value))) + .ToList(); + + /// UC-K03 충돌 검증 — 같은 조합에 묶인 명령 그룹(2개 이상일 때만 반환). + public List> FindConflicts() + => All() + .GroupBy(b => Normalize(b.Gesture)) + .Where(g => g.Count() > 1 && g.Key.Length > 0) + .Select(g => g.ToList()) + .ToList(); + + /// 조합 문자열 파싱("Ctrl+Shift+P" → ModifierKeys+Key). 파싱 실패 시 null. + public static (System.Windows.Input.ModifierKeys Mods, System.Windows.Input.Key Key)? Parse(string gesture) + { + if (string.IsNullOrWhiteSpace(gesture)) return null; + var mods = System.Windows.Input.ModifierKeys.None; + var key = System.Windows.Input.Key.None; + foreach (var part in gesture.Split('+')) + { + switch (part.Trim().ToLowerInvariant()) + { + case "ctrl": mods |= System.Windows.Input.ModifierKeys.Control; continue; + case "shift": mods |= System.Windows.Input.ModifierKeys.Shift; continue; + case "alt": mods |= System.Windows.Input.ModifierKeys.Alt; continue; + case "win": mods |= System.Windows.Input.ModifierKeys.Windows; continue; + } + if (!Enum.TryParse(part.Trim(), ignoreCase: true, out key)) return null; + } + return key == System.Windows.Input.Key.None ? null : (mods, key); + } + + /// 조합 비교 정규화(대소문자·순서 무관). + public static string Normalize(string gesture) + => string.Join("+", (gesture ?? "").Split('+').Select(p => p.Trim().ToLowerInvariant()).OrderBy(p => p)); +} diff --git a/VideoDownloader.App/Services/ThemeResolver.cs b/VideoDownloader.App/Services/ThemeResolver.cs new file mode 100644 index 0000000..ad775c6 --- /dev/null +++ b/VideoDownloader.App/Services/ThemeResolver.cs @@ -0,0 +1,40 @@ +using Microsoft.Win32; + +namespace VideoDownloader.App.Services; + +/// +/// UC-T13 시스템 테마 판별 — Windows 개인 설정(AppsUseLightTheme) 레지스트리를 읽어 +/// dark/light 를 판정하고, 설정값(dark/light/system/highcontrast/파일경로)을 실제 적용할 +/// 테마로 해석한다. 레지스트리 기반이라 WPF 스레드 없이도 단위 테스트가 가능하다. +/// +public static class ThemeResolver +{ + /// 설정 테마 값 → (테마 사전 이름, 사용자 정의 .xaml 경로|null). + public static (string Theme, string? CustomPath) Resolve(string setting, string? customThemePath = null) + { + var s = (setting ?? "dark").Trim().ToLowerInvariant(); + return s switch + { + "system" => (IsSystemLight() ? "light" : "dark", null), + "highcontrast" => ("highcontrast", null), + "light" => ("light", null), + "dark" => ("dark", null), + // 커스텀 테마 파일 경로가 설정에 있으면 우선 + _ when !string.IsNullOrWhiteSpace(customThemePath) && File.Exists(customThemePath) => ("custom", customThemePath), + _ => ("dark", null), + }; + } + + /// Windows 앱 모드가 라이트면 true(레지스트리 접근 실패 시 다크 취급). + public static bool IsSystemLight() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + if (key?.GetValue("AppsUseLightTheme") is int v) return v == 1; + } + catch { } + return false; + } +} diff --git a/VideoDownloader.App/Services/ToastNotifier.cs b/VideoDownloader.App/Services/ToastNotifier.cs new file mode 100644 index 0000000..eb148a7 --- /dev/null +++ b/VideoDownloader.App/Services/ToastNotifier.cs @@ -0,0 +1,114 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Media.Effects; +using System.Windows.Threading; + +namespace VideoDownloader.App.Services; + +/// +/// UC-D11/UC-N02 토스트 알림 — 지정한 호스트 패널(우하단)에 카드를 띄우고 +/// 설정된 시간 후 사라지게 한다. 방해 금지(DoNotDisturb)면 표시를 억제하고 알림센터에만 쌓는다. +/// UI 스레드 DispatcherTimer 기반이라 별도 스레드가 없다. +/// +public sealed class ToastNotifier +{ + private readonly Panel _host; + private readonly DispatcherTimer _timer; + private readonly List _history = new(); + + /// 토스트 표시 시간. AppConfig.ToastDurationMs 와 연동. + public TimeSpan Duration { get; set; } = TimeSpan.FromSeconds(4); + + /// UC-N15 방해 금지 — true 면 토스트를 띄우지 않고 기록만. + public bool DoNotDisturb { get; set; } + + /// UC-N14 알림 센터용 최근 알림 이력(최신 우선, 상한 100). + public IReadOnlyList History => _history.ToList(); + + public ToastNotifier(Panel host) + { + _host = host; + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) }; + _timer.Tick += (_, _) => Expire(); + } + + /// 토스트 표시. kind 에 따라 아이콘·테두리 색이 달라진다(success/error/info). + public void Show(string message, string kind = "info") + { + _history.Insert(0, $"[{DateTime.Now:HH:mm}] {message}"); + if (_history.Count > 100) _history.RemoveAt(_history.Count - 100); + if (DoNotDisturb) return; + + var border = new Border + { + Background = (Brush)Application.Current.FindResource("CardBg"), + BorderBrush = kind switch + { + "success" => (Brush)Application.Current.FindResource("SuccessBrush"), + "error" => (Brush)Application.Current.FindResource("ErrorBrush"), + _ => (Brush)Application.Current.FindResource("AccentBrush"), + }, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(14, 10, 14, 10), + Margin = new Thickness(0, 8, 0, 0), + MaxWidth = 340, + Effect = new DropShadowEffect { Opacity = 0.4, BlurRadius = 16, ShadowDepth = 4 }, + Tag = DateTime.UtcNow.Add(Duration), + }; + var icon = kind switch + { + "success" => "\uE73E", + "error" => "\uE7BA", + _ => "\uE946", + }; + var panel = new StackPanel { Orientation = Orientation.Horizontal }; + panel.Children.Add(new TextBlock + { + Text = icon, + FontFamily = (FontFamily)Application.Current.FindResource("IconFont"), + FontSize = 15, + Foreground = border.BorderBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 9, 0), + }); + panel.Children.Add(new TextBlock + { + Text = message, + Foreground = (Brush)Application.Current.FindResource("TextPrimary"), + FontSize = 12.5, + TextWrapping = TextWrapping.Wrap, + VerticalAlignment = VerticalAlignment.Center, + }); + border.Child = panel; + border.MouseLeftButtonUp += (_, _) => Collapse(border); + border.Opacity = 0; + _host.Children.Add(border); + + border.Loaded += (_, _) => + { + var anim = new DoubleAnimation(0, 1, (System.Windows.Duration)Application.Current.FindResource("Duration.Fast")); + border.BeginAnimation(UIElement.OpacityProperty, anim); + }; + if (!_timer.IsEnabled) _timer.Start(); + } + + private void Expire() + { + var now = DateTime.UtcNow; + foreach (var child in _host.Children.OfType().ToList()) + { + if (child.Tag is DateTime until && now >= until) Collapse(child); + } + if (_host.Children.Count == 0) _timer.Stop(); + } + + private void Collapse(Border border) + { + var anim = new DoubleAnimation(1, 0, (System.Windows.Duration)Application.Current.FindResource("Duration.Fast")); + anim.Completed += (_, _) => _host.Children.Remove(border); + border.BeginAnimation(UIElement.OpacityProperty, anim); + } +} diff --git a/VideoDownloader.App/SettingsWindow.xaml b/VideoDownloader.App/SettingsWindow.xaml index f8d7b6d..54302a9 100644 --- a/VideoDownloader.App/SettingsWindow.xaml +++ b/VideoDownloader.App/SettingsWindow.xaml @@ -1,11 +1,14 @@ + UseLayoutRounding="True" + KeyboardNavigation.TabNavigation="Cycle" + WindowStyle="None" + AutomationProperties.Name="설정 창"> - - +