diff --git a/.gitignore b/.gitignore index 586cd88..2e536d9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ TestResults/ # Node node_modules/ +build-tests/ *.tsbuildinfo # Local application data diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 35309d9..2c5e071 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,17 +21,17 @@ chrome.downloads.onCreated/onChanged ### Extension -Manifest V3 service worker이며 권한은 `downloads`, `nativeMessaging`뿐입니다. `onCreated`에서 referrer, 최초/최종 파일 URL을 별도 필드로 전달하고 `onChanged`에서 완료 또는 중단 상태를 전송합니다. 연결 오류는 다운로드를 취소하거나 변경하지 않습니다. +Manifest V3 service worker이며 권한은 `downloads`, `nativeMessaging`뿐입니다. `onCreated`에서 referrer, 최초/최종 파일 URL을 별도 필드로 전달하고, `onChanged`의 `filename` delta를 같은 Job의 메타데이터 갱신으로 전송합니다. `USER_CANCELED` error delta는 state delta를 기다리지 않고 즉시 idempotent `download.cancelled`로 전송합니다. interrupted는 `downloads.search({ id })` 후 delta error → item error → 마지막 error 순서로 원인을 결정합니다. `onErased`는 기록 삭제 진단일 뿐 취소가 아닙니다. 시작 재조정은 complete/cancelled/interrupted/in_progress/stale을 구분하고 item 누락을 취소로 추측하지 않습니다. 연결 로그에는 정제된 ID/state/error/전송 결과만 기록합니다. Chromium downloads API에는 신뢰할 수 있는 시작 탭 URL 필드가 없습니다. 따라서 활성 탭을 다운로드 출처로 추측하지 않고 `initiatingPageUrl`은 근거가 있을 때만 사용합니다. 현재 확장은 referrer를 우선 근거로 전달합니다. ### Native Host -브라우저가 실행하는 짧은 수명의 브리지입니다. 고정 개발 확장 origin, 프로토콜 버전, 비어 있지 않은 request ID, 명령 allowlist, 최대 1 MiB 메시지를 검사합니다. 셸 문자열을 실행하지 않으며 장기 상태나 UI가 없습니다. Agent가 없으면 같은 설치 디렉터리의 정확한 `DownloadRouter.Agent.exe`만 시작합니다. +브라우저가 실행하는 짧은 수명의 브리지입니다. 고정 개발 확장 origin, 프로토콜 버전, 비어 있지 않은 request ID, 명령 allowlist, 최대 1 MiB 메시지를 검사합니다. 임의 셸 문자열을 실행하지 않으며 장기 상태나 UI가 없습니다. Agent가 없으면 같은 설치 디렉터리의 정확한 `DownloadRouter.Agent.exe`만 숨김 프로세스로 분리 실행해 Native Messaging stdin/stdout/stderr를 상속하지 않게 합니다. ### Agent -현재 사용자 범위 mutex로 단일 인스턴스를 보장합니다. 여러 Native Host 연결을 비동기 Named Pipe 서버로 병합합니다. 명령 처리기는 DB 오류와 경계 오류를 안전한 오류 응답으로 바꾸며, 규칙이 없을 때 작업을 만들지 않습니다. +현재 사용자 범위 mutex로 단일 인스턴스를 보장합니다. 여러 Native Host 연결을 비동기 Named Pipe 서버로 병합합니다. 명령 처리기는 DB 오류와 경계 오류를 안전한 오류 응답으로 바꾸며, 규칙이 없을 때 작업을 만들지 않습니다. SelectSubfolder 작업 생성 시 설치 폴더 또는 개발 출력의 App만 실행 요청하며 실패해도 브라우저 다운로드와 원본 위치를 바꾸지 않습니다. ### Core @@ -40,7 +40,7 @@ Chromium downloads API에는 신뢰할 수 있는 시작 탭 URL 필드가 없 - IDN 호스트 정규화와 도메인 경계 비교 - 경로 토큰 해석과 루트 내부 검증 - URL 민감정보 제거 -- 명시적 작업 상태 머신 +- 브라우저 전송과 라우팅 결정을 분리한 명시적 상태 머신 ### Infrastructure @@ -56,42 +56,56 @@ SQLite 마이그레이션은 `schema_migrations`, `rules`, `download_jobs`, `job ### WinUI 3 App -Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. 현재 대시보드, 규칙, 이력, 규칙별 선택 대기 그룹, 브라우저 연결, 일반 설정, 진단, 정보 화면 골격이 있습니다. +Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. App은 사용자 범위 mutex로 단일 인스턴스를 유지하는 트레이 호스트이며 `--background`에서는 메인 창을 표시하지 않습니다. 500ms 간격으로 Pending을 읽되 자동 팝업 정책을 통과한 항목만 중복 없는 FIFO `FolderSelectionWindow`로 표시합니다. 선택창은 owner 없는 독립 top-level HWND입니다. 표시 때 선택창 HWND만 `SW_SHOWNORMAL`, XAML activate, topmost/bring-to-top, foreground 순으로 처리하고, Windows foreground 제한이 직접 호출을 거부하면 현재 foreground thread와 입력 큐를 잠시 연결해 선택창을 다시 활성화합니다. MainWindow에는 restore를 호출하지 않으며 FlashWindowEx는 모든 활성화 시도가 실패했을 때만 사용합니다. + +선택창 전면 진단은 MainWindow와 선택창의 HWND, visible, iconic, owner, foreground 및 Show/Bring/SetForeground 결과만 기록합니다. 다운로드 URL, query, token, 전체 로컬 경로는 기록하지 않습니다. + +공통 `FolderTreePicker`는 루트 하나만 먼저 만들고 노드 확장 시 해당 단계의 자식만 비동기로 읽습니다. 로드된 노드를 중복 조회하지 않고, 접근 불가 항목은 노드 단위 오류로 제한하며 reparse point는 선택 경계에서 제외합니다. 팝업, 선택 대기, 이력 경로 변경이 같은 컴포넌트와 Agent 명령을 사용합니다. 규칙의 저장 루트는 경계가 아직 정해지지 않은 선택이므로 HWND로 초기화한 Windows `FolderPicker`를 사용합니다. + +앱은 unpackaged WinUI 3 프로세스를 manifest에서 Per-Monitor V2로 선언합니다. 크기와 여백은 장치 독립 픽셀(DIP)을 사용하고 루트에서 layout rounding을 적용합니다. 모든 화면은 하나의 `NavigationView -> vertical ScrollViewer -> stretch viewport -> MaxWidth 1100 form` 구조를 공유합니다. 실제 폼 폭은 `ViewportWidth - 좌우 Padding`과 1100 DIP 중 작은 값이며 가로 스크롤을 만들지 않습니다. Compact는 `16,32,16,24`, Wide는 `32,48,32,32` DIP 패딩을 사용합니다. ## 작업 상태 ```text -WaitingForDownload -WaitingForSelection -ReadyToMove -Moving -Completed -RetryPending -Interrupted -Cancelled -Failed +BrowserTransferState RoutingState +InProgress WaitingForSelection +Complete SelectionReady +Cancelled Moving +Interrupted RetryPending + Completed + Skipped + Failed + NotRequired ``` -상태 전이는 `DownloadJobStateMachine`이 검사합니다. 완료 전에 사용자가 선택하면 `WaitingForDownload`, 완료 후 선택하면 `ReadyToMove`를 거쳐 동일한 이동 경로를 사용합니다. +두 축의 상태 전이는 `DownloadJobStateMachine`이 각각 검사합니다. 완료 전에 선택하면 `InProgress / SelectionReady`만 저장하고 파일을 이동하지 않습니다. `Complete / SelectionReady`가 된 뒤에만 Moving으로 전이합니다. 사용자 취소는 BrowserTransferState를 `Cancelled`로 바꾸고 WaitingForSelection/SelectionReady는 `NotRequired`로 종료합니다. Skipped/Failed 같은 이미 확정된 진단 라우팅은 보존할 수 있으며, 표시·Pending·이동 가능 여부는 항상 BrowserTransferState를 우선합니다. 다른 중단은 일반적으로 `Interrupted / Failed`입니다. 기존 단일 `Status` 열은 마이그레이션과 호환 표시용 파생 값으로 유지합니다. ## 저장 위치 선택 경계 저장 루트를 정규화한 뒤 상대 경로를 결합하고 다시 루트 내부인지 확인합니다. `..`, 루트 경로 자체 변경, reparse point 통과를 거부합니다. App은 reparse point 디렉터리를 열거하지 않습니다. 보안 결정은 UI가 아니라 Agent에서도 다시 검증합니다. +## 팝업 수명과 테마 + +자동 선택 팝업은 `SelectionPromptPolicy.AutoPromptWindow`의 30분 안에 생성되었거나 브라우저 이벤트가 갱신된 SelectSubfolder 대기 Job만 대상으로 합니다. 오래되거나 브라우저 record가 stale인 Job은 DB/대기 탭/InfoBadge에 남고 자동 큐에는 들어가지 않습니다. `모두 나중에 선택`은 메모리의 현재 큐 ID만 세션 숨김 집합으로 옮기므로 RoutingState와 파일은 바뀌지 않고 이후 새 ID는 다시 큐에 들어옵니다. + +`ThemeManager`는 App 시작 때 `config.local.json`의 System/Light/Dark를 정규화하고 모든 Window 루트 FrameworkElement를 등록합니다. 설정 변경은 등록된 열린 Window 전체에 적용되고 새 FolderSelectionWindow는 Content 지정 직후 등록됩니다. System은 `ElementTheme.Default`입니다. UI 설정 저장은 SQLite 이력 스키마와 분리합니다. + ## 배포 - 앱, Agent, Native Host: Windows x64 self-contained publish - 확장: `npm ci` 후 TypeScript compile, manifest 복사, ZIP 생성 - Native Host: HKCU 브라우저별 registry adapter와 `%LOCALAPPDATA%` manifest - Installer: per-user, `PrivilegesRequired=lowest` +- 버전: `Directory.Build.props` VersionPrefix를 App/Agent/Native Host assembly와 Installer AppVersion의 단일 원본으로 사용 +- 자동 시작: `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`에 설치 App의 정확한 따옴표 경로와 `--background` +- X 버튼: 기본은 AppWindow 숨김, 트레이 `종료` 또는 `--shutdown`만 App/Agent 정상 종료 +- 제거: 설치 파일·자동 시작·Native Host만 제거하고 사용자 DB와 규칙은 보존 설치 페이로드와 PC별 manifest는 커밋하지 않습니다. ## 후속 설계 항목 -- Agent가 선택 대기 알림/창을 활성화하는 방식 -- 새 폴더 만들기와 새로 고침을 포함한 전용 트리 선택기 -- Windows 시작 시 실행 설정 저장 및 등록 +- 세션을 넘는 “나중에 선택” 알림 정책과 새 폴더 만들기 - 중단된 작업의 시작 시 복구 워커 -- 브라우저 adapter의 실제 레지스트리/실행 경로 검증 -- 설치 서명과 업데이트 전략 +- Whale 이외 브라우저 adapter의 실제 레지스트리/실행 경로 검증 +- 설치 코드 서명과 업데이트 채널 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b06362..c1fbd9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,23 +6,90 @@ ### Added +- 선택창 전면 표시 전후의 MainWindow/FolderSelectionWindow HWND, visible, iconic, owner, foreground와 native 호출 결과를 남기는 경로·URL 비포함 진단 +- 숨김/최소화 MainWindow, iconic 선택창 복원, foreground fallback, FIFO 취소 제거를 검증하는 Core 회귀 테스트 +- 30분 자동 팝업 정책, 이전 세션 대기 작업 안내와 선택 창의 `모두 나중에 선택` 큐 일괄 숨김 +- Extension `download.cancelled`/`download.interrupted`, 시작 시 complete/interrupted/in_progress/stale 재조정과 정제된 연결 진단 +- 전역 `ThemeManager`, System/Light/Dark 사용자 설정 저장과 열린/새 Window 즉시 적용 +- 정보 화면 제품명·0.3.1 버전·commit·설치형/개발 빌드 표시, 데이터 폴더/GitHub 열기 +- `Directory.Build.props` 단일 버전 원본과 App/Agent/Native Host/Installer 일치 검증 + - .NET 10/WinUI 3 모노레포와 재현 가능한 bootstrap/build/test/publish 스크립트 - Manifest V3 TypeScript 확장, Native Messaging 브리지, Named Pipe Agent - SQLite 스키마와 마이그레이션, 규칙/작업/이벤트 저장 - 도메인·정확한 호스트·URL 포함 규칙과 출처 대상 선택 - 경로 토큰, 루트 경계와 reparse point 방어 - 동일/교차 볼륨 안전 이동, 안정화 확인, SHA-256 검증, 중복 이름 보존 -- WinUI 대시보드, 규칙, 이력, 선택 대기 그룹, 브라우저, 진단 화면 -- 27개 .NET 테스트, 4개 TypeScript 테스트, Agent/Native Host 스모크 테스트 +- WinUI 대시보드, 규칙, 파일별 선택 대기, 상태 필터·삭제 이력, 브라우저, 진단 화면 +- 73개 .NET 테스트, 13개 TypeScript 테스트, Agent/Native Host 스모크 테스트 - per-user Native Host 등록 및 Inno Setup 설치 골격 +- Native Host 등록의 Windows PowerShell 5.1 회귀 테스트와 개인정보 로그 회귀 테스트 +- 매칭 규칙의 작업 생성·실제 파일 이동을 검증하는 Agent 통합 테스트 +- BrowserTransferState/RoutingState 분리와 기존 DB v2 마이그레이션 +- 단일 FIFO 선택 팝업, App 자동 시작 요청, 파일별/선택 항목 폴더 적용과 이동 건너뛰기 +- Extension 시작 시 진행 중 브라우저 다운로드 상태 재조정 +- 계층형 lazy `FolderTreePicker`와 고정 크기 독립 `FolderSelectionWindow` +- 이력의 Job별 경로 선택·변경, 완료 파일 확인 후 재이동, 현재 필터 전체 선택 +- NavigationView Pending `InfoBadge`와 공통 대시보드 집계 +- 규칙 편집·활성 토글·soft delete·저장 루트 Windows FolderPicker +- 단일 인스턴스 트레이 호스트, `--background`/`--shutdown`, HKCU 로그인 자동 시작 +- per-user Inno Setup 설치/업그레이드/제거 및 설치 파일 빌드 스크립트 + +### Changed + +- 취소 상태는 선택 여부와 관계없이 `Cancelled`가 되고 Waiting/SelectionReady 라우팅은 `NotRequired`로 종료 +- UI 상태 갱신 주기를 500ms로 줄여 취소 후 팝업·Pending·이력을 1초 이내 반영 +- 브라우저 기록에서 찾지 못한 진행 작업은 취소로 추측하지 않고 stale 진단 상태로 보존하며 자동 팝업에서 제외 +- service worker 시작 재조정은 Job 상태만 맞추고 생성/마지막 실시간 브라우저 이벤트 기반의 30분 팝업 연령은 유지 + +- WinUI 앱을 Per-Monitor V2로 선언하고 공통 콘텐츠를 세로 ScrollViewer, stretch viewport, 1100 DIP 반응형 폼으로 재구성 +- 다운로드 이력이 규칙에 매칭된 작업만 표시함을 명시하고 데이터 변경 시 3초 간격으로 자동 갱신 +- Whale 설치 탐지에 Program Files와 Program Files (x86) 후보를 추가 +- 대시보드가 다운로드 중, 선택 대기, 완료, 취소/중단, 재시도/실패를 별도로 집계 +- 취소 이력을 자동 삭제하지 않고 취소선과 낮은 opacity로 유지하며 DB 이력만 사용자 삭제 +- 취소 시 당시 RoutingState를 보존하되 표시, Pending과 이동 가능 여부는 BrowserTransferState를 우선 +- 사이트 규칙 화면을 규칙 편집 Card와 저장된 규칙 Card 섹션으로 구분 + +### Fixed + +- MainWindow가 최소화된 상태에서 선택창이 foreground를 얻지 못하고 작업표시줄 강조로만 끝나던 문제 +- foreground 제한 시 바로 Flash fallback으로 종료하던 순서를 선택창 HWND의 normal 표시, 활성화, topmost, 입력 스레드 연결 재시도로 보강 +- 앱 시작 때 오래된 `WaitingForSelection` 전체를 FIFO에 재삽입해 20개 이상 팝업이 연속 표시되던 문제 +- service worker 시작 검색 결과가 오래된 Job의 마지막 브라우저 이벤트 시각을 현재로 덮어 다시 자동 팝업 대상으로 만들던 문제 +- Whale가 `state` delta 없이 `error.current=USER_CANCELED`만 보낼 때 Extension이 조기 반환해 취소가 누락되던 문제 +- `downloads.onErased`와 실제 사용자 취소를 혼동할 수 있는 시작 재조정 공백 +- 일반 설정의 테마 ComboBox가 저장·적용 로직에 연결되지 않아 화면이 바뀌지 않던 문제 +- 다크 테마에서 코드로 만든 카드와 기본 창 제목 표시줄이 라이트 리소스를 사용하던 대비 문제 +- 정보 화면에 실행 버전이 없고 설치 프로그램 버전만 별도 하드코딩되어 구성 요소가 불일치할 수 있던 문제 + +- DPI awareness 누락으로 QHD 125%에서 앱 전체가 96 DPI 비트맵으로 확대되던 흐릿한 렌더링 +- NavigationView 콘텐츠 폭/중복 패딩과 고정 MinWidth 때문에 좁은 창에서 오른쪽이 잘리던 레이아웃 +- PowerShell 5.1에 없는 경로 API와 RegistryKey 직접 쓰기로 실패하던 HKCU Native Host 등록 +- PowerShell 5.1이 Native Host manifest에 UTF-8 BOM을 붙이던 문제 +- Extension이 Native Messaging 실패를 아무 진단 없이 무시하던 문제 +- Native Host가 자동 시작한 Agent에 브라우저 표준 스트림 핸들을 상속하던 문제 +- `WaitingForSelection` 하나로 전송 중/완료/취소를 동시에 표현해 취소 항목이 Pending에 남던 문제 +- ScrollViewer가 좁은 창에서도 1100 DIP 폼을 측정해 우측 콘텐츠가 창 밖으로 나가던 문제 +- 모든 페이지 제목이 TitleBar 바로 아래에 붙어 보이던 공통 상단 여백 +- 다운로드 완료로 파일명이 바뀐 동안 열린 선택 팝업이 이전 이름과 상태를 유지하던 문제 +- `download`, `*.crdownload` 등 임시 이름을 확정 이름처럼 표시하던 문제와 같은 다운로드의 중복 Job 가능성 +- 긴 트리 노드가 선택 창 DesiredSize와 폭을 계속 키우던 문제 +- 최소화·트레이 상태에서 ContentDialog가 사용자 화면에 직접 나타나지 않던 문제 +- RoutingState에 따라 취소선이 누락되던 문제 +- 선택 대기 수가 내비게이션에서 보이지 않던 문제 +- 이력의 3상태 전체 선택이 한 번의 클릭으로 해제되지 않던 문제 +- `dotnet publish` 결과에서 unpackaged WinUI XBF/PRI가 빠져 설치 App이 시작하지 못하던 문제 ### Security - 명령 allowlist, 1 MiB 메시지 제한, current-user IPC, URL 민감정보 정제 - native SQLite bundle을 advisory 경고가 없는 고정 버전으로 명시 +- Extension 오류 로그를 bounded error code로 제한하고 파일 로그에서 URL, 로컬 경로, 토큰, 예외 메시지를 제거 ### Known limitations -- 실제 브라우저 검증 전 -- 선택 대기 자동 알림/팝업, 새 폴더 생성/새로 고침 미구현 -- 시작 시 실행 토글과 미완료 작업 자동 복구 미연결 +- Whale Automatic/SelectSubfolder는 실제 이동 검증 완료, 다른 Chromium 브라우저는 검증 전 +- Windows 100%/150% 및 FHD/4K 실제 디스플레이 시각 검증 전 +- 새 폴더 생성과 세션 간 “나중에 선택” 알림 미구현 +- 실제 로그아웃/로그인 자동 시작과 미완료 작업 자동 복구 미검증 +- 설치 파일 코드 서명 미구현 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 29e981e..5996eae 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -30,6 +30,15 @@ bootstrap은 버전 검사, NuGet restore, `npm ci`, 로컬 데이터 디렉터 - 새 패키지는 중앙 버전 파일 또는 잠금 파일에 정확한 버전을 기록합니다. - 사용자명, 회사 경로, 브라우저 프로필, PC별 Native Host manifest를 커밋하지 않습니다. +## WinUI와 DPI + +- unpackaged App manifest의 `PerMonitorV2, PerMonitor` 선언을 유지합니다. 제거하면 Windows가 앱을 96 DPI 비트맵으로 확대할 수 있습니다. +- XAML 크기와 간격은 DIP 기준이며 물리 픽셀이나 모니터 해상도별 고정 폭을 사용하지 않습니다. +- 새 화면은 공통 `ContentPanel` 안에서 가로 stretch하고, 폼 전체는 `min(1100 DIP, ScrollViewer viewport - padding)`을 넘지 않습니다. +- Compact/Wide의 공통 상단 여백 32/48 DIP를 화면별 Spacer로 중복 구현하지 않습니다. +- 좁은 창은 가로 스크롤 대신 세로 스크롤을 사용하고, 최소 폭을 지정할 때는 실제 720 DIP 창에서 우측 경계를 확인합니다. +- 시각 검증 시 `GetProcessDpiAwareness`, `GetWindowDpiAwarenessContext`, `GetDpiForWindow`와 진단 화면의 `XamlRoot.RasterizationScale`을 함께 확인합니다. + ## 반복 작업 ```powershell @@ -42,11 +51,37 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build.ps1 -Configu # Agent 파이프 왕복 확인 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 -Configuration Debug +# SelectSubfolder 완료 전 선택/취소용 로컬 지연 다운로드 +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\manual-download-server.ps1 ` + -Root .\artifacts\manual-whale\server -Port 8765 + # 설치 페이로드 준비 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\publish.ps1 -Configuration Release + +# 사용자 단위 Inno Setup 설치 파일 +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps1 ``` -Inno Setup으로 `installer\DownloadRouter.iss`를 컴파일하기 전에 `artifacts\publish\app`을 검토하고 서명 설정을 결정합니다. +`build-installer.ps1`은 publish 결과에 `App.xbf`, `MainWindow.xbf`, `DownloadRouter.App.pri`가 있는지 먼저 검사합니다. unpackaged WinUI 3 게시에서 이 세 파일이 빠지면 설치본이 `XamlParseException`으로 시작하지 못하므로 해당 검사를 제거하지 않습니다. 설치 파일은 아직 코드 서명되지 않았습니다. + +제품 버전은 `Directory.Build.props`의 `VersionPrefix` 한 곳에서만 변경합니다. build/publish는 가능한 경우 Git HEAD를 `SourceRevisionId`로 전달하고 정보 화면은 informational version을 읽습니다. `build-installer.ps1`은 App/Agent/Native Host ProductVersion이 VersionPrefix와 일치하지 않으면 Inno Setup 실행 전에 실패합니다. `DownloadRouter.iss`에 버전을 직접 하드코딩하지 마세요. + +## 트레이, 자동 시작, 선택 창 + +- App은 단일 인스턴스이며 `--background`에서 메인 창을 숨긴 채 트레이와 Agent만 준비합니다. +- 로그인 자동 시작은 관리자 권한이 필요 없는 HKCU Run을 사용합니다. 값은 반드시 따옴표로 감싼 절대 App 경로와 `--background`여야 합니다. +- 기본 X 동작은 메인 AppWindow 숨김입니다. `종료` 명령과 설치 업그레이드의 `--shutdown`만 완전 종료를 요청합니다. +- 폴더 선택은 메인 창의 ContentDialog가 아니라 별도 Window입니다. 창 크기는 DIP를 실제 모니터 DPI로 변환한 뒤 작업 영역 안으로 제한합니다. +- 선택창은 MainWindow owner를 설정하지 않는 독립 HWND입니다. 최소화 회귀를 막기 위해 MainWindow가 아니라 선택창 HWND에만 normal 표시/activate/foreground를 적용하고, foreground 제한 시 입력 스레드 연결 재시도 뒤에만 Flash fallback을 사용합니다. +- 선택창 전면 표시 코드를 바꿀 때는 MainWindow hidden/minimized, selection iconic, 팝업 종료 뒤 MainWindow 상태, FIFO 중복과 취소 제거 회귀 테스트를 함께 실행합니다. +- 트리는 전체 재귀 열거를 금지하고 확장한 노드의 직계 자식만 비동기로 읽습니다. UI와 Agent 양쪽에서 루트 경계를 검사합니다. +- 이력의 경로 변경은 규칙을 수정하지 않고 Job의 상대 경로만 변경합니다. 이미 이동된 파일은 사용자 확인 뒤 기존 안전 이동 서비스를 다시 사용합니다. +- 자동 팝업은 `SelectionPromptPolicy.AutoPromptWindow`(30분)를 통과한 Job만 사용합니다. 대기 탭과 InfoBadge는 모든 실제 Pending을 사용하므로 두 목록을 다시 합치지 마세요. +- 테마는 Window별 임시 코드 대신 App의 단일 ThemeManager에 등록합니다. 새 Window를 추가하면 Content 설정 직후 등록하고 UI 설정은 기존 `config.local.json`에 보존합니다. + +## 설치/제거 검증 + +업그레이드 전 App의 `--shutdown` 완료를 기다리고 설치합니다. 설치 뒤에는 시작 메뉴, HKCU Run, 6개 브라우저 Native Host, 백그라운드 단일 인스턴스, X 후 프로세스 유지와 트레이 메뉴를 확인합니다. 업그레이드 전후에는 사용자 DB의 행 수·SHA-256과 `config.local.json`의 테마 값을 비교합니다. 제거 스크립트는 `%LOCALAPPDATA%\eslee\DownloadRouter`를 삭제하면 안 됩니다. ## 기능 추가 순서 diff --git a/Directory.Build.props b/Directory.Build.props index e1b0192..a7f5762 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,6 +5,12 @@ enable enable true + 0.3.1 + $(VersionPrefix) + $(VersionPrefix).0 + $(VersionPrefix).0 + $(VersionPrefix) + true true true portable diff --git a/HANDOFF.md b/HANDOFF.md index 69706a4..0b9a596 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -6,7 +6,7 @@ - 저장소: `https://github.com/esleeeeee/eslee-Download-Router.git` - 이어서 사용할 브랜치: `feature/initial-spike`(기준 통합 브랜치는 `develop`) -- 구현 기준 커밋: `9975c47bd0ac64e26fa651dcd8162aac068f73d8` (`feat: bootstrap download router spike`) +- 구현 기준: Draft PR #1의 최신 `feature/initial-spike` - 원격 상태: `main`, `develop`, `feature/initial-spike` 게시 및 GitHub 플러그인 검증 완료 - .NET SDK: `10.0.302` - Node.js: 24 이상 @@ -32,20 +32,20 @@ Edge에서 `edge://extensions`를 열고 개발자 모드를 켠 뒤 “압축 ## 현재 정상 기준 - Debug solution build 성공, 경고 0/오류 0 -- .NET tests 27/27 통과 -- Extension lint/build와 Node tests 4/4 통과 +- .NET tests 67/67 통과 +- Extension lint/build와 Node tests 13/13 통과 - Agent Named Pipe ping 성공 - Native Host self-test 성공 - App/Agent/Native Host self-contained Release publish 성공 -- 공식 `feature/initial-spike@9975c47` 클린 클론 bootstrap/build/test 성공 +- QHD 125% Per-Monitor V2, 900×850 수평 넘침 0, 파일별 SelectSubfolder, 최소화 상태 전면 활성화, 30분 팝업 정책, 전역 테마와 0.3.1 버전 체계 검증 -브라우저 수동 검증, installer, UI 시각 검증은 아직 정상 기준에 포함되지 않습니다. +Whale Automatic 규칙, 다른 Chromium 브라우저, installer, 100%/150% 및 FHD/4K 시각 검증은 아직 정상 기준에 포함되지 않습니다. ## 가장 먼저 할 작업 Edge에서 실제 확장 -> Native Host -> Agent ping과 다운로드 이벤트를 확인하세요. 성공/실패 로그를 `docs/BROWSER_COMPATIBILITY.md`에 기록하고, 실패하면 `scripts\diagnose.ps1`과 Native Host `-Action Status` 결과부터 확인합니다. -그 다음 자동 규칙, 미매칭 fail-open, 직접 선택 묶음, 중복 이름, 취소/중단 순서로 검증합니다. +그 다음 자동 규칙, 미매칭 fail-open, 파일별 직접 선택, 중복 이름, 취소/중단 순서로 검증합니다. ## PC별로 다시 지정할 항목 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 2cc3ef3..8408ce3 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,14 +1,13 @@ # 프로젝트 상태 -기준일: 2026-07-21 (Asia/Seoul) +기준일: 2026-07-23 (Asia/Seoul) ## 요약 - 현재 단계: Phase 0 완료, Phase 1 기술 스파이크 완료, 핵심 기능 개발 중 - 공식 저장소: https://github.com/esleeeeee/eslee-Download-Router - 게시 브랜치: `main`, `develop`, `feature/initial-spike` — 세 브랜치 모두 공식 원격에 생성 완료 -- 구현 기준 커밋: `9975c47bd0ac64e26fa651dcd8162aac068f73d8` (`feat: bootstrap download router spike`) -- 원격 검증: GitHub 플러그인에서 공개 저장소, 세 브랜치, 위 커밋을 직접 확인 +- 작업 브랜치: `feature/initial-spike`, 기존 Draft PR #1에서 후속 변경 추적 ## 구현 완료 @@ -24,64 +23,85 @@ - 다운로드 완료 파일 안정화와 동일 볼륨 move - 교차 볼륨 copy -> 크기/SHA-256 검증 -> rename -> 원본 삭제 - 중복 파일 번호 보존과 재시도 상태 -- 규칙별 선택 대기 작업 묶음 처리 API와 App 화면 +- 브라우저 전송 상태와 라우팅 상태를 분리한 작업 모델과 v1 → v2 SQLite 마이그레이션 +- 파일별 SelectSubfolder 카드, 명시적으로 체크한 항목만 일괄 적용, 건너뛰기와 세션 단위 나중에 선택 +- 생성/마지막 브라우저 이벤트 기준 30분 자동 팝업 정책, 이전 세션 대기 표시, 현재 큐 `모두 나중에 선택` +- 고정 DIP 전용 선택 창, lazy 계층형 FolderTreePicker, 단일 FIFO, 숨김/최소화 상태의 선택창 HWND 직접 활성화, 취소 시 자동 닫기 +- `onCreated`/`filename` delta/완료 검색을 통한 같은 Job 파일명 갱신과 임시 이름 차단 +- `USER_CANCELED` 즉시 전송, interrupted 오류 우선순위, onErased 비취소 처리와 팝업 연령을 갱신하지 않는 service worker 시작 재조정 +- 이력의 Job별 저장 위치 선택·변경, 완료 파일 명시적 재이동, 이동 안 함/나중에 선택 +- 취소선·상태 필터·개별/선택/취소 이력 삭제 UI와 실제 파일 비삭제 보장 +- 현재 필터 전체 선택/해제와 선택 수 표시 +- NavigationView Pending InfoBadge와 대시보드 공통 집계 +- 한 페이지의 규칙 생성/편집 섹션과 저장된 규칙 카드, 활성 토글, soft delete, HWND FolderPicker +- App 단일 인스턴스 트레이 호스트, `--background`, HKCU 로그인 자동 시작, X 숨김과 명시적 종료 - WinUI 3 필수 내비게이션 화면 골격과 Agent 진단 - 브라우저 설치/관리 주소 adapter와 HKCU Native Host 등록 스크립트 -- self-contained win-x64 publish와 per-user Inno Setup 골격 +- Windows PowerShell 5.1 절대 경로/HKCU 등록 호환성과 BOM 없는 UTF-8 Native Host manifest +- 실패할 때만 분류 코드를 남기는 Extension Native Messaging 진단 로그 +- Per-Monitor V2 WinUI 렌더링, 뷰포트 실폭 제한, 32/48 DIP 공통 상단 여백, 1초 상태 변경 감지 +- 저장된 System/Light/Dark를 열린 모든 Window와 이후 생성 Window에 적용하는 공통 ThemeManager +- assembly informational version을 표시하는 정보 화면과 `Directory.Build.props` 기반 App/Agent/Native Host/Installer 단일 0.3.1 버전 +- self-contained win-x64 publish와 설치/업그레이드/제거가 검증된 per-user Inno Setup 0.3.1 ## 빌드와 테스트 | 항목 | 결과 | |---|---| | `.NET Debug build` | 성공, 경고 0, 오류 0 | -| `.NET tests` | 27/27 통과(Core 21, Infrastructure 4, Integration 2) | +| `.NET tests` | 73/73 통과(Core 46, Infrastructure 7, Integration 20) | | Extension ESLint/TypeScript build | 성공 | -| Extension Node tests | 4/4 통과 | +| Extension Node tests | 13/13 통과 | | Agent Named Pipe ping | 성공 | -| Native Host self-test | 성공 | +| Native Host self-test/길이 접두사 ping/Agent 자동 시작 | 성공 | +| Native Host Agent 장애 fail-open | `agent.unavailable`, Host 종료 코드 0, 브라우저 비차단 응답 확인 | | Release win-x64 self-contained publish | App/Agent/Native Host 성공 | +| WinUI QHD 125% | 수정 전 DPI Unaware/96 → 수정 후 Per-Monitor V2/120 확인 | +| WinUI 반응형 폭 | 900px 창에서 우측 넘침 0, 2400px 창에서 1100 DIP 폼 중앙 정렬 확인 | | clean clone | 공식 `feature/initial-spike@9975c47`에서 bootstrap/build/test 성공 | -| Installer compile/install/uninstall | 미검증 | +| Installer compile/install/upgrade/uninstall | 성공, 사용자 DB 해시·행 수 보존, 자동 시작/6개 Native Host/바로가기 정리 확인 | ## 브라우저 검증 | 브라우저 | 설치 탐지 | 확장 로드 | 다운로드 이벤트 | Native Messaging | 자동 저장 | 직접 선택 | |---|---|---|---|---|---|---| -| Whale | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | -| Edge | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | -| Chrome | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | +| Whale | 성공 | 성공 | 성공 | 성공 | 성공 | 성공(계층형 SelectSubfolder) | +| Edge | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | +| Chrome | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Brave | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Vivaldi | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Opera | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | -실제 브라우저에서 수행하지 않은 결과를 성공으로 표시하지 않았습니다. +Whale 4.38.386.14에서 로컬 HTTP fixture로 Automatic과 SelectSubfolder를 실제 검증했습니다. Automatic은 C: 기본 다운로드 위치에서 D: 테스트 규칙 루트로 교차 볼륨 이동했고, SelectSubfolder는 테스트 루트에서 `kr → 모야지`만 확장·선택해 이동했습니다. 100자 이상 노드를 표시하고 복귀해도 선택 창 폭은 700px(125%의 560 DIP)로 동일했습니다. 0.3.0 사용자 검증에서 트레이 숨김은 통과했지만 최소화 상태는 선택창이 Whale 뒤에 남는 회귀가 확인됐고, 0.3.1에서 선택창 HWND 전용 foreground/입력 스레드 재시도와 topmost 정책으로 수정했습니다. 2026-07-23에는 아무 선택 없음/선택 완료/나중에 선택/이동 안 함 네 실제 Whale 취소가 모두 약 0.24초 안에 `Cancelled`로 반영되고 팝업·Pending·이동 0건과 이력 취소선을 확인했습니다. ## 알려진 문제와 제한 - downloads API만으로 다운로드 시작 탭 URL을 신뢰성 있게 얻을 수 없어 활성 탭을 추측하지 않습니다. 현재 referrer와 파일 URL을 분리해 사용합니다. -- 직접 선택은 App의 대기 화면에서 규칙별 묶음으로 처리합니다. Agent의 자동 창 활성화/트레이 알림, 새 폴더 생성, 새로 고침은 미구현입니다. -- 시작 시 실행 UI는 실제 Windows 등록과 연결되지 않았습니다. -- Agent 시작 시 미완료 이동 복구 워커는 미구현입니다. DB에는 작업 상태가 유지됩니다. -- 브라우저별 Native Messaging registry adapter, 특히 Whale/Opera는 실제 PC 검증이 필요합니다. +- App 실행 파일을 찾을 수 있으면 Agent가 선택 UI를 시작하고, 실행 중이면 1초 폴링과 독립 창으로 FIFO를 표시합니다. 설치 손상으로 App을 찾지 못해도 다운로드는 원래 위치에서 완료되고 Pending에 남습니다. +- “나중에 선택”과 “모두 나중에 선택” 팝업 억제는 현재 App 세션 동안만 유지됩니다. 30분이 지난 작업은 대기 탭에서 수동 처리하며 트리의 새 폴더 생성은 미구현입니다. +- Extension 시작 시 Agent의 진행 중 ID를 브라우저 다운로드 기록과 대조해 완료·취소·중단을 재전송합니다. 이 재조정은 실제 브라우저 이벤트 시각을 갱신하지 않으므로 오래된 Pending을 자동 팝업 대상으로 되살리지 않으며, 브라우저 기록에서 사라진 작업도 근거 없이 완료·삭제하지 않습니다. +- Whale registry adapter는 이 PC에서 검증했습니다. Brave/Vivaldi/Opera adapter는 실제 PC 검증이 필요합니다. - 브라우저 자체 “다운로드 전에 저장 위치 확인” 설정 감지와 안내는 문서만 있고 UI 자동 감지는 미구현입니다. -- 앱 UI의 실제 실행/시각·접근성 검증과 installer 동작 검증이 필요합니다. +- QHD 125% 실제 실행과 좁은/넓은 창 시각·경계 검증은 완료했습니다. Windows 100%/150%, FHD/4K 실기기와 키보드/스크린리더 접근성 검증은 남아 있습니다. +- 전역 테마의 매핑·저장·fallback은 자동 검증했습니다. 실제 QHD 125%의 다크/라이트 전환, 선택 창, 트레이 복원, 재실행 검증 결과는 아래 최신 설치본 검증 기록을 기준으로 하며 100%/150% 실기기 전환은 남아 있습니다. +- HKCU 자동 시작 명령과 백그라운드 실행은 확인했지만 실제 로그아웃/로그인 또는 재부팅은 수행하지 않았습니다. +- 설치 파일은 코드 서명되지 않았습니다. ## 보류된 결정 - 배포 코드 서명 인증서와 업데이트 채널 - 안정 배포 확장 ID/스토어 배포 방식 - 로그 보존 기간과 사용자 삭제 UI -- Agent 선택 알림을 App activation, tray, 별도 picker 중 어떤 방식으로 구현할지 +- 로그아웃을 포함한 장기 “나중에 선택” 알림 정책 ## 다음 작업 -1. Edge에서 개발자 모드 확장 로드와 Native Messaging 왕복을 실제 검증 -2. Edge 자동 저장과 규칙 미매칭 fail-open 실다운로드 검증 -3. 규칙별 전용 폴더 트리 picker에 새 폴더/새로 고침 추가 및 Agent 알림 연결 -4. Whale registry adapter와 다운로드/Native Messaging 검증 -5. Chrome 동일 시나리오 검증 -6. Agent 시작 시 미완료 작업 복구와 시작 시 실행 옵션 구현 -7. Inno Setup 설치/제거와 앱 UI 검증 +1. Windows 100%/150% 및 FHD/4K에서 UI 실배율·테마 시각 검증 +2. Native Host 장애 중 Whale 실다운로드 유지 검증 +3. Edge에서 개발자 모드 확장 로드와 Native Messaging 왕복을 실제 검증 +4. FolderTreePicker 새 폴더 만들기와 영구 알림 설정 추가 +5. 브라우저 기록에서 사라진 오래된 비종료 작업의 사용자 확인 복구 흐름 설계 +6. 설치 코드 서명과 업데이트 채널 결정 ## 실행 명령 @@ -91,6 +111,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 -SkipBuild +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps1 ``` ## 로컬 전용 설정 diff --git a/README.md b/README.md index 556df50..24ebc60 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Chromium 기반 브라우저에서 완료된 다운로드를 사이트 규칙에 따라 Windows 폴더로 분류하는 로컬 전용 애플리케이션입니다. 규칙이 없거나 로컬 구성 요소가 응답하지 않으면 브라우저의 원래 다운로드를 그대로 유지하는 fail-open 방식을 사용합니다. -> 현재 상태: Phase 0과 Phase 1 기술 스파이크가 구현되었습니다. 자동 빌드·테스트와 로컬 IPC는 검증했지만 실제 브라우저의 확장 로드, Native Messaging, 다운로드 라우팅은 아직 수동 검증 전입니다. 자세한 내용은 [PROJECT_STATE.md](PROJECT_STATE.md)를 확인하세요. +> 현재 상태: 핵심 라우팅, 계층형 폴더 선택, 트레이 상주, 로그인 자동 시작, 규칙/이력 관리와 사용자 단위 설치 흐름을 구현했습니다. 30분 자동 팝업 정책, Whale 취소 재조정, 최소화 상태 선택창 전면 활성화, 전역 System/Light/Dark 테마와 단일 0.3.1 제품 버전 체계를 포함합니다. 자세한 결과와 남은 제약은 [PROJECT_STATE.md](PROJECT_STATE.md)를 확인하세요. ## 구성 요소 @@ -63,6 +63,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\package-extension. | Agent IPC 스모크 테스트 | `scripts\smoke-agent.ps1` | | 확장 패키지 생성 | `scripts\package-extension.ps1` | | self-contained 설치 페이로드 | `scripts\publish.ps1` | +| 사용자 단위 설치 파일 | `scripts\build-installer.ps1` | | Native Host 상태 확인 | `scripts\register-native-host.ps1 -Action Status` | | 진단 | `scripts\diagnose.ps1` | @@ -89,14 +90,27 @@ PowerShell 실행 정책이 로컬 스크립트를 막는 경우 예시처럼 `p - 직접 선택 경로는 지정 루트 내부로 제한하고 `..`, 심볼릭 링크, junction, reparse point 탈출을 거부합니다. - URL 자격 증명, query, fragment는 저장 전에 제거합니다. +## 설치형 실행 + +`scripts\build-installer.ps1`은 Release self-contained 페이로드와 Inno Setup 사용자 단위 설치 파일을 `artifacts\installer`에 만듭니다. 설치본은 App을 `--background`로 로그인 자동 시작하고, App이 단일 인스턴스 트레이 호스트로서 Agent 실행을 보장합니다. 기본 X 버튼 동작은 메인 창 숨기기이며 완전 종료는 트레이의 `종료` 또는 일반 설정의 명시적 종료 동작으로 수행합니다. + +설치/업그레이드 시 기존 프로세스는 정상 종료 신호를 받고, Whale·Edge·Chrome·Brave·Vivaldi·Opera의 HKCU Native Host가 설치 경로로 등록됩니다. 제거 시 자동 시작, 바로가기와 Native Host 등록만 제거하며 `%LOCALAPPDATA%\eslee\DownloadRouter`의 규칙·이력 DB는 보존합니다. + +`Directory.Build.props`의 `VersionPrefix`가 App, Agent, Native Host, 설치 프로그램의 단일 버전 원본입니다. 정보 화면은 실행 assembly의 informational version과 짧은 commit, 설치형/개발 빌드 구분을 표시합니다. 일반 설정의 시스템/라이트/다크 테마는 `%LOCALAPPDATA%\eslee\DownloadRouter\config.local.json`에 저장되어 열린 창, 새 선택 창, 트레이 복원과 백그라운드 시작에 동일하게 적용됩니다. + +SelectSubfolder 자동 팝업은 생성 또는 마지막 실시간 브라우저 이벤트가 30분 이내인 작업만 대상으로 합니다. Extension 시작 시 검색 기반 재조정은 이 연령을 갱신하지 않습니다. 오래된 작업과 브라우저 기록을 찾을 수 없는 작업은 삭제하지 않고 대기 탭과 배지에 유지합니다. 선택 창의 `모두 나중에 선택`은 현재 App 세션의 기존 큐만 숨기며 이후 새 다운로드는 정상 표시합니다. + 설계와 위협 모델은 [ARCHITECTURE.md](ARCHITECTURE.md), [SECURITY.md](SECURITY.md)를 참고하세요. ## 현재 제한 - Chromium downloads API가 다운로드 시작 탭 URL을 직접 제공하지 않으므로, 신뢰할 수 없는 활성 탭 추측은 하지 않습니다. 제공되는 referrer, 최초 URL, 최종 URL을 분리해 사용합니다. -- 폴더 선택 대기 작업은 앱의 전용 화면에서 규칙별로 묶어 처리하지만, 자동 팝업·새 폴더 만들기·새로 고침은 후속 구현입니다. -- 시작 시 실행 토글은 UI 골격만 있으며 실제 Windows 등록은 아직 연결하지 않았습니다. -- 모든 브라우저 수동 검증 결과는 현재 `미검증`입니다. +- 폴더 트리는 lazy loading과 선택 노드 새로 고침을 지원하지만 새 폴더 만들기는 제공하지 않습니다. +- Windows 로그아웃/로그인 또는 재부팅 자체는 작업 환경을 중단하므로 수행하지 않았습니다. HKCU Run 등록과 `--background` 실행은 각각 확인했습니다. +- `System` 테마는 WinUI의 `ElementTheme.Default`를 사용합니다. Windows 앱 테마 실시간 변경은 WinUI 알림에 따르며, 최소한 다음 창 생성과 App 재시작 시에는 현재 시스템 값을 반영합니다. +- UI는 QHD 125%에서 실제 검증했습니다. FHD/4K와 Windows 100%/150%는 Per-Monitor V2/DIP 구조 및 좁은·넓은 창 경계 테스트만 완료했고 물리 디스플레이 전환 검증은 남아 있습니다. +- Edge·Chrome·Brave·Vivaldi·Opera의 실제 다운로드는 아직 수동 검증하지 않았습니다. +- 설치 파일은 현재 코드 서명되지 않았습니다. ## 문서 diff --git a/TESTING.md b/TESTING.md index 08ff7a4..cc640b5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3,22 +3,83 @@ ## 자동 검증 ```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\bootstrap.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build.ps1 -Configuration Release -PublishNativeHost +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\register-native-host.ps1 -Action Repair -Browser Whale +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\register-native-host.ps1 -Action Status -Browser Whale powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\diagnose.ps1 ``` -2026-07-21 현재 로컬 결과: +2026-07-23 현재 로컬 결과: - .NET Debug build: 성공, 경고 0, 오류 0 -- .NET tests: 27/27 통과 - - Core 21: 규칙, IDN/도메인 경계, 경로, 개인정보, 프로토콜, 상태 전이 - - Infrastructure 4: SQLite migration/round-trip, 동일·교차 볼륨 흐름, 중복 이름 - - Integration 2: 명령 거부, 미매칭 fail-open -- Extension: ESLint 성공, TypeScript compile 성공, Node tests 4/4 통과, dist build 성공 +- .NET tests: 73/73 통과 + - Core 46: 규칙, 경로, 개인정보, 상태 전이, 30분 팝업 정책/23개 큐 일괄 숨김, 선택창 foreground/최소화/취소 FIFO, 테마 저장/fallback, 제품 버전, 임시 파일명, lazy 폴더 트리, 자동 시작 등록/해제 + - Infrastructure 7: v1 → v2 → v3 → v4 SQLite 마이그레이션, soft-delete 규칙, round-trip, 동일·교차 볼륨, 중복 이름, 로그 개인정보 제거 + - Integration 20: 명령 거부, 미매칭 fail-open, 완료 전/후 선택, 네 선택 흐름의 idempotent 취소, stale/in_progress 재조정, 시작 재조정의 30분 연령 보존, 중단, 규칙 편집·삭제, skip 완료 시 원본 보존, 실제 파일 비삭제 이력 삭제 +- Extension: ESLint 성공, TypeScript compile 성공, Node tests 13/13 통과, dist build 성공 +- Windows PowerShell 5.1 등록 테스트: drive/UNC 절대 경로, 상대 경로 거부, origin 필터, BOM 없는 UTF-8 통과 - Agent Named Pipe ping: 성공 -- Native Host `--self-test`: 성공 +- Native Host `--self-test`, 길이 접두사 ping, 게시 Agent 자동 시작: 성공 +- Agent 미가용: `agent.unavailable`, Native Host 종료 코드 0, Agent 미기동, stderr 민감정보 없음 - App/Agent/Native Host Release `win-x64 --self-contained true` publish: 성공 +- Inno Setup 6.7.3 설치 파일 compile: 성공, unpackaged WinUI XBF/PRI 포함 검사 통과 + +## UI/DPI 검증 + +QHD 2560x1440, Windows 배율 125%에서 실제 App 프로세스를 측정했습니다. + +- 수정 전: process DPI awareness `Unaware`, window DPI 96 +- 수정 후: `PerMonitorAware`, `PER_MONITOR_AWARE_V2=true`, window DPI 120 +- 900x850 물리 픽셀 창: 사이트 규칙의 보이는 입력/드롭다운/버튼 11개가 모두 우측 창 경계 안, 세로 스크롤바 생성 +- 2400x1250 물리 픽셀 창: 폼 폭 1375px = 1100 DIP, 콘텐츠 좌우 여백 309/312px +- 900x850 UI Automation 재검증: 최대 하위 요소 우측 경계가 창보다 9px 안쪽, 수평 넘침 0 +- Wide 공통 상단 여백: 48 DIP, 실제 QHD 125% 창에서 제목 위치가 창 상단 기준 39px에서 96px로 이동 +- 대시보드, 이력, 선택 대기, 브라우저 연결, 설정, 진단, 정보는 같은 공통 콘텐츠 루트를 사용하므로 동일 폭/스크롤 수정이 적용됨 + +### 테마와 정보 화면 + +- System/Light/Dark 문자열 매핑, 잘못된 값의 System fallback, `config.local.json` 저장/재로드 자동 테스트 +- MainWindow와 FolderSelectionWindow를 같은 ThemeManager에 등록해 설정 변경 시 열린 Window 전체, 생성 시 새 Window에 `RequestedTheme` 적용. 코드 생성 카드·상태 배지와 AppWindow 제목 표시줄도 현재 테마 리소스로 동기화 +- 정보 화면은 App assembly informational version을 읽으며 commit이 없어도 SemVer fallback +- `Directory.Build.props` VersionPrefix와 App ProductVersion, Inno Setup 전달 경로 자동 비교 +- QHD 125% 설치본에서 시스템→다크, 선택 창 다크, X 트레이 복원, 완전 재실행, 라이트, 시스템 복귀를 순서대로 확인 + +100%/150% Windows 실배율과 FHD/4K 물리 디스플레이는 현재 단일 QHD 125% 환경에서 직접 전환하지 않았습니다. manifest/DIP/MaxWidth 구조와 좁은·넓은 창 경계는 검증했지만 이 실기기 항목은 수동 확인으로 남깁니다. + +### 선택 창과 설치본 UI Automation + +- QHD 125%에서 전용 선택 창: 700×950px = 560×760 DIP, 작업 영역 중앙, 크기 조절/최대화 비활성 +- 100자 이상 폴더 노드를 `ScrollIntoView`한 전/후와 루트 복귀 후 창 폭: 모두 700px +- 트리 루트 첫 확장 한 번으로 직계 자식이 실체화되고 `kr → 모야지` 선택 가능 +- 메인 창 최소화 상태에서 선택 창은 별도 최상위 창으로 표시되고 메인 창은 `Minimized` 유지 +- 메인 창 X 후 App/Agent 프로세스 유지, 보이는 메인 최상위 창 없음 +- 이력 현재 필터 전체 선택: `Off → On`, 다시 한 번 클릭해 `Off`; 필터 변경 시 선택 초기화 정책 문구 표시 + +### 설치/업그레이드/제거 + +- silent 초기 설치와 업그레이드 종료 코드 0 +- 설치 App 10초 이상 안정 실행, 백그라운드 실행 시 보이는 메인 창 0, 두 번째 실행 후 App 인스턴스 1 +- 실제 창 DPI 120, `XamlRoot.RasterizationScale=1.25` +- 설치 전후 DB v4 마이그레이션에서 기존 Rule/Job 수와 ID, `config.local.json` 테마 보존 +- Installer/App/Agent/Native Host의 제품 버전 0.3.1 일치 + +### 최소화 상태 선택창 전면 활성화 + +- MainWindow와 FolderSelectionWindow의 HWND, visible, `IsIconic`, owner, foreground를 activation 전후로 기록 +- 선택창에는 owner를 지정하지 않으며 모든 restore/foreground 호출은 선택창 HWND만 대상으로 함 +- 순서: `SW_SHOWNORMAL` → `Window.Activate` → `BringWindowToTop` → `SetForegroundWindow` → foreground thread attach 재시도 → topmost/Flash fallback +- direct foreground가 거부된 실제 최소화 재현에서 attach 재시도로 선택창 foreground 획득, MainWindow는 `Minimized` 유지 +- 자동 테스트에서 숨김/최소화 MainWindow, iconic 선택창 normal 복원, MainWindow 비복원, FIFO 중복 방지와 취소 제거를 검증 +- 자동 시작 값: 설치 App의 따옴표 절대 경로 + `--background` +- 트레이 메뉴: `열기`, `저장 위치 선택 대기 열기`, `일반 설정`, `종료` +- 제거 전: Native Host 6개, 자동 시작, 시작 메뉴 존재 +- 제거 후: Native Host 0개, 자동 시작/시작 메뉴/설치 디렉터리 없음 +- 제거 전후 사용자 SQLite: 규칙 4행, 작업 29행, DB SHA-256 동일 +- 로그아웃/로그인·재부팅은 실제 수행하지 않음; 등록된 자동 시작 명령의 수동 백그라운드 실행만 확인 ## 새 테스트 작성 원칙 @@ -36,24 +97,57 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 2. 해당 브라우저만 `scripts\register-native-host.ps1 -Browser `으로 등록 3. `src\DownloadRouter.Extension\dist`를 개발자 모드에서 로드 4. Agent와 App 실행 후 진단 화면의 ping 확인 -5. `naver.com`과 하위 도메인용 테스트 규칙 생성 +5. `127.0.0.1` 또는 전용 테스트 호스트 규칙을 만들고 전용 테스트 목적 폴더 지정 6. 규칙 없는 사이트 다운로드가 원래 위치에 남는지 확인 7. 자동 규칙으로 일반 HTTP/HTTPS 파일 이동 확인 8. 동일 이름 3개가 번호를 붙여 모두 보존되는지 확인 -9. 직접 선택 규칙에서 여러 파일이 규칙별 한 그룹으로 표시되는지 확인 +9. 직접 선택 규칙에서 각 파일이 별도 카드와 FIFO 팝업으로 표시되고 서로 다른 폴더를 선택할 수 있는지 확인 10. 루트 외부, `..`, junction/reparse point 경로가 거부되는지 확인 11. 다운로드 취소·중단이 이력에 남고 파일을 삭제하지 않는지 확인 12. Native Host 등록을 제거하거나 Agent를 종료한 상태에서도 다운로드가 계속되는지 확인 13. 브라우저의 “다운로드 전 저장 위치 확인” 설정과 충돌 안내 확인 -## 아직 수행하지 않은 검증 +### SelectSubfolder 반복 가능한 Whale 시나리오 + +테스트 전용 격리 DB와 폴더를 사용하고 실제 사용자 다운로드를 삭제하지 않습니다. `scripts/manual-download-server.ps1`은 브라우저 완료 전 선택과 취소를 재현할 수 있도록 로컬 파일을 지연 전송합니다. + +1. A/B/C/D 하위 폴더가 있는 테스트 저장 루트와 바이너리 fixture를 준비합니다. +2. 별도 `DOWNLOAD_ROUTER_DATA_DIR`로 Agent를 시작하고 `127.0.0.1` SelectSubfolder 규칙을 등록합니다. +3. 서버를 시작합니다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\manual-download-server.ps1 ` + -Root .\artifacts\manual-whale\server -Port 8765 -DelayMilliseconds 75 +``` + +4. 완료 후 두 파일을 A/B에 각각 선택하고 둘 다 이동되는지 확인합니다. +5. 큰 파일 다운로드 중 D를 선택해 `InProgress / SelectionReady`와 목적지 파일 없음, 완료 후 자동 이동을 확인합니다. +6. 큰 파일을 Whale에서 취소해 팝업 닫힘, `Cancelled / NotRequired`, Pending 제외, 이동 0건을 확인합니다. +7. 4개를 동시에 시작해 팝업이 하나만 열리고 파일별 선택으로 A/B/C/D 이동이 가능한지 확인합니다. +8. 취소된 이력 정리 후 이미 이동된 실제 파일이 그대로 있는지 확인합니다. + +### 취소와 오래된 큐 회귀 시나리오 + +1. 아무 선택 없음, 위치 선택 완료, 나중에 선택, 이번 파일 이동 안 함의 네 Job을 각각 실제 Whale에서 시작합니다. +2. 브라우저 UI에서 취소하고 1초 이내 `Cancelled`, 팝업 닫힘, Pending 감소, 이동 0, 이력 취소선/설명 반영을 확인합니다. +3. App을 완전 종료 후 다시 실행해 네 취소 상태가 DB에서 유지되는지 확인합니다. +4. 격리 DB에 마지막 브라우저 이벤트가 31분 이상 지난 SelectSubfolder 대기 Job 23개를 만들고 시작 팝업 0, 대기 탭 23개와 이전 세션 안내를 확인합니다. +5. 최근 Job을 추가해 `1 / 23` 형식과 동시에 대기 중인 다른 파일 수를 확인합니다. +6. `모두 나중에 선택` 뒤 RoutingState와 파일이 바뀌지 않고 기존 큐 전체가 닫히는지 확인합니다. +7. 그 뒤 새 다운로드를 시작해 새 팝업이 정상 표시되는지 확인합니다. + +2026-07-22 실제 Whale 4.38.386.14 결과: 위 4~8번과 설치본 Automatic/Tree 흐름 성공. 이번 테스트가 만든 파일은 휴지통으로 이동해 복구 가능하며 테스트 DB 이력과 규칙만 정확한 ID로 정리했습니다. + +2026-07-23 실제 Whale 4.38.386.14 결과: A(선택 없음) 237ms, C(나중에 선택) 236ms, D(이동 안 함) 227ms에 `Cancelled`가 반영됐고 B(선택 완료)도 Agent 진단에서 즉시 `SelectionReady → NotRequired`를 확인했습니다. 네 경우 모두 팝업 0, Pending 원복, 이동 0이며 다크 테마 이력에서 파일명·보조 설명 취소선과 낮은 opacity를 확인했습니다. service worker 재로드 중 시작 재조정이 오래된 Job의 팝업 연령을 갱신하는 추가 원인을 재현해 프로토콜의 `isReconciliation`으로 분리했고, 31분 fixture가 재조정 뒤에도 자동 팝업 제외되는 통합 테스트를 추가했습니다. + +## 브라우저 검증 상태 -- 실제 Whale, Edge, Chrome, Brave, Vivaldi, Opera 확장 로드 -- 실제 Native Messaging 왕복 -- 실제 브라우저 다운로드 자동 이동/직접 선택 -- 앱 화면 시각 QA와 접근성 +- Whale 4.38.386.14: 확장 `dist` 로드와 고정 ID, 실제 다운로드 이벤트, Native Messaging 왕복, 미매칭 fail-open 성공 +- Whale SelectSubfolder: 완료 전/후 선택, A/B 개별 폴더, 네 선택 상태의 취소, 4개 FIFO, 이력 취소선·삭제 성공 +- Whale Automatic 규칙: 실제 C: → D: 교차 볼륨 이동 성공 +- Edge/Chrome/Brave/Vivaldi/Opera 확장과 Native Messaging: 미검증 +- UI: QHD 125% 시각·경계·독립 팝업 검증 완료, 100%/150%와 접근성 미검증 - 네트워크 드라이브 및 실제 다른 물리 드라이브 -- clean clone 전체 bootstrap/build/test(초기 push 후 수행 예정) -- Inno Setup 설치/제거 및 코드 서명 +- Inno Setup 설치/업그레이드/제거 성공, 코드 서명 미구현 미수행 항목을 성공으로 기록하지 않습니다. diff --git a/docs/BROWSER_COMPATIBILITY.md b/docs/BROWSER_COMPATIBILITY.md index cbdbed8..a198cc5 100644 --- a/docs/BROWSER_COMPATIBILITY.md +++ b/docs/BROWSER_COMPATIBILITY.md @@ -1,14 +1,14 @@ # 브라우저 호환성 및 검증 기록 -마지막 갱신: 2026-07-21 +마지막 갱신: 2026-07-22 -자동화된 Agent/Native Host 스모크 테스트와 실제 브라우저 수동 테스트를 구분합니다. 현재 실제 브라우저 검증은 전부 미수행입니다. +자동화된 Agent/Native Host 스모크 테스트와 실제 브라우저 수동 테스트를 구분합니다. Whale의 SelectSubfolder와 Automatic은 실제 브라우저에서 성공했습니다. | 브라우저 | 지원 수준 | 설치 탐지 | 확장 로드 | 다운로드 이벤트 | Native Messaging | 자동 저장 | 직접 선택 | 상태 | |---|---|---|---|---|---|---|---|---| -| Naver Whale | 정식 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 수동 검증 필요 | -| Microsoft Edge | 정식 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 최우선 검증 대상 | -| Google Chrome | 정식 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 수동 검증 필요 | +| Naver Whale | 정식 목표 | 성공 | 성공 | 성공 | 성공 | 성공 | 성공 | 성공 | +| Microsoft Edge | 정식 목표 | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 확장 수동 검증 필요 | +| Google Chrome | 정식 목표 | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 확장 수동 검증 필요 | | Brave | 호환 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | adapter 후보 | | Vivaldi | 호환 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | adapter 후보 | | Opera | 호환 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | registry 확인 필요 | @@ -48,9 +48,54 @@ Native Messaging: 증거/로그 위치(민감정보 제외): ``` +## 2026-07-21 — Whale 4.38.386.14 부분 검증 + +- Windows: Windows 11, 10.0.26200.0 +- 설치 탐지: 성공. Program Files 설치 경로 후보를 진단 스크립트에 반영 +- 확장 로드: 성공. 고정 ID `gilicenlclaemgiijcjjejilikbooggj`, Profile 1의 unpacked 경로가 현재 `dist`와 일치 +- manifest: HKCU Whale 등록, 이름/Host 경로/origin 일치, JSON 유효, UTF-8 BOM 없음 +- 다운로드 이벤트/Native Messaging: 성공. 실제 미매칭 다운로드 이벤트가 Agent 로그에 도달했고 Agent 오류 0건 +- 미매칭 fail-open: 성공. 사용자 다운로드는 완료되고 작업 DB에는 규칙 미매칭 항목을 만들지 않음 +- 자동 저장/직접 선택: 실제 Whale에서는 미검증. 격리된 통합 테스트에서는 샘플 규칙의 작업 생성과 파일 이동 성공 +- 종합 상태: 부분 성공 + ## 2026-07-21 — 로컬 프로토콜 검증 - 실제 브라우저: 사용하지 않음 - Native Host self-test: 성공 - Agent current-user Named Pipe ping: 성공 -- 해석: 프로세스와 IPC 골격은 작동하지만 어느 브라우저의 호환성도 증명하지 않음 +- Agent 장애 fail-open: `agent.unavailable`, Host 종료 코드 0, 브라우저 비차단 응답 확인 +- 해석: 로컬 프로토콜과 Whale 미매칭 경로는 확인했지만 다른 브라우저 호환성과 Whale 자동 이동은 증명하지 않음 + +## 2026-07-22 — Whale 4.38.386.14 SelectSubfolder 검증 + +- Windows: Windows 11, QHD 2560×1440, 125% +- 데이터: 별도 `DOWNLOAD_ROUTER_DATA_DIR`, 로컬 HTTP 지연 서버, 전용 A/B/C/D 저장 폴더 +- 완료 후 선택: `quick-a.bin → A`, `quick-b.bin → B`, 원본 다운로드 위치에서 안전 이동 성공 +- 완료 전 선택: 진행 중 D 선택 시 `InProgress / SelectionReady`, 목적지 파일 없음. 완료 이벤트 후 24 MiB 파일 D 이동 성공 +- 사용자 취소: Whale 취소 버튼 후 팝업 자동 닫힘, `Cancelled / NotRequired`, `download.cancelled`, Pending 0, 이동 0 +- 4개 동시 시작: 단일 FIFO 팝업에서 네 작업을 순서대로 처리하고 A/B/C/D에 각각 이동 +- 이력 삭제: 취소 이력 정리 확인창을 거쳐 DB 항목만 제거. 이미 이동된 테스트 파일 4개 유지 +- UI: 900×850 창에서 UI Automation 기준 수평 넘침 0, 공통 Wide 상단 패딩 48 DIP 확인 +- Automatic 규칙은 같은 날짜의 설치본 검증에서 성공. 다른 Chromium 브라우저와 Native Host 장애 중 실다운로드는 미검증 + +## 2026-07-22 — 설치본 Whale Automatic/Tree 검증 + +- 환경: Windows 11, QHD 2560×1440, 125%, Whale 4.38.386.14 +- 설치본: per-user Inno Setup 0.2.0 업그레이드 설치, App/Agent/Native Host self-contained +- Automatic: 실제 Whale 다운로드가 C: 기본 다운로드 위치에서 D: 규칙 루트로 이동. 브라우저 완료 전 이동 없음, 작업은 `Complete / Completed` +- SelectSubfolder: D: 테스트 루트에서 `kr` 확장 후 `모야지` 선택, C: 원본 소멸과 D: 대상 생성 확인 +- 긴 노드: 100자 이상 폴더를 표시하고 위아래로 이동해도 선택 창 폭은 125%에서 700px(560 DIP)로 동일 +- 숨김/최소화: 메인 창을 숨기거나 최소화한 상태에서 독립 선택 창 표시, 메인 창은 복원되지 않음 +- 파일명: 임시 이름 필터와 같은 Job 메타데이터 갱신은 자동 테스트 및 Agent 실연동으로 확인. Whale가 이 fixture에서 지연 filename delta를 내지 않아 “확인 중 → 최종 이름” 시각 전환 자체는 미재현 +- Automatic/Tree 테스트 파일과 테스트 규칙·작업만 정리했으며 사용자 작업은 보존 + +## 2026-07-23 — Whale 취소/재조정/설치본 테마 검증 + +- 환경: Windows 11, QHD 2560×1440, 125%, Whale 4.38.386.14, per-user 설치본 0.3.1 +- 실제 브라우저 취소: 선택 없음 237ms, 나중에 선택 236ms, 이동 안 함 227ms에 Agent `Cancelled`; 선택 완료도 진단 로그에서 `InProgress → Cancelled`, `SelectionReady → NotRequired` 즉시 적용 +- 공통 결과: 선택 팝업 닫힘, Pending 수 원복, 이동 파일 0, 이력 유지, 파일명과 보조 설명 취소선, opacity 감소 +- 진단: `onChanged`의 정제된 download ID/state/error, Native Messaging 결과, Agent 명령과 전후 BrowserTransferState/RoutingState를 기록하며 URL/query/token/전체 경로는 기록하지 않음 +- 시작 재조정: `chrome.downloads.search` 결과를 실시간 이벤트와 구분해 기존 Job의 30분 팝업 연령을 갱신하지 않음. 브라우저 기록 없음은 stale로 보존 +- 설치/테마: 기존 `config.local.json` 해시와 Dark 설정을 보존한 업그레이드 종료 코드 0; MainWindow, 카드, NavigationView, 이력, 상태 배지와 제목 표시줄의 다크 대비 확인 +- 다른 Chromium 브라우저와 Windows 100%/150%, FHD/4K 실기기 시각 검증은 남아 있음 diff --git a/docs/MANUAL_EXTENSION_INSTALL.md b/docs/MANUAL_EXTENSION_INSTALL.md index 92c1295..6c16fb1 100644 --- a/docs/MANUAL_EXTENSION_INSTALL.md +++ b/docs/MANUAL_EXTENSION_INSTALL.md @@ -12,6 +12,14 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\package-extension. 압축 해제 로드 경로는 `src\DownloadRouter.Extension\dist`입니다. ZIP은 배포 검토용이며 개발자 모드 “압축 풀린 확장 로드”에는 폴더를 사용합니다. +일반 사용자 설치 흐름을 검증할 때는 Inno Setup 6이 설치된 환경에서 다음을 실행합니다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps1 +``` + +생성된 `artifacts\installer\eslee-download-router-setup.exe`는 사용자 단위로 App/Agent/Native Host/확장 dist를 설치하고 HKCU Native Host 및 선택한 자동 시작을 등록합니다. 설치본을 사용하는 경우 아래 개발 출력 등록 명령은 실행하지 않습니다. + ## 2. Native Host 등록 브라우저별로 하나씩 등록하고 상태를 확인합니다. @@ -22,6 +30,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\register-native-ho ``` 등록은 HKCU와 `%LOCALAPPDATA%\eslee\DownloadRouter\native-host`만 사용하며 관리자 권한을 요구하지 않습니다. +manifest는 Windows PowerShell 5.1에서도 UTF-8 BOM 없이 기록됩니다. `scripts\diagnose.ps1`의 `valid-json=yes`, `utf8-bom=False`, `host-exists=True`, `development-origin=True`를 함께 확인하세요. ## 3. 확장 로드 @@ -40,6 +49,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 -SkipB 앱의 “진단 및 문제 해결”에서 Agent 연결을 테스트합니다. 이 성공은 앱과 Agent 사이만 확인합니다. 브라우저 Native Messaging 성공은 확장 service worker 오류와 Agent 작업 이력을 함께 확인해야 합니다. +“다운로드 이력”은 모든 브라우저 다운로드가 아니라 사이트 규칙에 매칭되어 추적된 작업만 표시합니다. 규칙이 없는 다운로드가 정상 완료되고 이력이 비어 있는 것은 fail-open 정책상 정상입니다. 이력 화면을 열어 둔 동안 추적 작업이 바뀌면 약 3초 안에 자동 갱신됩니다. + ## 5. 테스트 규칙 샘플은 `samples\rule.naver.example.json`에 있습니다. 앱에서 동일한 값으로 규칙을 만들고 개인 PC에 맞는 저장 루트를 지정합니다. 실제 개인 파일 대신 공개 테스트 파일을 사용하세요. @@ -52,6 +63,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\unregister-native- 브라우저 확장 관리 화면에서 개발자 확장을 제거합니다. 사용자 DB와 규칙은 자동 삭제하지 않습니다. +Inno Setup 설치본 제거도 자동 시작, 바로가기와 설치 경로의 Native Host 등록만 해제합니다. `%LOCALAPPDATA%\eslee\DownloadRouter`의 SQLite DB와 규칙은 재설치를 위해 기본 보존됩니다. + ## 문제 해결 - `scripts\diagnose.ps1` 실행 @@ -60,5 +73,6 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\unregister-native- - 확장 ID와 `allowed_origins` 일치 확인 - 기업 정책의 Native Messaging/개발자 모드 차단 확인 - 브라우저를 완전히 종료 후 다시 시작 +- 확장 관리 화면의 service worker 검사에서 `[Download Router] Native host communication failed ()` 확인. 로그에는 원문 URL, 토큰, 전체 로컬 경로가 포함되지 않습니다. URL query, 인증 토큰, 실제 사용자 경로가 포함된 로그를 issue나 문서에 붙이지 마세요. diff --git a/installer/DownloadRouter.iss b/installer/DownloadRouter.iss index a29dc83..317e869 100644 --- a/installer/DownloadRouter.iss +++ b/installer/DownloadRouter.iss @@ -1,5 +1,10 @@ #define AppName "eslee Download Router" -#define AppVersion "0.1.0" +#ifndef AppVersion + #error AppVersion must be supplied from Directory.Build.props by scripts/build-installer.ps1 +#endif +#ifndef AppCommit + #define AppCommit "unknown" +#endif #define Publisher "eslee" [Setup] @@ -16,13 +21,47 @@ OutputBaseFilename=eslee-download-router-setup Compression=lzma2 SolidCompression=yes UninstallDisplayIcon={app}\DownloadRouter.App.exe +DisableProgramGroupPage=yes +WizardStyle=modern +CloseApplications=yes +RestartApplications=no +VersionInfoVersion={#AppVersion}.0 +VersionInfoProductVersion={#AppVersion} +VersionInfoDescription={#AppName} installer ({#AppCommit}) + +[Tasks] +Name: "desktopicon"; Description: "바탕 화면 바로가기 만들기"; GroupDescription: "추가 바로가기:"; Flags: unchecked +Name: "autostart"; Description: "Windows 로그인 시 백그라운드로 자동 시작"; GroupDescription: "백그라운드 실행:"; Flags: checkedonce [Files] Source: "..\artifacts\publish\app\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +[Icons] +Name: "{autoprograms}\eslee Download Router"; Filename: "{app}\DownloadRouter.App.exe" +Name: "{autodesktop}\eslee Download Router"; Filename: "{app}\DownloadRouter.App.exe"; Tasks: desktopicon + +[Registry] +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "eslee Download Router"; ValueData: """{app}\DownloadRouter.App.exe"" --background"; Tasks: autostart; Flags: uninsdeletevalue + [Run] -Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\register-native-host.ps1"" -Action Register -HostPath ""{app}\DownloadRouter.NativeHost.exe"""; Flags: runhidden +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\register-native-host.ps1"" -Action Register -Browser All -HostPath ""{app}\DownloadRouter.NativeHost.exe"""; Flags: runhidden Filename: "{app}\DownloadRouter.App.exe"; Description: "{cm:LaunchProgram,{#AppName}}"; Flags: nowait postinstall skipifsilent [UninstallRun] Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\unregister-native-host.ps1"""; Flags: runhidden; RunOnceId: "UnregisterNativeHost" +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\unregister-startup.ps1"""; Flags: runhidden; RunOnceId: "UnregisterStartup" + +[Code] +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; + AppExe: String; +begin + Result := ''; + AppExe := ExpandConstant('{app}\DownloadRouter.App.exe'); + if FileExists(AppExe) then + begin + if not Exec(AppExe, '--shutdown', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + Result := '기존 eslee Download Router를 정상 종료하지 못했습니다.'; + end; +end; diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..2bf541c --- /dev/null +++ b/installer/README.md @@ -0,0 +1,37 @@ +# 사용자 단위 설치 파일 + +`DownloadRouter.iss`는 관리자 권한 없이 현재 사용자에게 설치하는 Inno Setup 6 정의입니다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps1 +``` + +산출물은 `artifacts\installer\eslee-download-router-setup.exe`입니다. Release self-contained App, Agent, Native Host, 확장 dist와 등록/해제 스크립트를 포함합니다. + +설치 프로그램의 `AppVersion`은 `DownloadRouter.iss`에 하드코딩하지 않습니다. `build-installer.ps1`이 `Directory.Build.props`의 `VersionPrefix`를 읽어 Inno Setup에 전달하고 App/Agent/Native Host ProductVersion의 일치를 먼저 검사합니다. + +## 설치 정책 + +- 기본 경로: `%LOCALAPPDATA%\Programs\eslee\DownloadRouter` +- 시작 메뉴 바로가기, 선택적 바탕화면 바로가기 +- 선택한 경우 HKCU Run에 `DownloadRouter.App.exe --background` +- Whale, Edge, Chrome, Brave, Vivaldi, Opera의 HKCU Native Host 등록 +- 업그레이드 전 설치 App에 `--shutdown`을 전달해 App과 Agent를 정상 종료 +- unpackaged WinUI 필수 리소스 `App.xbf`, `MainWindow.xbf`, `DownloadRouter.App.pri` 포함 +- `%LOCALAPPDATA%\eslee\DownloadRouter`의 DB, 규칙, 이력과 `config.local.json` 테마는 업그레이드에서 보존 + +## 제거 정책 + +제거는 자동 시작, Native Host 등록, 바로가기와 설치 파일만 삭제합니다. 사용자 규칙과 이력이 들어 있는 `%LOCALAPPDATA%\eslee\DownloadRouter`는 기본 보존합니다. 사용자 데이터 삭제 옵션은 현재 제공하지 않으므로 제거기에서 이 경로를 추가로 지우지 마세요. + +## 배포 전 확인 + +- 설치/업그레이드/제거 종료 코드 +- 설치 App 10초 이상 안정 실행과 X 후 트레이 상주 +- 시작 메뉴/자동 시작/Native Host 등록 및 제거 +- 제거 전후 SQLite 행 수와 파일 SHA-256 동일 +- App/Agent/Native Host/Installer 제품 버전 일치와 정보 화면 표시 +- System/Light/Dark 즉시 전환, 트레이 복원·완전 재실행·백그라운드 시작 후 유지 +- 서명된 배포라면 `SignTool` 설정과 인증서 체인을 별도 검토 + +현재 로컬 검증 산출물은 코드 서명되지 않았습니다. diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 new file mode 100644 index 0000000..4320d0b --- /dev/null +++ b/scripts/build-installer.ps1 @@ -0,0 +1,47 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release' +) + +. (Join-Path $PSScriptRoot 'common.ps1') +$root = Get-RepositoryRoot +$versionDocument = [xml](Get-Content -LiteralPath (Join-Path $root 'Directory.Build.props') -Raw) +$appVersion = [string]($versionDocument.Project.PropertyGroup.VersionPrefix | Select-Object -First 1) +if ($appVersion -notmatch '^\d+\.\d+\.\d+$') { + throw 'Directory.Build.props VersionPrefix must be a three-part SemVer.' +} +$gitRevision = & git -C $root rev-parse HEAD 2>$null +$sourceRevisionId = if ($LASTEXITCODE -eq 0) { ([string]$gitRevision).Trim() } else { 'unknown' } + +& (Join-Path $PSScriptRoot 'publish.ps1') -Configuration $Configuration +if ($LASTEXITCODE -ne 0) { throw 'Installer payload publish failed.' } + +$isccCandidates = @( + (Get-Command ISCC.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue), + (Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe') +) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -Unique + +$iscc = $isccCandidates | Select-Object -First 1 +if (-not $iscc) { + throw 'Inno Setup 6 was not found. Install the trusted JRSoftware.InnoSetup winget package.' +} + +foreach ($binary in @('DownloadRouter.App.exe', 'DownloadRouter.Agent.exe', 'DownloadRouter.NativeHost.exe')) { + $productVersion = (Get-Item -LiteralPath (Join-Path $root "artifacts\publish\app\$binary")).VersionInfo.ProductVersion + if (-not $productVersion.StartsWith("$appVersion+", [StringComparison]::Ordinal) -and $productVersion -ne $appVersion) { + throw "$binary product version '$productVersion' does not match $appVersion." + } +} + +& $iscc "/DAppVersion=$appVersion" "/DAppCommit=$sourceRevisionId" (Join-Path $root 'installer\DownloadRouter.iss') +if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compilation failed.' } + +$setup = Join-Path $root 'artifacts\installer\eslee-download-router-setup.exe' +if (-not (Test-Path -LiteralPath $setup -PathType Leaf)) { + throw 'The expected installer output was not created.' +} + +Write-Host "Installer built: $setup" +Write-Host "Product version: $appVersion ($sourceRevisionId)" diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 180de45..d4ff438 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -9,8 +9,11 @@ param( $root = Get-RepositoryRoot $dotnet = Assert-DotNetVersion $nodeTools = Assert-NodeTools +$gitRevision = & git -C $root rev-parse HEAD 2>$null +$sourceRevisionId = if ($LASTEXITCODE -eq 0) { ([string]$gitRevision).Trim() } else { '' } +$versionProperty = if ($sourceRevisionId) { "-p:SourceRevisionId=$sourceRevisionId" } else { $null } -& $dotnet build (Join-Path $root 'DownloadRouter.slnx') --configuration $Configuration --no-restore --nologo +& $dotnet build (Join-Path $root 'DownloadRouter.slnx') --configuration $Configuration --no-restore --nologo $versionProperty if ($LASTEXITCODE -ne 0) { throw 'dotnet build failed. Run scripts/bootstrap.ps1 first.' } Push-Location (Join-Path $root 'src\DownloadRouter.Extension') @@ -24,9 +27,9 @@ finally { if ($PublishNativeHost) { $output = Join-Path $root 'artifacts\native-host' - & $dotnet publish (Join-Path $root 'src\DownloadRouter.Agent\DownloadRouter.Agent.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $output --nologo + & $dotnet publish (Join-Path $root 'src\DownloadRouter.Agent\DownloadRouter.Agent.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $output --nologo $versionProperty if ($LASTEXITCODE -ne 0) { throw 'Agent publish failed.' } - & $dotnet publish (Join-Path $root 'src\DownloadRouter.NativeHost\DownloadRouter.NativeHost.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $output --nologo + & $dotnet publish (Join-Path $root 'src\DownloadRouter.NativeHost\DownloadRouter.NativeHost.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $output --nologo $versionProperty if ($LASTEXITCODE -ne 0) { throw 'Native Host publish failed.' } Write-Host "Published Native Host bundle: $output" } diff --git a/scripts/diagnose.ps1 b/scripts/diagnose.ps1 index b894676..a3488e5 100644 --- a/scripts/diagnose.ps1 +++ b/scripts/diagnose.ps1 @@ -14,7 +14,10 @@ Write-Host "Node.js: $(if ($node) { & $node.Source --version } else { 'not found Write-Host "npm: $(if ($npm) { & $npm.Source --version } else { 'not found' })" $browserCandidates = [ordered]@{ - Whale = @((Join-Path $env:LOCALAPPDATA 'Naver\Naver Whale\Application\whale.exe')) + Whale = @( + (Join-Path $env:LOCALAPPDATA 'Naver\Naver Whale\Application\whale.exe'), + (Join-Path $env:ProgramFiles 'Naver\Naver Whale\Application\whale.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Naver\Naver Whale\Application\whale.exe')) Edge = @((Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'), (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')) Chrome = @((Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'), (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe')) Brave = @((Join-Path $env:ProgramFiles 'BraveSoftware\Brave-Browser\Application\brave.exe')) @@ -27,5 +30,38 @@ foreach ($entry in $browserCandidates.GetEnumerator()) { } & (Join-Path $PSScriptRoot 'register-native-host.ps1') -Action Status -Browser All | Format-Table -AutoSize +$expectedManifestPath = Join-Path $env:LOCALAPPDATA 'eslee\DownloadRouter\native-host\com.eslee.download_router.json' +if (Test-Path -LiteralPath $expectedManifestPath -PathType Leaf) { + try { + $bytes = [IO.File]::ReadAllBytes($expectedManifestPath) + $hasUtf8Bom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + $offset = if ($hasUtf8Bom) { 3 } else { 0 } + $json = [Text.Encoding]::UTF8.GetString($bytes, $offset, $bytes.Length - $offset) + $manifest = $json | ConvertFrom-Json + $expectedOrigin = 'chrome-extension://gilicenlclaemgiijcjjejilikbooggj/' + $hostExists = Test-Path -LiteralPath $manifest.path -PathType Leaf + $originAllowed = @($manifest.allowed_origins) -contains $expectedOrigin + Write-Host "Native Host manifest: valid-json=yes, utf8-bom=$hasUtf8Bom, host-exists=$hostExists, development-origin=$originAllowed" + + if ($hostExists) { + $startInfo = New-Object Diagnostics.ProcessStartInfo + $startInfo.FileName = $manifest.path + $startInfo.Arguments = '--self-test' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardError = $true + $process = [Diagnostics.Process]::Start($startInfo) + $null = $process.StandardError.ReadToEnd() + $process.WaitForExit() + Write-Host "Native Host self-test: $(if ($process.ExitCode -eq 0) { 'passed' } else { "failed (exit $($process.ExitCode))" })" + } + } + catch { + Write-Warning "Native Host manifest validation failed: $($_.Exception.GetType().Name)" + } +} +else { + Write-Warning 'Native Host manifest was not found at the expected per-user location.' +} Write-Host "Extension load path after build: $(Join-Path $root 'src\DownloadRouter.Extension\dist')" Write-Host 'No browser profile, full URL, downloaded file, token, or raw log content was collected.' diff --git a/scripts/manual-download-server.ps1 b/scripts/manual-download-server.ps1 new file mode 100644 index 0000000..6bd7940 --- /dev/null +++ b/scripts/manual-download-server.ps1 @@ -0,0 +1,81 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Root, + [ValidateRange(1024, 65535)] + [int]$Port = 8765, + [ValidateRange(0, 5000)] + [int]$DelayMilliseconds = 75, + [ValidateRange(4096, 1048576)] + [int]$ChunkSize = 65536 +) + +$rootFull = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar) +if (-not (Test-Path -LiteralPath $rootFull -PathType Container)) { + throw "Download fixture root does not exist: $rootFull" +} + +$listener = New-Object Net.HttpListener +$listener.Prefixes.Add("http://127.0.0.1:$Port/") +$listener.Start() +Write-Host "Manual download server listening on http://127.0.0.1:$Port/" + +try { + while ($listener.IsListening) { + $context = $listener.GetContext() + try { + $relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/')) + if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) { + $context.Response.StatusCode = 404 + $context.Response.Close() + continue + } + + $filePath = [IO.Path]::GetFullPath((Join-Path $rootFull $relative)) + if ((-not $filePath.StartsWith( + $rootFull + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) -or + (-not (Test-Path -LiteralPath $filePath -PathType Leaf))) { + $context.Response.StatusCode = 404 + $context.Response.Close() + continue + } + + $file = Get-Item -LiteralPath $filePath + $context.Response.StatusCode = 200 + $context.Response.ContentType = 'application/octet-stream' + $context.Response.ContentLength64 = $file.Length + $context.Response.AddHeader('Content-Disposition', "attachment; filename=`"$($file.Name)`"") + $buffer = New-Object byte[] $ChunkSize + $input = [IO.File]::OpenRead($file.FullName) + try { + while (($read = $input.Read($buffer, 0, $buffer.Length)) -gt 0) { + $context.Response.OutputStream.Write($buffer, 0, $read) + $context.Response.OutputStream.Flush() + if ($DelayMilliseconds -gt 0) { + Start-Sleep -Milliseconds $DelayMilliseconds + } + } + } + finally { + $input.Dispose() + $context.Response.OutputStream.Dispose() + } + } + catch [Net.HttpListenerException] { + if ($listener.IsListening) { + Write-Warning $_.Exception.GetType().Name + } + } + catch [IO.IOException] { + # Browser cancellation closes the response stream. This is expected in manual tests. + } + finally { + try { $context.Response.Close() } catch { } + } + } +} +finally { + $listener.Stop() + $listener.Close() +} diff --git a/scripts/native-host-registration.ps1 b/scripts/native-host-registration.ps1 new file mode 100644 index 0000000..aa76927 --- /dev/null +++ b/scripts/native-host-registration.ps1 @@ -0,0 +1,24 @@ +function Test-WindowsAbsolutePath([string]$Path) { + if ([string]::IsNullOrWhiteSpace($Path)) { + return $false + } + + $isDriveAbsolute = $Path -match '^[A-Za-z]:[\\/]' + $isUnc = $Path -match '^\\\\[^\\]+\\[^\\]+' + return $isDriveAbsolute -or $isUnc +} + +function Get-NativeHostAllowedOrigins( + [string]$DevelopmentExtensionId, + [string[]]$AdditionalExtensionId = @() +) { + return @($DevelopmentExtensionId) + $AdditionalExtensionId | + Where-Object { $_ -match '^[a-p]{32}$' } | + Select-Object -Unique | + ForEach-Object { "chrome-extension://$_/" } +} + +function Write-Utf8WithoutBom([string]$Path, [string]$Content) { + $encoding = New-Object System.Text.UTF8Encoding -ArgumentList $false + [IO.File]::WriteAllText($Path, $Content, $encoding) +} diff --git a/scripts/publish.ps1 b/scripts/publish.ps1 index ba37595..e473eaf 100644 --- a/scripts/publish.ps1 +++ b/scripts/publish.ps1 @@ -8,6 +8,9 @@ param( $root = Get-RepositoryRoot $dotnet = Assert-DotNetVersion $nodeTools = Assert-NodeTools +$gitRevision = & git -C $root rev-parse HEAD 2>$null +$sourceRevisionId = if ($LASTEXITCODE -eq 0) { ([string]$gitRevision).Trim() } else { '' } +$versionProperty = if ($sourceRevisionId) { "-p:SourceRevisionId=$sourceRevisionId" } else { $null } $publishRoot = Join-Path $root 'artifacts\publish\app' $stagingRoot = Join-Path $root 'artifacts\publish\staging' @@ -16,12 +19,17 @@ if (Test-Path -LiteralPath $stagingRoot) { Remove-Item -LiteralPath $stagingRoot New-Item -ItemType Directory -Path $publishRoot -Force | Out-Null New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null -& $dotnet publish (Join-Path $root 'src\DownloadRouter.App\DownloadRouter.App.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $publishRoot --nologo +& $dotnet publish (Join-Path $root 'src\DownloadRouter.App\DownloadRouter.App.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $publishRoot --nologo $versionProperty if ($LASTEXITCODE -ne 0) { throw 'Settings app publish failed.' } +foreach ($resource in @('App.xbf', 'MainWindow.xbf', 'DownloadRouter.App.pri')) { + if (-not (Test-Path -LiteralPath (Join-Path $publishRoot $resource) -PathType Leaf)) { + throw "Required unpackaged WinUI resource was not published: $resource" + } +} foreach ($project in @('DownloadRouter.Agent', 'DownloadRouter.NativeHost')) { $projectOutput = Join-Path $stagingRoot $project - & $dotnet publish (Join-Path $root "src\$project\$project.csproj") --configuration $Configuration --runtime win-x64 --self-contained true --output $projectOutput --nologo + & $dotnet publish (Join-Path $root "src\$project\$project.csproj") --configuration $Configuration --runtime win-x64 --self-contained true --output $projectOutput --nologo $versionProperty if ($LASTEXITCODE -ne 0) { throw "$project publish failed." } Copy-Item -Path (Join-Path $projectOutput '*') -Destination $publishRoot -Recurse -Force } @@ -44,7 +52,9 @@ New-Item -ItemType Directory -Path $scriptOutput -Force | Out-Null Copy-Item -Path (Join-Path $root 'src\DownloadRouter.Extension\dist\*') -Destination $extensionOutput -Recurse -Force Copy-Item -LiteralPath (Join-Path $root 'scripts\common.ps1') -Destination $scriptOutput Copy-Item -LiteralPath (Join-Path $root 'scripts\register-native-host.ps1') -Destination $scriptOutput +Copy-Item -LiteralPath (Join-Path $root 'scripts\native-host-registration.ps1') -Destination $scriptOutput Copy-Item -LiteralPath (Join-Path $root 'scripts\unregister-native-host.ps1') -Destination $scriptOutput +Copy-Item -LiteralPath (Join-Path $root 'scripts\unregister-startup.ps1') -Destination $scriptOutput Write-Host "Self-contained installer payload: $publishRoot" Write-Host 'Build installer\DownloadRouter.iss with Inno Setup after reviewing signing settings.' diff --git a/scripts/register-native-host.ps1 b/scripts/register-native-host.ps1 index 6f7ea45..a4d7f2e 100644 --- a/scripts/register-native-host.ps1 +++ b/scripts/register-native-host.ps1 @@ -9,6 +9,7 @@ param( ) . (Join-Path $PSScriptRoot 'common.ps1') +. (Join-Path $PSScriptRoot 'native-host-registration.ps1') $root = Get-RepositoryRoot $hostName = 'com.eslee.download_router' $developmentExtensionId = 'gilicenlclaemgiijcjjejilikbooggj' @@ -50,7 +51,9 @@ if ($Action -eq 'Unregister') { return } -if (-not [IO.Path]::IsPathFullyQualified($HostPath)) { throw 'HostPath must be an absolute path.' } +if (-not (Test-WindowsAbsolutePath $HostPath)) { + throw 'HostPath must be an absolute path.' +} $HostPath = [IO.Path]::GetFullPath($HostPath) if (-not (Test-Path -LiteralPath $HostPath -PathType Leaf)) { throw "Native Host executable not found: $HostPath`nRun .\scripts\build.ps1 -Configuration Release -PublishNativeHost first." @@ -62,10 +65,7 @@ if ([IO.Path]::GetFileName($HostPath) -ne 'DownloadRouter.NativeHost.exe') { if (-not (Test-Path -LiteralPath $manifestDirectory)) { New-Item -ItemType Directory -Path $manifestDirectory | Out-Null } -$allowedOrigins = @($developmentExtensionId) + $AdditionalExtensionId | - Where-Object { $_ -match '^[a-p]{32}$' } | - Select-Object -Unique | - ForEach-Object { "chrome-extension://$_/" } +$allowedOrigins = Get-NativeHostAllowedOrigins $developmentExtensionId $AdditionalExtensionId $manifest = [ordered]@{ name = $hostName description = 'eslee Download Router Native Messaging Host' @@ -73,12 +73,13 @@ $manifest = [ordered]@{ type = 'stdio' allowed_origins = @($allowedOrigins) } -$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 +$manifestJson = $manifest | ConvertTo-Json -Depth 4 +Write-Utf8WithoutBom $manifestPath ($manifestJson + [Environment]::NewLine) foreach ($name in $selected) { $key = Get-RegistryKey $name if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null } - (Get-Item -Path $key).SetValue('', $manifestPath, [Microsoft.Win32.RegistryValueKind]::String) + Set-Item -LiteralPath $key -Value $manifestPath Write-Host "Registered $name -> $manifestPath" } diff --git a/scripts/send-agent-command.ps1 b/scripts/send-agent-command.ps1 new file mode 100644 index 0000000..9f7c741 --- /dev/null +++ b/scripts/send-agent-command.ps1 @@ -0,0 +1,68 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('ping', 'download.started', 'download.metadata', 'download.changed', 'download.cancelled', 'download.interrupted', 'downloads.active', 'rules.list', 'rules.upsert', 'rules.delete', 'jobs.list', 'jobs.delete', 'selection.complete', 'selection.skip', 'route.change', 'job.retry', 'diagnostics.status')] + [string]$Command, + [Parameter(Mandatory = $true)] + [string]$PayloadJson, + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Debug' +) + +. (Join-Path $PSScriptRoot 'common.ps1') +$root = Get-RepositoryRoot +$hostPath = Join-Path $root "src\DownloadRouter.NativeHost\bin\$Configuration\net10.0\DownloadRouter.NativeHost.exe" +if (-not (Test-Path -LiteralPath $hostPath -PathType Leaf)) { + throw "Native Host development output was not found: $hostPath" +} + +$payload = $PayloadJson | ConvertFrom-Json +$request = @{ + version = 1 + requestId = [guid]::NewGuid() + command = $Command + payload = $payload +} | ConvertTo-Json -Depth 20 -Compress +$requestBytes = [Text.Encoding]::UTF8.GetBytes($request) +$prefix = [BitConverter]::GetBytes([int]$requestBytes.Length) +$startInfo = New-Object Diagnostics.ProcessStartInfo +$startInfo.FileName = $hostPath +$startInfo.Arguments = 'chrome-extension://gilicenlclaemgiijcjjejilikbooggj/' +$startInfo.UseShellExecute = $false +$startInfo.CreateNoWindow = $true +$startInfo.RedirectStandardInput = $true +$startInfo.RedirectStandardOutput = $true +$startInfo.RedirectStandardError = $true +$process = [Diagnostics.Process]::Start($startInfo) +try { + $process.StandardInput.BaseStream.Write($prefix, 0, $prefix.Length) + $process.StandardInput.BaseStream.Write($requestBytes, 0, $requestBytes.Length) + $process.StandardInput.BaseStream.Flush() + $process.StandardInput.Close() + + $responsePrefix = New-Object byte[] 4 + if ($process.StandardOutput.BaseStream.Read($responsePrefix, 0, 4) -ne 4) { + throw "Native Host returned no framed response: $($process.StandardError.ReadToEnd())" + } + + $responseLength = [BitConverter]::ToInt32($responsePrefix, 0) + if ($responseLength -lt 1 -or $responseLength -gt 1048576) { + throw "Native Host returned an invalid response length: $responseLength" + } + + $responseBytes = New-Object byte[] $responseLength + $offset = 0 + while ($offset -lt $responseLength) { + $read = $process.StandardOutput.BaseStream.Read($responseBytes, $offset, $responseLength - $offset) + if ($read -le 0) { throw 'Native Host response ended before the declared length.' } + $offset += $read + } + + [Text.Encoding]::UTF8.GetString($responseBytes) +} +finally { + $process.StandardInput.Dispose() + $process.StandardOutput.Dispose() + $process.StandardError.Dispose() + $process.Dispose() +} diff --git a/scripts/test-native-host-registration.ps1 b/scripts/test-native-host-registration.ps1 new file mode 100644 index 0000000..d0db9e6 --- /dev/null +++ b/scripts/test-native-host-registration.ps1 @@ -0,0 +1,42 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'native-host-registration.ps1') + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { + throw $Message + } +} + +Assert-True (Test-WindowsAbsolutePath 'C:\Program Files\eslee\DownloadRouter.NativeHost.exe') 'Drive-absolute paths must be accepted.' +Assert-True (Test-WindowsAbsolutePath '\\server\share\DownloadRouter.NativeHost.exe') 'UNC paths must be accepted.' +Assert-True (-not (Test-WindowsAbsolutePath 'C:relative\DownloadRouter.NativeHost.exe')) 'Drive-relative paths must be rejected.' +Assert-True (-not (Test-WindowsAbsolutePath '.\DownloadRouter.NativeHost.exe')) 'Relative paths must be rejected.' + +$developmentExtensionId = 'gilicenlclaemgiijcjjejilikbooggj' +$additionalExtensionId = 'abcdefghijklmnopabcdefghijklmnop' +$origins = @(Get-NativeHostAllowedOrigins $developmentExtensionId @($additionalExtensionId, 'invalid-id', $developmentExtensionId)) +Assert-True ($origins.Count -eq 2) 'Only unique extension IDs in the Chromium a-p alphabet must be retained.' +Assert-True ($origins -contains "chrome-extension://$developmentExtensionId/") 'The development extension origin is required.' +Assert-True ($origins -contains "chrome-extension://$additionalExtensionId/") 'A valid additional extension origin must be retained.' + +$temporaryFile = [IO.Path]::GetTempFileName() +try { + Write-Utf8WithoutBom $temporaryFile '{"name":"com.eslee.download_router"}' + $bytes = [IO.File]::ReadAllBytes($temporaryFile) + $hasUtf8Bom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + Assert-True (-not $hasUtf8Bom) 'Native Messaging manifests must be UTF-8 without BOM.' + $parsed = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json + Assert-True ($parsed.name -eq 'com.eslee.download_router') 'The BOM-free manifest must remain valid JSON.' +} +finally { + if (Test-Path -LiteralPath $temporaryFile) { + Remove-Item -LiteralPath $temporaryFile -Force + } +} + +Write-Host 'Native Host registration compatibility tests passed.' diff --git a/scripts/test.ps1 b/scripts/test.ps1 index 4361e70..342eb3c 100644 --- a/scripts/test.ps1 +++ b/scripts/test.ps1 @@ -9,6 +9,8 @@ $root = Get-RepositoryRoot $dotnet = Assert-DotNetVersion $nodeTools = Assert-NodeTools +& (Join-Path $PSScriptRoot 'test-native-host-registration.ps1') + & $dotnet test (Join-Path $root 'DownloadRouter.slnx') --configuration $Configuration --no-build --no-restore --nologo if ($LASTEXITCODE -ne 0) { throw '.NET tests failed.' } diff --git a/scripts/unregister-native-host.ps1 b/scripts/unregister-native-host.ps1 index bd24b91..c761097 100644 --- a/scripts/unregister-native-host.ps1 +++ b/scripts/unregister-native-host.ps1 @@ -5,3 +5,8 @@ param( ) & (Join-Path $PSScriptRoot 'register-native-host.ps1') -Action Unregister -Browser $Browser + +$manifestPath = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'eslee\DownloadRouter\native-host\com.eslee.download_router.json' +if (Test-Path -LiteralPath $manifestPath -PathType Leaf) { + Remove-Item -LiteralPath $manifestPath -Force +} diff --git a/scripts/unregister-startup.ps1 b/scripts/unregister-startup.ps1 new file mode 100644 index 0000000..8c0adcc --- /dev/null +++ b/scripts/unregister-startup.ps1 @@ -0,0 +1,9 @@ +[CmdletBinding()] +param() + +$runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' +if (Test-Path -LiteralPath $runKey) { + Remove-ItemProperty -LiteralPath $runKey -Name 'eslee Download Router' -ErrorAction SilentlyContinue +} + +Write-Host 'Removed current-user login startup registration.' diff --git a/scripts/whale-test-server.mjs b/scripts/whale-test-server.mjs new file mode 100644 index 0000000..431acde --- /dev/null +++ b/scripts/whale-test-server.mjs @@ -0,0 +1,84 @@ +import http from "node:http"; + +const port = Number.parseInt(process.argv[2] ?? "8765", 10); +const host = "127.0.0.1"; + +const page = ` + +eslee Download Router Whale validation +

Whale validation downloads

+`; + +const fixtures = new Map([ + ["/automatic", ["whale-automatic-test.txt", "automatic routing fixture\n"]], + ["/select", ["whale-tree-test.txt", "tree selection fixture\n"]], +]); + +const server = http.createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://${host}:${port}`); + if (url.pathname === "/") { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(page); + return; + } + + const fixture = fixtures.get(url.pathname); + if (fixture) { + const [fileName, body] = fixture; + response.writeHead(200, { + "content-type": "text/plain; charset=utf-8", + "content-disposition": `attachment; filename="${fileName}"`, + "content-length": Buffer.byteLength(body), + }); + response.end(body); + return; + } + + if (url.pathname === "/pending") { + const body = "pending filename fixture\n"; + response.writeHead(200, { + "content-type": "application/octet-stream", + "content-length": Buffer.byteLength(body), + }); + response.end(body); + return; + } + + if (url.pathname === "/slow") { + const chunk = Buffer.alloc(64 * 1024, 0x61); + const chunks = 200; + let sent = 0; + response.writeHead(200, { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=whale-cancel-test.bin", + "content-length": chunk.length * chunks, + }); + const timer = setInterval(() => { + if (response.destroyed || sent >= chunks) { + clearInterval(timer); + if (!response.destroyed) response.end(); + return; + } + response.write(chunk); + sent += 1; + }, 100); + response.on("close", () => clearInterval(timer)); + return; + } + + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("not found\n"); +}); + +server.listen(port, host, () => { + process.stdout.write(`Whale validation server listening on http://${host}:${port}/\n`); +}); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/src/DownloadRouter.Agent/AgentCommandHandler.cs b/src/DownloadRouter.Agent/AgentCommandHandler.cs index 11b1483..082dcc2 100644 --- a/src/DownloadRouter.Agent/AgentCommandHandler.cs +++ b/src/DownloadRouter.Agent/AgentCommandHandler.cs @@ -19,6 +19,7 @@ public sealed class AgentCommandHandler( PathBoundaryValidator boundaryValidator, DownloadJobStateMachine stateMachine, FileMoveService fileMoveService, + ISelectionUiLauncher selectionUiLauncher, AppPaths paths, ILogger logger) { @@ -50,11 +51,19 @@ public async Task HandleAsync(AgentCommand request, CancellationT timestamp = DateTimeOffset.UtcNow, }), "download.started" => await HandleDownloadStartedAsync(request, cancellationToken).ConfigureAwait(false), + "download.metadata" => await HandleDownloadMetadataAsync(request, cancellationToken).ConfigureAwait(false), "download.changed" => await HandleDownloadChangedAsync(request, cancellationToken).ConfigureAwait(false), + "download.cancelled" => await HandleDownloadChangedAsync(request, cancellationToken, "cancelled").ConfigureAwait(false), + "download.interrupted" => await HandleDownloadChangedAsync(request, cancellationToken, "interrupted").ConfigureAwait(false), "rules.list" => AgentResponse.Ok(request.RequestId, await repository.GetRulesAsync(cancellationToken).ConfigureAwait(false)), "rules.upsert" => await HandleRuleUpsertAsync(request, cancellationToken).ConfigureAwait(false), + "rules.delete" => await HandleRuleDeleteAsync(request, cancellationToken).ConfigureAwait(false), "jobs.list" => AgentResponse.Ok(request.RequestId, await repository.GetRecentJobsAsync(cancellationToken: cancellationToken).ConfigureAwait(false)), + "jobs.delete" => await HandleJobsDeleteAsync(request, cancellationToken).ConfigureAwait(false), + "downloads.active" => await HandleActiveDownloadsAsync(request, cancellationToken).ConfigureAwait(false), "selection.complete" => await HandleSelectionCompletedAsync(request, cancellationToken).ConfigureAwait(false), + "selection.skip" => await HandleSelectionSkippedAsync(request, cancellationToken).ConfigureAwait(false), + "route.change" => await HandleRouteChangeAsync(request, cancellationToken).ConfigureAwait(false), "job.retry" => await HandleRetryAsync(request, cancellationToken).ConfigureAwait(false), "diagnostics.status" => AgentResponse.Ok(request.RequestId, new { @@ -97,22 +106,26 @@ private async Task HandleDownloadStartedAsync( { var payload = Deserialize(request.Payload); if (!TryParseBrowser(payload.Browser, out var browser) - || string.IsNullOrWhiteSpace(payload.DownloadId) - || string.IsNullOrWhiteSpace(payload.FileName)) + || string.IsNullOrWhiteSpace(payload.DownloadId)) { - return AgentResponse.Error(request.RequestId, "download.invalid-metadata", "Browser, download ID, and file name are required."); + return AgentResponse.Error(request.RequestId, "download.invalid-metadata", "Browser and download ID are required."); } var existing = await repository.GetJobAsync(browser, payload.DownloadId, cancellationToken).ConfigureAwait(false); if (existing is not null) { + existing = await UpdateFileNameAsync(existing, payload.FileName, payload.FilePath, cancellationToken).ConfigureAwait(false); return AgentResponse.Ok(request.RequestId, new { tracked = true, jobId = existing.Id, existing = true }); } + var trustedFileName = DownloadPresentation.TrustedFileName(payload.FileName) + ?? DownloadPresentation.TrustedFileName(payload.FilePath) + ?? string.Empty; + var metadata = new DownloadMetadata( browser, payload.DownloadId, - Path.GetFileName(payload.FileName), + trustedFileName, payload.FilePath, payload.InitiatingPageUrl, payload.InitialUrl, @@ -123,19 +136,21 @@ private async Task HandleDownloadStartedAsync( var matched = matcher.Match(rules, metadata); if (matched is null) { - logger.LogInformation("No enabled rule matched download {BrowserDownloadId}; browser behavior remains unchanged", payload.DownloadId); + logger.LogInformation( + "No enabled rule matched download {BrowserDownloadId}; browser behavior remains unchanged", + SanitizeDownloadId(payload.DownloadId)); return AgentResponse.Ok(request.RequestId, new { tracked = false, failOpen = true }); } - var status = matched.Rule.StorageMode == StorageMode.SelectSubfolder - ? DownloadJobStatus.WaitingForSelection - : DownloadJobStatus.WaitingForDownload; + var routingState = matched.Rule.StorageMode == StorageMode.SelectSubfolder + ? RoutingState.WaitingForSelection + : RoutingState.NotRequired; var job = new DownloadJob( Guid.NewGuid(), browser, payload.DownloadId, - Path.GetFileName(payload.FileName), - Path.GetFileName(payload.FileName), + trustedFileName, + trustedFileName, sanitizer.Sanitize(payload.InitiatingPageUrl), sanitizer.Sanitize(payload.InitialUrl), sanitizer.Sanitize(payload.FinalUrl), @@ -145,17 +160,21 @@ private async Task HandleDownloadStartedAsync( NormalizeOptionalSourcePath(payload.FilePath), null, null, - status, + BrowserTransferState.InProgress, + routingState, null, null, DateTimeOffset.UtcNow, - null); + null, + DateTimeOffset.UtcNow); await repository.CreateJobAsync(job, cancellationToken).ConfigureAwait(false); + var selectionUiRequested = matched.Rule.StorageMode == StorageMode.SelectSubfolder + && selectionUiLauncher.RequestSelectionUi(); logger.LogInformation( "Rule {RuleId} matched download {DownloadId} using {SourceField}", matched.Rule.Id, - payload.DownloadId, + SanitizeDownloadId(payload.DownloadId), matched.SourceField); return AgentResponse.Ok(request.RequestId, new { @@ -163,12 +182,40 @@ private async Task HandleDownloadStartedAsync( jobId = job.Id, storageMode = matched.Rule.StorageMode.ToString(), requiresSelection = matched.Rule.StorageMode == StorageMode.SelectSubfolder, + selectionUiRequested, }); } - private async Task HandleDownloadChangedAsync( + private async Task HandleDownloadMetadataAsync( AgentCommand request, CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + if (!TryParseBrowser(payload.Browser, out var browser)) + { + return AgentResponse.Error(request.RequestId, "download.unknown-browser", "The browser is not supported."); + } + + var job = await repository.GetJobAsync(browser, payload.DownloadId, cancellationToken).ConfigureAwait(false); + if (job is null) + { + return AgentResponse.Ok(request.RequestId, new { tracked = false, failOpen = true }); + } + + var updated = await UpdateFileNameAsync(job, payload.FileName, payload.FilePath, cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, new + { + tracked = true, + jobId = updated.Id, + fileName = DownloadPresentation.DisplayFileName(updated), + changed = !string.Equals(job.CurrentFileName, updated.CurrentFileName, StringComparison.Ordinal), + }); + } + + private async Task HandleDownloadChangedAsync( + AgentCommand request, + CancellationToken cancellationToken, + string? forcedState = null) { var payload = Deserialize(request.Payload); if (!TryParseBrowser(payload.Browser, out var browser)) @@ -176,70 +223,172 @@ private async Task HandleDownloadChangedAsync( return AgentResponse.Error(request.RequestId, "download.unknown-browser", "The browser is not supported."); } + var sanitizedDownloadId = SanitizeDownloadId(payload.DownloadId); + var state = (forcedState ?? payload.State).Trim().ToLowerInvariant(); + logger.LogInformation( + "Browser command received: command={Command}, browser={Browser}, downloadId={DownloadId}, state={State}, error={Error}", + request.Command, + browser, + sanitizedDownloadId, + SanitizeState(state), + NormalizeBrowserError(payload.Error)); + var job = await repository.GetJobAsync(browser, payload.DownloadId, cancellationToken).ConfigureAwait(false); if (job is null) { return AgentResponse.Ok(request.RequestId, new { tracked = false, failOpen = true }); } - var state = payload.State.Trim().ToLowerInvariant(); + var beforeBrowser = job.BrowserState; + var beforeRouting = job.RoutingState; + if (state == "stale") + { + if (job.BrowserState == BrowserTransferState.InProgress && !job.IsBrowserRecordStale) + { + job = job with { IsBrowserRecordStale = true }; + await repository.UpdateJobAsync(job, "download.browser-record-stale", cancellationToken).ConfigureAwait(false); + } + + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); + return AgentResponse.Ok(request.RequestId, JobState(job)); + } + + if (state == "in_progress") + { + if (job.BrowserState == BrowserTransferState.InProgress) + { + job = job with + { + LastBrowserEventAt = payload.IsReconciliation + ? job.LastBrowserEventAt + : DateTimeOffset.UtcNow, + IsBrowserRecordStale = false, + }; + await repository.UpdateJobAsync(job, "download.reconciled-in-progress", cancellationToken).ConfigureAwait(false); + } + + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); + return AgentResponse.Ok(request.RequestId, JobState(job)); + } + if (state is "interrupted" or "cancelled") { var cancelled = string.Equals(payload.Error, "USER_CANCELED", StringComparison.OrdinalIgnoreCase) || state == "cancelled"; - var next = cancelled ? DownloadJobStatus.Cancelled : DownloadJobStatus.Interrupted; - stateMachine.EnsureCanTransition(job.Status, next); + var next = cancelled ? BrowserTransferState.Cancelled : BrowserTransferState.Interrupted; + if (job.BrowserState != BrowserTransferState.InProgress) + { + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); + return AgentResponse.Ok(request.RequestId, JobState(job)); + } + + stateMachine.EnsureCanTransition(job.BrowserState, next); + var routing = cancelled + ? job.RoutingState is RoutingState.WaitingForSelection or RoutingState.SelectionReady + ? RoutingState.NotRequired + : job.RoutingState + : job.RoutingState is RoutingState.Completed or RoutingState.Skipped + ? job.RoutingState + : RoutingState.Failed; + if (!cancelled) + { + stateMachine.EnsureCanTransition(job.RoutingState, routing); + } + var reportedFileName = string.IsNullOrWhiteSpace(payload.FilePath) + ? job.CurrentFileName + : DownloadPresentation.TrustedFileName(payload.FileName) + ?? DownloadPresentation.TrustedFileName(NormalizeOptionalSourcePath(payload.FilePath)) + ?? job.CurrentFileName; job = job with { - Status = next, - ErrorCode = cancelled ? "download.cancelled" : "download.interrupted", - ErrorMessage = payload.Error, + BrowserState = next, + RoutingState = routing, + CurrentFileName = reportedFileName, + ErrorCode = cancelled ? "download.cancelled" : "download.interrupted." + NormalizeBrowserError(payload.Error), + ErrorMessage = cancelled + ? "The user cancelled this download in the browser." + : "The browser interrupted this download before completion.", + CompletedAt = DateTimeOffset.UtcNow, + LastBrowserEventAt = payload.IsReconciliation + ? job.LastBrowserEventAt + : DateTimeOffset.UtcNow, + IsBrowserRecordStale = false, }; - await repository.UpdateJobAsync(job, "download.interrupted", cancellationToken).ConfigureAwait(false); - return AgentResponse.Ok(request.RequestId, new { tracked = true, status = next.ToString() }); + await repository.UpdateJobAsync(job, cancelled ? "download.cancelled" : "download.interrupted", cancellationToken).ConfigureAwait(false); + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); + return AgentResponse.Ok(request.RequestId, JobState(job)); } if (state != "complete") { - return AgentResponse.Ok(request.RequestId, new { tracked = true, status = job.Status.ToString() }); + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); + return AgentResponse.Ok(request.RequestId, JobState(job)); + } + + if (job.BrowserState != BrowserTransferState.InProgress) + { + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); + return AgentResponse.Ok(request.RequestId, JobState(job)); } + stateMachine.EnsureCanTransition(job.BrowserState, BrowserTransferState.Complete); var sourcePath = NormalizeOptionalSourcePath(payload.FilePath) ?? throw new ArgumentException("A completed download must include its final local path."); job = job with { OriginalPath = sourcePath, - CurrentFileName = Path.GetFileName(sourcePath), + CurrentFileName = DownloadPresentation.TrustedFileName(payload.FileName) + ?? DownloadPresentation.TrustedFileName(sourcePath) + ?? job.CurrentFileName, + BrowserState = BrowserTransferState.Complete, ErrorCode = null, ErrorMessage = null, + LastBrowserEventAt = payload.IsReconciliation + ? job.LastBrowserEventAt + : DateTimeOffset.UtcNow, + IsBrowserRecordStale = false, }; var rule = await repository.GetRuleAsync(job.RuleId, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The matched rule no longer exists."); - if (rule.StorageMode == StorageMode.SelectSubfolder && job.SelectedRelativeFolder is null) + if (job.RoutingState == RoutingState.WaitingForSelection) { - if (job.Status != DownloadJobStatus.WaitingForSelection) + await repository.UpdateJobAsync(job, "download.completed-selection-pending", cancellationToken).ConfigureAwait(false); + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); + return AgentResponse.Ok(request.RequestId, new { - stateMachine.EnsureCanTransition(job.Status, DownloadJobStatus.WaitingForSelection); - } - - job = job with { Status = DownloadJobStatus.WaitingForSelection }; - await repository.UpdateJobAsync(job, "selection.waiting", cancellationToken).ConfigureAwait(false); - return AgentResponse.Ok(request.RequestId, new { tracked = true, status = job.Status.ToString(), requiresSelection = true }); + tracked = true, + status = job.Status.ToString(), + browserState = job.BrowserState.ToString(), + routingState = job.RoutingState.ToString(), + requiresSelection = true, + }); } - if (job.Status != DownloadJobStatus.ReadyToMove) + if (job.RoutingState == RoutingState.Skipped) { - stateMachine.EnsureCanTransition(job.Status, DownloadJobStatus.ReadyToMove); + job = job with { CompletedAt = DateTimeOffset.UtcNow }; + await repository.UpdateJobAsync(job, "download.completed-routing-skipped", cancellationToken).ConfigureAwait(false); + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); + return AgentResponse.Ok(request.RequestId, new + { + tracked = true, + status = job.Status.ToString(), + browserState = job.BrowserState.ToString(), + routingState = job.RoutingState.ToString(), + destinationPath = (string?)null, + }); } - job = job with { Status = DownloadJobStatus.ReadyToMove }; await repository.UpdateJobAsync(job, "download.completed", cancellationToken).ConfigureAwait(false); var moved = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, moved); return AgentResponse.Ok(request.RequestId, new { tracked = true, status = moved.Status.ToString(), + browserState = moved.BrowserState.ToString(), + routingState = moved.RoutingState.ToString(), destinationPath = moved.FinalPath, errorCode = moved.ErrorCode, }); @@ -251,10 +400,27 @@ private async Task HandleRuleUpsertAsync( { var rule = Deserialize(request.Payload); _ = pathTokenResolver.Resolve(rule.StorageRoot); - await repository.UpsertRuleAsync(rule with { UpdatedAt = DateTimeOffset.UtcNow }, cancellationToken).ConfigureAwait(false); + var existing = await repository.GetRuleAsync(rule.Id, cancellationToken).ConfigureAwait(false); + var persisted = rule with + { + CreatedAt = existing?.CreatedAt ?? rule.CreatedAt, + UpdatedAt = DateTimeOffset.UtcNow, + }; + await repository.UpsertRuleAsync(persisted, cancellationToken).ConfigureAwait(false); return AgentResponse.Ok(request.RequestId, new { ruleId = rule.Id }); } + private async Task HandleRuleDeleteAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + var deleted = await repository.DeleteRuleAsync(payload.RuleId, cancellationToken).ConfigureAwait(false); + return deleted + ? AgentResponse.Ok(request.RequestId, new { ruleId = payload.RuleId, jobsDeleted = 0, filesDeleted = 0 }) + : AgentResponse.Error(request.RequestId, "rule.not-found", "The rule was not found or was already deleted."); + } + private async Task HandleSelectionCompletedAsync( AgentCommand request, CancellationToken cancellationToken) @@ -269,7 +435,7 @@ private async Task HandleSelectionCompletedAsync( foreach (var jobId in payload.JobIds.Distinct()) { var job = await repository.GetJobAsync(jobId, cancellationToken).ConfigureAwait(false); - if (job is null || job.Status is DownloadJobStatus.Completed or DownloadJobStatus.Cancelled) + if (job is null || !job.IsSelectionPending) { continue; } @@ -279,30 +445,163 @@ private async Task HandleSelectionCompletedAsync( var root = pathTokenResolver.Resolve(rule.StorageRoot); _ = boundaryValidator.ValidateRelativeFolder(root, payload.RelativeFolder); - var next = job.OriginalPath is null - ? DownloadJobStatus.WaitingForDownload - : DownloadJobStatus.ReadyToMove; - stateMachine.EnsureCanTransition(job.Status, next); + stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.SelectionReady); job = job with { SelectedRelativeFolder = payload.RelativeFolder, - Status = next, + RoutingState = RoutingState.SelectionReady, ErrorCode = null, ErrorMessage = null, }; await repository.UpdateJobAsync(job, "selection.completed", cancellationToken).ConfigureAwait(false); - if (job.OriginalPath is not null) + if (job.BrowserState == BrowserTransferState.Complete && job.OriginalPath is not null) { job = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); } - results.Add(new { jobId = job.Id, status = job.Status.ToString(), destinationPath = job.FinalPath }); + results.Add(new + { + jobId = job.Id, + status = job.Status.ToString(), + browserState = job.BrowserState.ToString(), + routingState = job.RoutingState.ToString(), + destinationPath = job.FinalPath, + }); } return AgentResponse.Ok(request.RequestId, results); } + private async Task HandleSelectionSkippedAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + var job = await repository.GetJobAsync(payload.JobId, cancellationToken).ConfigureAwait(false); + if (job is null) + { + return AgentResponse.Error(request.RequestId, "job.not-found", "The download job was not found."); + } + + if (job.BrowserState is not (BrowserTransferState.InProgress or BrowserTransferState.Complete) + || job.RoutingState is not (RoutingState.WaitingForSelection or RoutingState.SelectionReady)) + { + return AgentResponse.Error(request.RequestId, "selection.not-pending", "This download can no longer skip folder routing."); + } + + stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.Skipped); + job = job with + { + RoutingState = RoutingState.Skipped, + ErrorCode = null, + ErrorMessage = null, + CompletedAt = DateTimeOffset.UtcNow, + }; + await repository.UpdateJobAsync(job, "selection.skipped", cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, JobState(job)); + } + + private async Task HandleJobsDeleteAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + if (payload.JobIds.Count is < 1 or > 1000) + { + return AgentResponse.Error(request.RequestId, "jobs.invalid-count", "Delete between 1 and 1000 history items."); + } + + var deleted = await repository.DeleteJobsAsync(payload.JobIds, cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, new { deleted, filesDeleted = 0 }); + } + + private async Task HandleRouteChangeAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + var job = await repository.GetJobAsync(payload.JobId, cancellationToken).ConfigureAwait(false); + if (job is null) + { + return AgentResponse.Error(request.RequestId, "job.not-found", "The download job was not found."); + } + + if (!DownloadPresentation.CanChangeRoute(job)) + { + return AgentResponse.Error( + request.RequestId, + "route.change-not-allowed", + job.BrowserState is BrowserTransferState.Cancelled or BrowserTransferState.Interrupted + ? "Cancelled or interrupted downloads cannot change destination." + : "This download state cannot change destination."); + } + + var rule = await repository.GetRuleAsync(job.RuleId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The matched rule no longer exists."); + var root = pathTokenResolver.Resolve(rule.StorageRoot); + _ = boundaryValidator.ValidateRelativeFolder(root, payload.RelativeFolder); + + if (job.RoutingState == RoutingState.Completed) + { + if (!payload.ConfirmCompletedMove) + { + return AgentResponse.Error(request.RequestId, "route.confirmation-required", "Moving an already routed file requires explicit confirmation."); + } + + if (string.IsNullOrWhiteSpace(job.FinalPath) || !File.Exists(job.FinalPath)) + { + return AgentResponse.Error(request.RequestId, "file.source-missing", "The routed file no longer exists at its recorded location."); + } + + job = job with + { + OriginalPath = job.FinalPath, + SelectedRelativeFolder = payload.RelativeFolder, + ErrorCode = null, + ErrorMessage = null, + }; + var movedAgain = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, JobRouteState(movedAgain)); + } + + if (job.RoutingState is RoutingState.WaitingForSelection or RoutingState.Skipped) + { + stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.SelectionReady); + job = job with { RoutingState = RoutingState.SelectionReady }; + } + + job = job with + { + SelectedRelativeFolder = payload.RelativeFolder, + ErrorCode = null, + ErrorMessage = null, + CompletedAt = null, + }; + await repository.UpdateJobAsync(job, "route.changed", cancellationToken).ConfigureAwait(false); + + if (job.BrowserState == BrowserTransferState.Complete && job.OriginalPath is not null) + { + job = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); + } + + return AgentResponse.Ok(request.RequestId, JobRouteState(job)); + } + + private async Task HandleActiveDownloadsAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + if (!TryParseBrowser(payload.Browser, out var browser)) + { + return AgentResponse.Error(request.RequestId, "download.unknown-browser", "The browser is not supported."); + } + + var downloads = await repository.GetActiveBrowserDownloadsAsync(browser, cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, downloads); + } + private async Task HandleRetryAsync(AgentCommand request, CancellationToken cancellationToken) { var payload = request.Payload.Deserialize(ProtocolJson.Options) @@ -318,12 +617,14 @@ private async Task HandleRetryAsync(AgentCommand request, Cancell return AgentResponse.Error(request.RequestId, "file.source-missing", "The original download path is unknown."); } - stateMachine.EnsureCanTransition(job.Status, DownloadJobStatus.RetryPending); - job = job with { Status = DownloadJobStatus.RetryPending, ErrorCode = null, ErrorMessage = null }; + if (job.BrowserState != BrowserTransferState.Complete) + { + return AgentResponse.Error(request.RequestId, "download.not-complete", "Only completed browser downloads can be retried."); + } + + stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.RetryPending); + job = job with { RoutingState = RoutingState.RetryPending, ErrorCode = null, ErrorMessage = null }; await repository.UpdateJobAsync(job, "job.retry-requested", cancellationToken).ConfigureAwait(false); - stateMachine.EnsureCanTransition(job.Status, DownloadJobStatus.ReadyToMove); - job = job with { Status = DownloadJobStatus.ReadyToMove }; - await repository.UpdateJobAsync(job, "job.retry-ready", cancellationToken).ConfigureAwait(false); var rule = await repository.GetRuleAsync(job.RuleId, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The matched rule no longer exists."); job = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); @@ -340,8 +641,13 @@ private async Task MoveJobAsync( throw new InvalidOperationException("Cannot move a job without a source path."); } - stateMachine.EnsureCanTransition(job.Status, DownloadJobStatus.Moving); - job = job with { Status = DownloadJobStatus.Moving }; + if (job.BrowserState != BrowserTransferState.Complete) + { + throw new InvalidOperationException("Cannot move a download before the browser reports completion."); + } + + stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.Moving); + job = job with { RoutingState = RoutingState.Moving }; await repository.UpdateJobAsync(job, "file.move-started", cancellationToken).ConfigureAwait(false); var root = pathTokenResolver.Resolve(rule.StorageRoot); @@ -352,12 +658,12 @@ private async Task MoveJobAsync( browserReportedComplete: true, cancellationToken).ConfigureAwait(false); var next = result.Success - ? DownloadJobStatus.Completed - : result.CanRetry ? DownloadJobStatus.RetryPending : DownloadJobStatus.Failed; - stateMachine.EnsureCanTransition(job.Status, next); + ? RoutingState.Completed + : result.CanRetry ? RoutingState.RetryPending : RoutingState.Failed; + stateMachine.EnsureCanTransition(job.RoutingState, next); job = job with { - Status = next, + RoutingState = next, FinalPath = result.DestinationPath, ErrorCode = result.ErrorCode, ErrorMessage = result.ErrorMessage, @@ -389,5 +695,118 @@ private static bool TryParseBrowser(string value, out BrowserKind browser) return Path.GetFullPath(path); } + private static string NormalizeBrowserError(string? value) + { + var known = value?.Trim().ToUpperInvariant(); + return known switch + { + "FILE_FAILED" => "file-failed", + "FILE_ACCESS_DENIED" => "file-access-denied", + "FILE_NO_SPACE" => "file-no-space", + "FILE_NAME_TOO_LONG" => "file-name-too-long", + "FILE_TOO_LARGE" => "file-too-large", + "FILE_VIRUS_INFECTED" => "file-virus-infected", + "FILE_TRANSIENT_ERROR" => "file-transient-error", + "FILE_BLOCKED" => "file-blocked", + "FILE_SECURITY_CHECK_FAILED" => "file-security-check-failed", + "FILE_TOO_SHORT" => "file-too-short", + "FILE_HASH_MISMATCH" => "file-hash-mismatch", + "NETWORK_FAILED" => "network-failed", + "NETWORK_TIMEOUT" => "network-timeout", + "NETWORK_DISCONNECTED" => "network-disconnected", + "NETWORK_SERVER_DOWN" => "network-server-down", + "NETWORK_INVALID_REQUEST" => "network-invalid-request", + "SERVER_FAILED" => "server-failed", + "SERVER_NO_RANGE" => "server-no-range", + "SERVER_BAD_CONTENT" => "server-bad-content", + "SERVER_UNAUTHORIZED" => "server-unauthorized", + "SERVER_CERT_PROBLEM" => "server-cert-problem", + "SERVER_FORBIDDEN" => "server-forbidden", + "SERVER_UNREACHABLE" => "server-unreachable", + "SERVER_CONTENT_LENGTH_MISMATCH" => "server-content-length-mismatch", + "SERVER_CROSS_ORIGIN_REDIRECT" => "server-cross-origin-redirect", + "USER_SHUTDOWN" => "user-shutdown", + "CRASH" => "crash", + _ => "unknown", + }; + } + + private static object JobState(DownloadJob job) + => new + { + tracked = true, + status = job.Status.ToString(), + browserState = job.BrowserState.ToString(), + routingState = job.RoutingState.ToString(), + }; + + private async Task UpdateFileNameAsync( + DownloadJob job, + string? reportedFileName, + string? reportedFilePath, + CancellationToken cancellationToken) + { + var trusted = DownloadPresentation.TrustedFileName(reportedFileName) + ?? DownloadPresentation.TrustedFileName(reportedFilePath); + var updated = job with + { + CurrentFileName = trusted ?? job.CurrentFileName, + OriginalFileName = trusted is not null && string.IsNullOrWhiteSpace(job.OriginalFileName) + ? trusted + : job.OriginalFileName, + LastBrowserEventAt = DateTimeOffset.UtcNow, + IsBrowserRecordStale = false, + }; + await repository.UpdateJobAsync( + updated, + trusted is not null && !string.Equals(trusted, job.CurrentFileName, StringComparison.Ordinal) + ? "download.filename-updated" + : "download.browser-event", + cancellationToken).ConfigureAwait(false); + return updated; + } + + private void LogTransition( + string downloadId, + string command, + BrowserTransferState beforeBrowser, + RoutingState beforeRouting, + DownloadJob after) + => logger.LogInformation( + "Browser command applied: command={Command}, downloadId={DownloadId}, browserState={BeforeBrowser}->{AfterBrowser}, routingState={BeforeRouting}->{AfterRouting}", + command, + downloadId, + beforeBrowser, + after.BrowserState, + beforeRouting, + after.RoutingState); + + private static string SanitizeDownloadId(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "invalid"; + } + + var sanitized = new string(value.Where(static character => + char.IsAsciiLetterOrDigit(character) || character is '-' or '_' or '.').Take(64).ToArray()); + return sanitized.Length == 0 ? "invalid" : sanitized; + } + + private static string SanitizeState(string? value) + => value is "complete" or "interrupted" or "cancelled" or "in_progress" or "stale" + ? value + : "unknown"; + + private static object JobRouteState(DownloadJob job) + => new + { + jobId = job.Id, + status = job.Status.ToString(), + browserState = job.BrowserState.ToString(), + routingState = job.RoutingState.ToString(), + destinationPath = job.FinalPath, + }; + private sealed record JobRetryPayload(Guid JobId); } diff --git a/src/DownloadRouter.Agent/Program.cs b/src/DownloadRouter.Agent/Program.cs index 82dc74c..f6e2362 100644 --- a/src/DownloadRouter.Agent/Program.cs +++ b/src/DownloadRouter.Agent/Program.cs @@ -1,5 +1,6 @@ using DownloadRouter.Agent; using DownloadRouter.Core.Jobs; +using DownloadRouter.Core.Models; using DownloadRouter.Core.Paths; using DownloadRouter.Core.Privacy; using DownloadRouter.Core.Rules; @@ -38,6 +39,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); @@ -45,5 +47,22 @@ var repository = host.Services.GetRequiredService(); await repository.InitializeAsync().ConfigureAwait(false); await repository.RecoverInProgressJobsAsync().ConfigureAwait(false); -await host.RunAsync().ConfigureAwait(false); +using var shutdownEvent = new EventWaitHandle( + initialState: false, + EventResetMode.AutoReset, + ProtocolConstants.AgentShutdownEventName); +var shutdownRegistration = ThreadPool.RegisterWaitForSingleObject( + shutdownEvent, + (_, _) => _ = host.StopAsync(), + null, + Timeout.Infinite, + executeOnlyOnce: true); +try +{ + await host.RunAsync().ConfigureAwait(false); +} +finally +{ + shutdownRegistration.Unregister(null); +} return 0; diff --git a/src/DownloadRouter.Agent/SelectionUiLauncher.cs b/src/DownloadRouter.Agent/SelectionUiLauncher.cs new file mode 100644 index 0000000..71c0910 --- /dev/null +++ b/src/DownloadRouter.Agent/SelectionUiLauncher.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; +using DownloadRouter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace DownloadRouter.Agent; + +public interface ISelectionUiLauncher +{ + bool RequestSelectionUi(); +} + +public sealed class SelectionUiLauncher(ILogger logger) : ISelectionUiLauncher +{ + private readonly object gate = new(); + private string? cachedAppPath; + + public bool RequestSelectionUi() + { + lock (gate) + { + try + { + if (Mutex.TryOpenExisting(ProtocolConstants.AppMutexName, out var existing)) + { + existing.Dispose(); + return true; + } + + var appPath = cachedAppPath ??= FindAppPath(); + if (appPath is null) + { + logger.LogInformation("Selection UI executable was not found; the download remains pending and unchanged"); + return false; + } + + Process.Start(new ProcessStartInfo + { + FileName = appPath, + UseShellExecute = true, + WorkingDirectory = Path.GetDirectoryName(appPath)!, + }); + return true; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Selection UI could not be opened; the browser download remains unchanged"); + return false; + } + } + } + + private static string? FindAppPath() + { + var overridePath = Environment.GetEnvironmentVariable("DOWNLOAD_ROUTER_APP_PATH"); + if (!string.IsNullOrWhiteSpace(overridePath) + && Path.IsPathFullyQualified(overridePath) + && File.Exists(overridePath)) + { + return Path.GetFullPath(overridePath); + } + + var installed = Path.Combine(AppContext.BaseDirectory, "DownloadRouter.App.exe"); + if (File.Exists(installed)) + { + return installed; + } + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "DownloadRouter.slnx"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + return null; + } + + var configuration = AppContext.BaseDirectory.Contains( + $"{Path.DirectorySeparatorChar}Release{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase) + ? "Release" + : "Debug"; + var development = Path.Combine( + directory.FullName, + "src", + "DownloadRouter.App", + "bin", + configuration, + "net10.0-windows10.0.22621.0", + "win-x64", + "DownloadRouter.App.exe"); + return File.Exists(development) ? development : null; + } +} diff --git a/src/DownloadRouter.App/AgentProcessManager.cs b/src/DownloadRouter.App/AgentProcessManager.cs new file mode 100644 index 0000000..1d38dbf --- /dev/null +++ b/src/DownloadRouter.App/AgentProcessManager.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using DownloadRouter.Core.Models; + +namespace DownloadRouter.App; + +public sealed class AgentProcessManager(AgentClient client) +{ + public static void RequestShutdown() + { + try + { + using var shutdownEvent = EventWaitHandle.OpenExisting(ProtocolConstants.AgentShutdownEventName); + shutdownEvent.Set(); + } + catch (WaitHandleCannotBeOpenedException) + { + } + } + + public async Task EnsureRunningAsync(CancellationToken cancellationToken = default) + { + if (await CanPingAsync(cancellationToken)) + { + return true; + } + + var agentPath = FindAgentPath(); + if (agentPath is null) + { + return false; + } + + Process.Start(new ProcessStartInfo + { + FileName = agentPath, + WorkingDirectory = Path.GetDirectoryName(agentPath)!, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + }); + for (var attempt = 0; attempt < 20; attempt++) + { + await Task.Delay(150, cancellationToken); + if (await CanPingAsync(cancellationToken)) + { + return true; + } + } + + return false; + } + + private async Task CanPingAsync(CancellationToken cancellationToken) + { + try + { + return (await client.SendAsync("ping", cancellationToken: cancellationToken)).Success; + } + catch (Exception exception) when (exception is IOException or TimeoutException or OperationCanceledException) + { + return false; + } + } + + private static string? FindAgentPath() + { + var installed = Path.Combine(AppContext.BaseDirectory, "DownloadRouter.Agent.exe"); + if (File.Exists(installed)) + { + return installed; + } + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "DownloadRouter.slnx"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + return null; + } + + var configuration = AppContext.BaseDirectory.Contains( + $"{Path.DirectorySeparatorChar}Release{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase) + ? "Release" + : "Debug"; + var candidate = Path.Combine( + directory.FullName, + "src", + "DownloadRouter.Agent", + "bin", + configuration, + "net10.0", + "DownloadRouter.Agent.exe"); + return File.Exists(candidate) ? candidate : null; + } +} diff --git a/src/DownloadRouter.App/App.xaml b/src/DownloadRouter.App/App.xaml index fd7e321..ca04195 100644 --- a/src/DownloadRouter.App/App.xaml +++ b/src/DownloadRouter.App/App.xaml @@ -4,6 +4,26 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + + + + + + + + + + + + + + + + + + + diff --git a/src/DownloadRouter.App/App.xaml.cs b/src/DownloadRouter.App/App.xaml.cs index c1bdb56..477122a 100644 --- a/src/DownloadRouter.App/App.xaml.cs +++ b/src/DownloadRouter.App/App.xaml.cs @@ -1,19 +1,122 @@ using Microsoft.UI.Xaml; +using DownloadRouter.Core.Models; +using DownloadRouter.Core.Settings; namespace DownloadRouter.App; public partial class App : Application { private Window? window; + private Mutex? singleInstance; + private EventWaitHandle? activationEvent; + private RegisteredWaitHandle? activationRegistration; + private EventWaitHandle? shutdownEvent; + private RegisteredWaitHandle? shutdownRegistration; public App() { InitializeComponent(); + UnhandledException += (_, eventArgs) => + { + try + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "eslee", + "DownloadRouter", + "logs"); + Directory.CreateDirectory(directory); + File.AppendAllText( + Path.Combine(directory, "app-unhandled.log"), + $"{DateTimeOffset.UtcNow:O} {eventArgs.Exception}{Environment.NewLine}"); + } + catch + { + } + }; } protected override void OnLaunched(LaunchActivatedEventArgs args) { - window = new MainWindow(); - window.Activate(); + var commandLine = Environment.GetCommandLineArgs(); + var background = commandLine + .Any(static argument => argument.Equals("--background", StringComparison.OrdinalIgnoreCase)); + var shutdown = commandLine + .Any(static argument => argument.Equals("--shutdown", StringComparison.OrdinalIgnoreCase)); + singleInstance = new Mutex(initiallyOwned: true, ProtocolConstants.AppMutexName, out var createdNew); + if (!createdNew) + { + if (shutdown) + { + SignalExistingInstance(ProtocolConstants.AppShutdownEventName); + AgentProcessManager.RequestShutdown(); + } + else if (!background) + { + SignalExistingInstance(ProtocolConstants.AppActivationEventName); + } + + singleInstance.Dispose(); + singleInstance = null; + Exit(); + return; + } + + if (shutdown) + { + AgentProcessManager.RequestShutdown(); + singleInstance.Dispose(); + singleInstance = null; + Exit(); + return; + } + + var preferencesStore = new AppPreferencesStore(); + var preferences = preferencesStore.Load(); + var themeManager = new ThemeManager(preferences.ThemePreference); + var mainWindow = new MainWindow(preferencesStore, themeManager, preferences); + window = mainWindow; + activationEvent = new EventWaitHandle( + initialState: false, + EventResetMode.AutoReset, + ProtocolConstants.AppActivationEventName); + activationRegistration = ThreadPool.RegisterWaitForSingleObject( + activationEvent, + (_, _) => mainWindow.DispatcherQueue.TryEnqueue(mainWindow.ShowFromTray), + null, + Timeout.Infinite, + executeOnlyOnce: false); + shutdownEvent = new EventWaitHandle( + initialState: false, + EventResetMode.AutoReset, + ProtocolConstants.AppShutdownEventName); + shutdownRegistration = ThreadPool.RegisterWaitForSingleObject( + shutdownEvent, + (_, _) => mainWindow.DispatcherQueue.TryEnqueue(mainWindow.ExitFromExternalRequest), + null, + Timeout.Infinite, + executeOnlyOnce: true); + mainWindow.InitializeHost(background, () => + { + activationRegistration?.Unregister(null); + shutdownRegistration?.Unregister(null); + activationEvent?.Dispose(); + shutdownEvent?.Dispose(); + singleInstance?.Dispose(); + AgentProcessManager.RequestShutdown(); + Exit(); + }); + } + + private static void SignalExistingInstance(string eventName) + { + try + { + using var existingEvent = EventWaitHandle.OpenExisting(eventName); + existingEvent.Set(); + } + catch (WaitHandleCannotBeOpenedException) + { + } } } diff --git a/src/DownloadRouter.App/DownloadRouter.App.csproj b/src/DownloadRouter.App/DownloadRouter.App.csproj index 8888fca..6ff21a7 100644 --- a/src/DownloadRouter.App/DownloadRouter.App.csproj +++ b/src/DownloadRouter.App/DownloadRouter.App.csproj @@ -18,4 +18,14 @@ + + + + + + + diff --git a/src/DownloadRouter.App/FolderSelectionWindow.cs b/src/DownloadRouter.App/FolderSelectionWindow.cs new file mode 100644 index 0000000..95f09ed --- /dev/null +++ b/src/DownloadRouter.App/FolderSelectionWindow.cs @@ -0,0 +1,448 @@ +using System.Runtime.InteropServices; +using DownloadRouter.Core.Jobs; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.Graphics; + +namespace DownloadRouter.App; + +public enum FolderSelectionAction +{ + Apply, + Later, + LaterAll, + Skip, + Closed, +} + +public sealed record FolderSelectionResult( + FolderSelectionAction Action, + string RelativeFolder); + +public sealed class FolderSelectionWindow( + ThemeManager themeManager, + nint mainWindowHandle, + Action? diagnosticWriter = null) +{ + private const int DefaultWidthDip = 560; + private const int DefaultHeightDip = 760; + private readonly Window window = new(); + private readonly FolderTreePicker picker = new(); + private readonly TextBlock details = new() + { + TextWrapping = TextWrapping.Wrap, + HorizontalAlignment = HorizontalAlignment.Stretch, + MaxWidth = DefaultWidthDip - 40, + }; + private readonly TaskCompletionSource completion = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private DispatcherQueueTimer? foregroundRetryTimer; + private int foregroundRetryCount; + private bool completed; + + public void UpdateDescription(string description) + => details.Text = description; + + public async Task ShowAsync( + string storageRoot, + string? selectedRelativeFolder, + string description, + bool allowLater, + bool allowSkip, + CancellationToken cancellationToken = default, + bool allowLaterAll = false) + { + window.Title = "다운로드 저장 위치 선택"; + window.Content = CreateContent(description, allowLater, allowSkip, allowLaterAll); + themeManager.RegisterWindow(window); + window.Closed += (_, _) => + { + diagnosticWriter?.Invoke("selection-window closed-event"); + CompleteWithoutClosing(FolderSelectionAction.Closed); + }; + await picker.InitializeAsync(storageRoot, selectedRelativeFolder, cancellationToken); + window.Activate(); + SizeAndCenterWindow(); + ShowAndActivateSelectionWindow("initial"); + StartForegroundRetries(); + + using var registration = cancellationToken.Register(() => + { + diagnosticWriter?.Invoke("selection-window cancellation-requested"); + window.DispatcherQueue.TryEnqueue(() => Complete(FolderSelectionAction.Closed)); + }); + return await completion.Task; + } + + private UIElement CreateContent(string description, bool allowLater, bool allowSkip, bool allowLaterAll) + { + var root = new Grid + { + Padding = new Thickness(20), + RowSpacing = 12, + MinWidth = 420, + MaxWidth = DefaultWidthDip, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + details.Text = description; + var detailsScroll = new ScrollViewer + { + Content = details, + MaxHeight = 150, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + }; + root.Children.Add(detailsScroll); + + picker.HorizontalAlignment = HorizontalAlignment.Stretch; + picker.VerticalAlignment = VerticalAlignment.Stretch; + picker.MinHeight = 240; + Grid.SetRow(picker, 1); + root.Children.Add(picker); + + var buttons = new StackPanel + { + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var apply = new Button + { + Content = "이 위치로 보내기", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + apply.Click += (_, _) => Complete(FolderSelectionAction.Apply); + picker.SelectionChanged += (_, _) => apply.IsEnabled = picker.IsSelectionAccessible; + buttons.Children.Add(apply); + + if (allowLater) + { + var later = new Button + { + Content = "나중에 선택", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + later.Click += (_, _) => Complete(FolderSelectionAction.Later); + buttons.Children.Add(later); + } + + if (allowLaterAll) + { + var laterAll = new Button + { + Content = "모두 나중에 선택", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + laterAll.Click += (_, _) => Complete(FolderSelectionAction.LaterAll); + buttons.Children.Add(laterAll); + } + + if (allowSkip) + { + var skip = new Button + { + Content = "이번 파일은 이동하지 않기", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + skip.Click += (_, _) => Complete(FolderSelectionAction.Skip); + buttons.Children.Add(skip); + } + + var close = new Button + { + Content = "창 닫기", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + close.Click += (_, _) => Complete(FolderSelectionAction.Closed); + buttons.Children.Add(close); + Grid.SetRow(buttons, 2); + root.Children.Add(buttons); + return root; + } + + private void Complete(FolderSelectionAction action) + { + if (CompleteWithoutClosing(action)) + { + window.Close(); + } + } + + private bool CompleteWithoutClosing(FolderSelectionAction action) + { + if (completed) + { + return false; + } + + completed = true; + foregroundRetryTimer?.Stop(); + foregroundRetryTimer = null; + diagnosticWriter?.Invoke($"selection-window completion action={action}"); + completion.TrySetResult(new FolderSelectionResult(action, picker.SelectedRelativeFolder)); + return true; + } + + private void SizeAndCenterWindow() + { + var handle = WinRT.Interop.WindowNative.GetWindowHandle(window); + var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(handle); + var appWindow = AppWindow.GetFromWindowId(windowId); + var dpi = Math.Max(96u, GetDpiForWindow(handle)); + var scale = dpi / 96d; + var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary); + var workArea = displayArea.WorkArea; + var width = Math.Max(420, Math.Min((int)Math.Round(DefaultWidthDip * scale), workArea.Width - 64)); + var height = Math.Max(520, Math.Min((int)Math.Round(DefaultHeightDip * scale), workArea.Height - 64)); + appWindow.Resize(new SizeInt32(width, height)); + appWindow.Move(new PointInt32( + workArea.X + Math.Max(0, (workArea.Width - width) / 2), + workArea.Y + Math.Max(0, (workArea.Height - height) / 2))); + if (appWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsResizable = false; + presenter.IsMaximizable = false; + presenter.IsAlwaysOnTop = true; + } + } + + private void StartForegroundRetries() + { + foregroundRetryTimer = window.DispatcherQueue.CreateTimer(); + foregroundRetryTimer.Interval = TimeSpan.FromMilliseconds(250); + foregroundRetryTimer.IsRepeating = true; + foregroundRetryTimer.Tick += (_, _) => + { + if (completed || foregroundRetryCount >= 4) + { + foregroundRetryTimer?.Stop(); + foregroundRetryTimer = null; + return; + } + + foregroundRetryCount++; + var handle = WinRT.Interop.WindowNative.GetWindowHandle(window); + if (!IsWindowVisible(handle) + || IsIconic(handle) + || GetForegroundWindow() != handle) + { + ShowAndActivateSelectionWindow($"retry-{foregroundRetryCount}"); + } + }; + foregroundRetryTimer.Start(); + } + + private void ShowAndActivateSelectionWindow(string phase) + { + var handle = WinRT.Interop.WindowNative.GetWindowHandle(window); + var operations = new NativeSelectionWindowActivationOperations(window); + var result = new SelectionWindowActivationCoordinator(operations) + .Activate(mainWindowHandle, handle); + diagnosticWriter?.Invoke(FormatActivationDiagnostic(phase, result)); + } + + private static string FormatActivationDiagnostic( + string phase, + SelectionWindowActivationResult result) + => $"selection-window activation phase={phase} " + + $"main-before={FormatState(result.MainBefore)} " + + $"selection-before={FormatState(result.SelectionBefore)} " + + $"selection-after-show={FormatState(result.SelectionAfterShow)} " + + $"show-normal={result.ShowNormalSucceeded} " + + $"bring-to-top={result.BringToTopSucceeded} " + + $"set-foreground={result.DirectForegroundSucceeded} " + + $"attached-input={result.AttachedForegroundSucceeded} " + + $"raised={result.RaisedForegroundSucceeded} " + + $"flash-fallback={result.FlashFallbackUsed} " + + $"main-after={FormatState(result.MainAfter)} " + + $"selection-after={FormatState(result.SelectionAfter)}"; + + private static string FormatState(NativeWindowState state) + => $"[hwnd=0x{state.Handle:X},visible={state.IsVisible},iconic={state.IsIconic}," + + $"owner=0x{state.OwnerHandle:X},foreground=0x{state.ForegroundHandle:X}]"; + + private sealed class NativeSelectionWindowActivationOperations( + Window xamlWindow) : ISelectionWindowActivationOperations + { + private const int ShowNormal = 1; + private const uint GetWindowOwner = 4; + private const uint SwpNoSize = 0x0001; + private const uint SwpNoMove = 0x0002; + private const uint SwpNoActivate = 0x0010; + private const uint SwpShowWindow = 0x0040; + private static readonly nint HwndTopmost = new(-1); + + public NativeWindowState Capture(nint windowHandle) + { + var foreground = GetForegroundWindow(); + return windowHandle == 0 + ? new NativeWindowState(0, false, false, 0, foreground) + : new NativeWindowState( + windowHandle, + IsWindowVisible(windowHandle), + IsIconic(windowHandle), + GetWindow(windowHandle, GetWindowOwner), + foreground); + } + + public bool ShowSelectionNormal(nint selectionWindowHandle) + { + _ = ShowWindow(selectionWindowHandle, ShowNormal); + return IsWindowVisible(selectionWindowHandle) && !IsIconic(selectionWindowHandle); + } + + public void ActivateSelection() + => xamlWindow.Activate(); + + public bool BringSelectionToTop(nint selectionWindowHandle) + => BringWindowToTop(selectionWindowHandle); + + public bool TrySetSelectionForeground(nint selectionWindowHandle) + { + _ = SetForegroundWindow(selectionWindowHandle); + return GetForegroundWindow() == selectionWindowHandle; + } + + public bool TryAttachInputAndActivate(nint selectionWindowHandle) + { + var foregroundWindowHandle = GetForegroundWindow(); + if (foregroundWindowHandle == selectionWindowHandle) + { + return true; + } + + if (foregroundWindowHandle == 0) + { + return false; + } + + var selectionThread = GetWindowThreadProcessId(selectionWindowHandle, out _); + var foregroundThread = GetWindowThreadProcessId(foregroundWindowHandle, out _); + if (selectionThread == 0 || foregroundThread == 0) + { + return false; + } + + var attached = selectionThread == foregroundThread + || AttachThreadInput(selectionThread, foregroundThread, true); + if (!attached) + { + return false; + } + + try + { + _ = ShowWindow(selectionWindowHandle, ShowNormal); + _ = BringWindowToTop(selectionWindowHandle); + _ = SetActiveWindow(selectionWindowHandle); + _ = SetFocus(selectionWindowHandle); + _ = SetForegroundWindow(selectionWindowHandle); + xamlWindow.Activate(); + return GetForegroundWindow() == selectionWindowHandle; + } + finally + { + if (selectionThread != foregroundThread) + { + _ = AttachThreadInput(selectionThread, foregroundThread, false); + } + } + } + + public bool RaiseSelectionAboveOtherWindows(nint selectionWindowHandle) + { + var flags = SwpNoMove | SwpNoSize | SwpNoActivate | SwpShowWindow; + var raised = SetWindowPos(selectionWindowHandle, HwndTopmost, 0, 0, 0, 0, flags); + xamlWindow.Activate(); + _ = BringWindowToTop(selectionWindowHandle); + _ = SetForegroundWindow(selectionWindowHandle); + return raised && GetForegroundWindow() == selectionWindowHandle; + } + + public void FlashSelection(nint selectionWindowHandle) + { + var flash = new FlashWindowInfo + { + Size = (uint)Marshal.SizeOf(), + Window = selectionWindowHandle, + Flags = 3, + Count = 3, + Timeout = 0, + }; + _ = FlashWindowEx(ref flash); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct FlashWindowInfo + { + public uint Size; + public nint Window; + public uint Flags; + public uint Count; + public uint Timeout; + } + + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsIconic(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(nint windowHandle, int command); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool BringWindowToTop(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(nint windowHandle); + + [DllImport("user32.dll")] + private static extern nint GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern nint GetWindow(nint windowHandle, uint command); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(nint windowHandle, out uint processId); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach); + + [DllImport("user32.dll")] + private static extern nint SetActiveWindow(nint windowHandle); + + [DllImport("user32.dll")] + private static extern nint SetFocus(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowPos( + nint windowHandle, + nint insertAfter, + int x, + int y, + int width, + int height, + uint flags); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FlashWindowEx(ref FlashWindowInfo info); +} diff --git a/src/DownloadRouter.App/FolderTreePicker.cs b/src/DownloadRouter.App/FolderTreePicker.cs new file mode 100644 index 0000000..82ae014 --- /dev/null +++ b/src/DownloadRouter.App/FolderTreePicker.cs @@ -0,0 +1,238 @@ +using DownloadRouter.Core.Paths; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Markup; + +namespace DownloadRouter.App; + +public sealed class FolderTreePicker : UserControl +{ + private readonly SafeFolderTreeProvider provider = new(new PathBoundaryValidator()); + private readonly TreeView tree = new() + { + HorizontalAlignment = HorizontalAlignment.Stretch, + SelectionMode = TreeViewSelectionMode.Single, + }; + private readonly TextBlock selectedPath = new() + { + TextWrapping = TextWrapping.Wrap, + Opacity = 0.75, + }; + private readonly TextBlock nodeError = new() + { + TextWrapping = TextWrapping.Wrap, + Visibility = Visibility.Collapsed, + }; + private readonly ProgressRing loading = new() + { + Width = 20, + Height = 20, + Visibility = Visibility.Collapsed, + }; + private readonly Dictionary entries = []; + private readonly HashSet loadedNodes = []; + private string storageRoot = string.Empty; + + public event EventHandler? SelectionChanged; + + public FolderTreePicker() + { + tree.ItemTemplate = (DataTemplate)XamlReader.Load( + """ + + + + """); + var refresh = new Button + { + Content = "선택한 폴더 새로 고침", + HorizontalAlignment = HorizontalAlignment.Left, + }; + refresh.Click += async (_, _) => await RefreshSelectedAsync(); + tree.Expanding += Tree_Expanding; + tree.SelectionChanged += Tree_SelectionChanged; + + var panel = new Grid + { + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + RowSpacing = 8, + }; + panel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + panel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + panel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + panel.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + panel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + panel.Children.Add(selectedPath); + Grid.SetRow(loading, 1); + panel.Children.Add(loading); + Grid.SetRow(nodeError, 2); + panel.Children.Add(nodeError); + Grid.SetRow(tree, 3); + panel.Children.Add(tree); + Grid.SetRow(refresh, 4); + panel.Children.Add(refresh); + Content = panel; + } + + public string SelectedRelativeFolder + => tree.SelectedNode is not null && entries.TryGetValue(tree.SelectedNode, out var entry) + ? entry.RelativePath == "." ? string.Empty : entry.RelativePath + : string.Empty; + + public string SelectedFullPath + => tree.SelectedNode is not null && entries.TryGetValue(tree.SelectedNode, out var entry) + ? entry.FullPath + : storageRoot; + + public bool IsSelectionAccessible + => tree.SelectedNode is not null + && entries.TryGetValue(tree.SelectedNode, out var entry) + && entry.IsAccessible; + + public async Task InitializeAsync( + string root, + string? selectedRelativeFolder = null, + CancellationToken cancellationToken = default) + { + storageRoot = Path.GetFullPath(root); + entries.Clear(); + loadedNodes.Clear(); + tree.RootNodes.Clear(); + var rootEntry = new FolderTreeEntry( + storageRoot, + storageRoot, + ".", + true); + var rootNode = CreateNode(rootEntry); + rootNode.HasUnrealizedChildren = true; + tree.RootNodes.Add(rootNode); + tree.SelectedNode = rootNode; + selectedPath.Text = $"선택 위치: {storageRoot}"; + + if (!string.IsNullOrWhiteSpace(selectedRelativeFolder)) + { + await SelectRelativePathAsync(selectedRelativeFolder, cancellationToken); + } + } + + public async Task SelectRelativePathAsync( + string relativeFolder, + CancellationToken cancellationToken = default) + { + var rootNode = tree.RootNodes.FirstOrDefault(); + if (rootNode is null) + { + return; + } + + var current = rootNode; + foreach (var part in relativeFolder.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + await EnsureChildrenLoadedAsync(current, cancellationToken); + var next = current.Children.FirstOrDefault(candidate => + entries.TryGetValue(candidate, out var entry) + && string.Equals(entry.Name, part, StringComparison.OrdinalIgnoreCase)); + if (next is null) + { + return; + } + + current.IsExpanded = true; + current = next; + } + + tree.SelectedNode = current; + } + + private TreeViewNode CreateNode(FolderTreeEntry entry) + { + var node = new TreeViewNode + { + Content = entry.Name, + HasUnrealizedChildren = entry.IsAccessible, + }; + entries[node] = entry; + return node; + } + + private async void Tree_Expanding(TreeView sender, TreeViewExpandingEventArgs args) + { + await EnsureChildrenLoadedAsync(args.Node, CancellationToken.None); + args.Node.IsExpanded = true; + } + + private void Tree_SelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args) + { + if (tree.SelectedNode is null || !entries.TryGetValue(tree.SelectedNode, out var entry)) + { + return; + } + + selectedPath.Text = entry.IsAccessible + ? $"선택 위치: {entry.FullPath}" + : entry.ErrorMessage ?? "이 폴더에 접근할 수 없습니다."; + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + + private async Task EnsureChildrenLoadedAsync(TreeViewNode node, CancellationToken cancellationToken) + { + if (loadedNodes.Contains(node) || !entries.TryGetValue(node, out var entry) || !entry.IsAccessible) + { + return; + } + + loadedNodes.Add(node); + loading.Visibility = Visibility.Visible; + loading.IsActive = true; + nodeError.Visibility = Visibility.Collapsed; + try + { + var children = await provider.GetChildrenAsync(storageRoot, entry.FullPath, cancellationToken); + node.Children.Clear(); + foreach (var child in children.Entries) + { + node.Children.Add(CreateNode(child)); + } + + node.HasUnrealizedChildren = false; + if (!string.IsNullOrWhiteSpace(children.ErrorMessage)) + { + nodeError.Text = children.ErrorMessage; + nodeError.Visibility = Visibility.Visible; + } + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + node.HasUnrealizedChildren = false; + nodeError.Text = "이 폴더의 하위 항목을 읽을 수 없습니다."; + nodeError.Visibility = Visibility.Visible; + } + finally + { + loading.IsActive = false; + loading.Visibility = Visibility.Collapsed; + } + } + + private async Task RefreshSelectedAsync() + { + var node = tree.SelectedNode ?? tree.RootNodes.FirstOrDefault(); + if (node is null || !entries.TryGetValue(node, out var entry) || !entry.IsAccessible) + { + return; + } + + loadedNodes.Remove(node); + node.Children.Clear(); + node.HasUnrealizedChildren = true; + await EnsureChildrenLoadedAsync(node, CancellationToken.None); + node.IsExpanded = true; + } +} diff --git a/src/DownloadRouter.App/MainWindow.History.cs b/src/DownloadRouter.App/MainWindow.History.cs new file mode 100644 index 0000000..fbf0637 --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.History.cs @@ -0,0 +1,388 @@ +using DownloadRouter.Core.Models; +using DownloadRouter.Core.Jobs; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.UI.Text; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private IReadOnlyList historyJobs = []; + private IReadOnlyDictionary historyRules = new Dictionary(); + private readonly HashSet selectedHistoryJobs = []; + private HistoryFilter historyFilter = HistoryFilter.All; + private string? historySnapshot; + private bool blockingDialogOpen; + + private async Task ShowHistoryAsync() + { + try + { + var response = await agent.SendAsync("jobs.list"); + var rulesResponse = await agent.SendAsync("rules.list"); + historyJobs = AgentClient.ReadData>(response) ?? []; + historyRules = (AgentClient.ReadData>(rulesResponse) ?? []) + .ToDictionary(static rule => rule.Id); + RenderHistory(); + } + catch (Exception exception) + { + Prepare("다운로드 작업 및 이력", "사이트 규칙에 매칭된 작업의 전송 상태와 라우팅 상태를 각각 표시합니다."); + AddError("다운로드 이력을 불러오지 못했습니다.", exception); + } + } + + private void RenderHistory() + { + Prepare("다운로드 작업 및 이력", "취소된 작업도 자동 삭제하지 않으며, 이력 삭제는 실제 파일에 영향을 주지 않습니다."); + + var filter = new ComboBox + { + Header = "상태 필터", + ItemsSource = new[] { "전체", "완료", "취소", "중단", "실패", "진행 중" }, + SelectedIndex = (int)historyFilter, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + filter.SelectionChanged += (_, _) => + { + historyFilter = (HistoryFilter)Math.Max(filter.SelectedIndex, 0); + selectedHistoryJobs.Clear(); + RenderHistory(); + }; + ContentPanel.Children.Add(filter); + + var visible = historyJobs.Where(MatchesHistoryFilter).ToList(); + var visibleIds = visible.Select(static job => job.Id).ToHashSet(); + var selectedVisibleCount = visibleIds.Count(selectedHistoryJobs.Contains); + var selectAll = new CheckBox + { + Content = "현재 필터의 항목 전체 선택", + IsThreeState = true, + IsChecked = visible.Count == 0 || selectedVisibleCount == 0 + ? false + : selectedVisibleCount == visible.Count ? true : null, + }; + selectAll.Checked += (_, _) => + { + selectedHistoryJobs.UnionWith(visibleIds); + RenderHistory(); + }; + selectAll.Unchecked += (_, _) => + { + selectedHistoryJobs.ExceptWith(visibleIds); + RenderHistory(); + }; + selectAll.Indeterminate += (_, _) => + { + selectedHistoryJobs.ExceptWith(visibleIds); + RenderHistory(); + }; + ContentPanel.Children.Add(selectAll); + ContentPanel.Children.Add(new TextBlock + { + Text = $"선택됨: {selectedHistoryJobs.Count}개 · 필터 변경 시 선택은 초기화됩니다.", + Opacity = 0.7, + TextWrapping = TextWrapping.Wrap, + }); + + var deleteSelected = new Button + { + Content = "선택 항목 삭제", + HorizontalAlignment = HorizontalAlignment.Stretch, + IsEnabled = selectedHistoryJobs.Count > 0, + }; + deleteSelected.Click += async (_, _) => await DeleteHistoryAsync(selectedHistoryJobs.ToArray()); + ContentPanel.Children.Add(deleteSelected); + + var clearCancelled = new Button + { + Content = "취소된 이력 정리", + HorizontalAlignment = HorizontalAlignment.Stretch, + IsEnabled = historyJobs.Any(static job => job.BrowserState == BrowserTransferState.Cancelled), + }; + clearCancelled.Click += async (_, _) => await DeleteHistoryAsync( + historyJobs.Where(static job => job.BrowserState == BrowserTransferState.Cancelled) + .Select(static job => job.Id) + .ToArray()); + ContentPanel.Children.Add(clearCancelled); + + foreach (var job in visible) + { + ContentPanel.Children.Add(CreateHistoryCard(job)); + } + + if (visible.Count == 0) + { + AddMuted(historyJobs.Count == 0 + ? "아직 규칙에 매칭되어 추적된 다운로드가 없습니다." + : "선택한 상태에 해당하는 이력이 없습니다."); + } + + historySnapshot = CreateHistorySnapshot(historyJobs); + } + + private Border CreateHistoryCard(DownloadJob job) + { + var panel = new StackPanel { Spacing = 8 }; + var checkbox = new CheckBox + { + Content = "이 항목 선택", + IsChecked = selectedHistoryJobs.Contains(job.Id), + }; + checkbox.Checked += (_, _) => + { + selectedHistoryJobs.Add(job.Id); + RenderHistory(); + }; + checkbox.Unchecked += (_, _) => + { + selectedHistoryJobs.Remove(job.Id); + RenderHistory(); + }; + panel.Children.Add(checkbox); + + var title = new TextBlock + { + Text = DownloadPresentation.DisplayFileName(job), + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap, + TextDecorations = job.BrowserState == BrowserTransferState.Cancelled + ? TextDecorations.Strikethrough + : TextDecorations.None, + }; + panel.Children.Add(title); + panel.Children.Add(CreateStatusBadge(job)); + var auxiliary = new TextBlock + { + Text = $"{job.Browser} · {GetSourceHost(job)} · 시작 {job.CreatedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}", + TextWrapping = TextWrapping.Wrap, + TextDecorations = DownloadPresentation.IsCancelled(job) + ? TextDecorations.Strikethrough + : TextDecorations.None, + Opacity = DownloadPresentation.IsCancelled(job) ? 0.55 : 1, + }; + panel.Children.Add(auxiliary); + + if (DownloadPresentation.IsCancelled(job)) + { + panel.Children.Add(new TextBlock + { + Text = "사용자가 다운로드를 취소했습니다.", + TextWrapping = TextWrapping.Wrap, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); + } + + if (!string.IsNullOrWhiteSpace(job.FinalPath)) + { + panel.Children.Add(new TextBlock { Text = $"이동 위치: {job.FinalPath}", TextWrapping = TextWrapping.Wrap }); + } + + if (!string.IsNullOrWhiteSpace(job.ErrorCode)) + { + panel.Children.Add(new TextBlock { Text = $"오류 코드: {job.ErrorCode}", TextWrapping = TextWrapping.Wrap }); + } + + if (DownloadPresentation.CanChangeRoute(job) && historyRules.TryGetValue(job.RuleId, out var rule)) + { + var route = new Button + { + Content = job.RoutingState == RoutingState.Completed ? "다른 위치로 이동" : "저장 위치 선택/변경", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + route.Click += async (_, _) => await ChangeHistoryRouteAsync(job, rule); + panel.Children.Add(route); + } + else if (job.BrowserState is BrowserTransferState.Cancelled or BrowserTransferState.Interrupted + || job.RoutingState is RoutingState.Failed or RoutingState.RetryPending) + { + panel.Children.Add(new TextBlock + { + Text = job.BrowserState == BrowserTransferState.Cancelled + ? "취소된 다운로드는 저장 위치를 변경할 수 없습니다." + : job.BrowserState == BrowserTransferState.Interrupted + ? "중단된 다운로드는 저장 위치를 변경할 수 없습니다." + : "실패 또는 재시도 상태에서는 먼저 원본 파일 상태를 확인하세요.", + TextWrapping = TextWrapping.Wrap, + Opacity = 0.7, + }); + } + + var delete = new Button { Content = "이력에서 삭제", HorizontalAlignment = HorizontalAlignment.Stretch }; + delete.Click += async (_, _) => await DeleteHistoryAsync([job.Id]); + panel.Children.Add(delete); + + return new Border + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(16), + CornerRadius = new CornerRadius(8), + Background = themeManager.GetThemeBrush("CardSurfaceBrush"), + Opacity = job.BrowserState == BrowserTransferState.Cancelled ? 0.55 : 1, + Child = panel, + }; + } + + private async Task ChangeHistoryRouteAsync(DownloadJob job, DownloadRule rule) + { + var root = pathResolver.Resolve(rule.StorageRoot); + var completedMove = job.RoutingState == RoutingState.Completed; + if (completedMove && !await ShowConfirmationAsync( + "이미 이동된 파일을 다시 이동", + "기존 안전 이동 절차를 사용해 이 파일만 다른 위치로 이동합니다. 같은 이름의 파일은 덮어쓰지 않습니다.")) + { + return; + } + + var result = await CreateFolderSelectionWindow().ShowAsync( + root, + job.SelectedRelativeFolder, + $"파일: {DownloadPresentation.DisplayFileName(job)}\n현재 상태: {DescribeStatus(job)}\n저장 루트: {root}", + allowLater: !completedMove, + allowSkip: !completedMove); + if (result.Action == FolderSelectionAction.Skip) + { + var skipped = await agent.SendAsync("selection.skip", new SelectionSkippedPayload(job.Id)); + if (!skipped.Success) + { + await ShowMessageAsync(skipped.Message ?? "이동 건너뛰기에 실패했습니다."); + } + } + else if (result.Action == FolderSelectionAction.Later) + { + deferredSelectionPrompts.Add(job.Id); + } + else if (result.Action == FolderSelectionAction.Apply) + { + var response = await agent.SendAsync( + "route.change", + new JobRouteChangePayload(job.Id, result.RelativeFolder, completedMove)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "저장 위치 변경에 실패했습니다."); + } + } + + await ShowHistoryAsync(); + } + + private Border CreateStatusBadge(DownloadJob job) + => new() + { + HorizontalAlignment = HorizontalAlignment.Left, + Padding = new Thickness(8, 4, 8, 4), + CornerRadius = new CornerRadius(12), + Background = themeManager.GetThemeBrush( + job.BrowserState == BrowserTransferState.Interrupted || job.RoutingState == RoutingState.Failed + ? "WarningStatusSurfaceBrush" + : "StatusSurfaceBrush"), + Child = new TextBlock + { + Text = DescribeStatus(job), + TextWrapping = TextWrapping.Wrap, + }, + }; + + private async Task DeleteHistoryAsync(IReadOnlyList ids) + { + if (ids.Count == 0) + { + await ShowMessageAsync("삭제할 이력을 선택하세요."); + return; + } + + var confirmed = await ShowConfirmationAsync( + "다운로드 이력 삭제", + $"{ids.Count}개 항목을 이력에서 삭제하시겠습니까?\n\n이 작업은 프로그램 이력에서만 삭제되며 실제 다운로드 파일은 삭제하지 않습니다."); + if (!confirmed) + { + return; + } + + var response = await agent.SendAsync("jobs.delete", new JobsDeletePayload(ids)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "이력 삭제에 실패했습니다."); + return; + } + + selectedHistoryJobs.ExceptWith(ids); + await ShowHistoryAsync(); + } + + private async Task ShowConfirmationAsync(string title, string message) + { + blockingDialogOpen = true; + try + { + var dialog = new ContentDialog + { + Title = title, + Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, + PrimaryButtonText = "확인", + CloseButtonText = "취소", + DefaultButton = ContentDialogButton.Close, + XamlRoot = ContentPanel.XamlRoot, + }; + return await dialog.ShowAsync() == ContentDialogResult.Primary; + } + finally + { + blockingDialogOpen = false; + } + } + + private bool MatchesHistoryFilter(DownloadJob job) + => historyFilter switch + { + HistoryFilter.All => true, + HistoryFilter.Completed => job.RoutingState == RoutingState.Completed, + HistoryFilter.Cancelled => job.BrowserState == BrowserTransferState.Cancelled, + HistoryFilter.Interrupted => job.BrowserState == BrowserTransferState.Interrupted, + HistoryFilter.Failed => job.RoutingState is RoutingState.Failed or RoutingState.RetryPending, + HistoryFilter.InProgress => job.BrowserState == BrowserTransferState.InProgress, + _ => true, + }; + + private static string CreateHistorySnapshot(IEnumerable jobs) + => string.Join('|', jobs.Select(static job => + $"{job.Id:N}:{job.CurrentFileName}:{job.BrowserState}:{job.RoutingState}:{job.SelectedRelativeFolder}:{job.FinalPath}:{job.CompletedAt:O}:{job.ErrorCode}")); + + private static string DescribeStatus(DownloadJob job) + => job.BrowserState switch + { + BrowserTransferState.Cancelled => "사용자가 다운로드를 취소했습니다", + BrowserTransferState.Interrupted => "브라우저 또는 네트워크 오류로 중단", + BrowserTransferState.InProgress when job.RoutingState == RoutingState.WaitingForSelection => "다운로드 중 · 위치 선택 대기", + BrowserTransferState.Complete when job.RoutingState == RoutingState.WaitingForSelection => "다운로드 완료 · 위치 선택 대기", + BrowserTransferState.InProgress when job.RoutingState == RoutingState.SelectionReady => "위치 선택 완료 · 다운로드 진행 중", + _ => job.RoutingState switch + { + RoutingState.Moving => "파일 이동 중", + RoutingState.Completed => "이동 완료", + RoutingState.Skipped => "이번 파일 이동 안 함", + RoutingState.RetryPending => "파일 이동 재시도 대기", + RoutingState.Failed => "파일 이동 실패", + RoutingState.NotRequired when job.BrowserState == BrowserTransferState.InProgress => "다운로드 중", + RoutingState.NotRequired => "다운로드 완료", + _ => $"{job.BrowserState} · {job.RoutingState}", + }, + }; + + private static string GetSourceHost(DownloadJob job) + => Uri.TryCreate(job.SanitizedSource, UriKind.Absolute, out var source) + ? source.Host + : "출처 확인 불가"; + + private enum HistoryFilter + { + All, + Completed, + Cancelled, + Interrupted, + Failed, + InProgress, + } +} diff --git a/src/DownloadRouter.App/MainWindow.Lifetime.cs b/src/DownloadRouter.App/MainWindow.Lifetime.cs new file mode 100644 index 0000000..b76b33d --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.Lifetime.cs @@ -0,0 +1,143 @@ +using System.Runtime.InteropServices; +using DownloadRouter.Core.Models; +using DownloadRouter.Core.Settings; +using Microsoft.UI.Windowing; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private readonly AppPreferencesStore preferencesStore; + private readonly ThemeManager themeManager; + private AppPreferences preferences; + private AppWindow? appWindow; + private TrayIconHost? trayIcon; + private bool exitRequested; + private Action? exitCallback; + + public void InitializeHost(bool background, Action onExit) + { + exitCallback = onExit; + var handle = WinRT.Interop.WindowNative.GetWindowHandle(this); + var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(handle); + appWindow = AppWindow.GetFromWindowId(windowId); + appWindow.Closing += AppWindow_Closing; + trayIcon = new TrayIconHost( + DispatcherQueue, + ShowFromTray, + () => NavigateFromTray("pending"), + () => NavigateFromTray("settings"), + ExitFromTray); + Closed += (_, _) => + { + trayIcon?.Dispose(); + exitCallback?.Invoke(); + }; + _ = EnsureAgentForHostAsync(); + if (!background) + { + ShowFromTray(); + } + } + + public void ShowFromTray() + { + appWindow?.Show(); + Activate(); + var handle = WinRT.Interop.WindowNative.GetWindowHandle(this); + _ = ShowWindow(handle, 9); + _ = SetForegroundWindow(handle); + } + + private void NavigateFromTray(string tag) + { + ShowFromTray(); + var item = Navigation.MenuItems + .OfType() + .FirstOrDefault(candidate => string.Equals(candidate.Tag as string, tag, StringComparison.Ordinal)); + if (item is not null) + { + Navigation.SelectedItem = item; + } + } + + private void ExitFromTray() + => ExitFromExternalRequest(); + + public void ExitFromExternalRequest() + { + exitRequested = true; + StopLiveUpdates(); + trayIcon?.Dispose(); + trayIcon = null; + Close(); + } + + private void AppWindow_Closing(AppWindow sender, AppWindowClosingEventArgs args) + { + if (exitRequested) + { + return; + } + + if (preferences.CloseBehavior == WindowCloseBehavior.MinimizeToTray) + { + args.Cancel = true; + sender.Hide(); + return; + } + + exitRequested = true; + StopLiveUpdates(); + trayIcon?.Dispose(); + trayIcon = null; + } + + private async Task EnsureAgentForHostAsync() + { + var running = await new AgentProcessManager(agent).EnsureRunningAsync(); + if (!running) + { + WriteAppDiagnostic("Agent start failed; Native Host fallback remains enabled."); + } + } + + private static void WriteAppDiagnostic(string message) + => WriteDiagnostic("app-startup.log", message); + + private static void WriteWindowActivationDiagnostic(string message) + => WriteDiagnostic("window-activation.log", message); + + private FolderSelectionWindow CreateFolderSelectionWindow() + => new( + themeManager, + WinRT.Interop.WindowNative.GetWindowHandle(this), + WriteWindowActivationDiagnostic); + + private static void WriteDiagnostic(string fileName, string message) + { + try + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "eslee", + "DownloadRouter", + "logs"); + Directory.CreateDirectory(directory); + File.AppendAllText( + Path.Combine(directory, fileName), + $"{DateTimeOffset.UtcNow:O} {message}{Environment.NewLine}"); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(nint windowHandle, int command); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(nint windowHandle); +} diff --git a/src/DownloadRouter.App/MainWindow.Live.cs b/src/DownloadRouter.App/MainWindow.Live.cs new file mode 100644 index 0000000..32a5c47 --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.Live.cs @@ -0,0 +1,310 @@ +using DownloadRouter.Core.Jobs; +using DownloadRouter.Core.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private readonly DispatcherTimer liveUpdateTimer = new() { Interval = TimeSpan.FromMilliseconds(500) }; + private readonly SelectionPromptQueue selectionPromptQueue = new(); + private readonly HashSet deferredSelectionPrompts = []; + private readonly HashSet sessionAutoPromptJobs = []; + private CancellationTokenSource? currentSelectionCancellation; + private Guid? currentSelectionJobId; + private DownloadRule? currentSelectionRule; + private FolderSelectionWindow? currentSelectionWindow; + private string? currentSelectionSnapshot; + private bool currentSelectionInvalidated; + private bool liveUpdateInProgress; + private bool selectionPromptInProgress; + private string? livePageSnapshot; + private int previousAutomaticCount; + private int shownAutoPromptCount; + + private void InitializeLiveUpdates() + { + liveUpdateTimer.Tick += LiveUpdateTimer_Tick; + liveUpdateTimer.Start(); + } + + private void StopLiveUpdates() + { + liveUpdateTimer.Stop(); + currentSelectionCancellation?.Cancel(); + } + + private async Task ShowDashboardAsync() + { + Prepare("대시보드", "브라우저 전송 상태와 파일 라우팅 상태를 구분해 표시합니다."); + try + { + var jobsResponse = await agent.SendAsync("jobs.list"); + var rulesResponse = await agent.SendAsync("rules.list"); + var jobs = AgentClient.ReadData>(jobsResponse) ?? []; + var rules = AgentClient.ReadData>(rulesResponse) ?? []; + RenderDashboard(jobs, rules); + } + catch (Exception exception) + { + AddError("Agent에 연결할 수 없습니다. scripts/run-dev.ps1을 실행한 뒤 다시 시도하세요.", exception); + } + } + + private void RenderDashboard(IReadOnlyList jobs, IReadOnlyList rules) + { + Prepare("대시보드", "브라우저 전송 상태와 파일 라우팅 상태를 구분해 표시합니다."); + var counts = DownloadJobQueries.CountDashboard(jobs); + AddCard("활성 규칙", rules.Count(static rule => rule.IsEnabled).ToString()); + AddCard("다운로드 중", counts.DownloadsInProgress.ToString()); + AddCard("저장 위치 선택 대기", counts.WaitingForSelection.ToString()); + AddCard("최근 완료", counts.RecentlyCompleted.ToString()); + AddCard("취소/중단", counts.CancelledOrInterrupted.ToString()); + AddCard("재시도/실패", counts.RetryOrFailed.ToString()); + } + + private async void LiveUpdateTimer_Tick(object? sender, object args) + { + if (liveUpdateInProgress) + { + return; + } + + liveUpdateInProgress = true; + try + { + var jobs = AgentClient.ReadData>(await agent.SendAsync("jobs.list")) ?? []; + var rules = AgentClient.ReadData>(await agent.SendAsync("rules.list")) ?? []; + var active = DownloadJobQueries.ActiveSelections(jobs); + var automatic = DownloadJobQueries.AutomaticSelections(jobs, DateTimeOffset.UtcNow); + UpdatePendingBadge(active.Count, automatic.Count); + SynchronizeSelectionPrompt(active, automatic); + + if (!selectionPromptInProgress && !blockingDialogOpen) + { + _ = ProcessNextSelectionPromptAsync(automatic, rules); + } + + await RefreshVisibleLivePageAsync(jobs, rules); + } + catch (Exception exception) + { + System.Diagnostics.Debug.WriteLine($"Live UI refresh failed: {exception.GetType().Name}"); + } + finally + { + liveUpdateInProgress = false; + } + } + + private void SynchronizeSelectionPrompt( + IReadOnlyList active, + IReadOnlyList automatic) + { + var activeIds = active.Select(static job => job.Id).ToHashSet(); + if (currentSelectionJobId is Guid current && !activeIds.Contains(current)) + { + WriteWindowActivationDiagnostic( + $"selection-window invalidated current-job={current:N} active-count={active.Count}"); + currentSelectionInvalidated = true; + currentSelectionCancellation?.Cancel(); + } + else if (currentSelectionJobId is Guid activeCurrent) + { + var currentJob = active.First(candidate => candidate.Id == activeCurrent); + var updatedSnapshot = CreateSelectionPromptSnapshot(currentJob, active.Count); + if (!string.Equals(currentSelectionSnapshot, updatedSnapshot, StringComparison.Ordinal)) + { + currentSelectionSnapshot = updatedSnapshot; + if (currentSelectionRule is not null) + { + currentSelectionWindow?.UpdateDescription( + CreateSelectionDescription( + currentJob, + currentSelectionRule, + Math.Max(0, active.Count - 1), + Math.Max(1, shownAutoPromptCount), + Math.Max(shownAutoPromptCount, sessionAutoPromptJobs.Count))); + } + } + } + + foreach (var job in automatic) + { + if (job.Id != currentSelectionJobId && !deferredSelectionPrompts.Contains(job.Id)) + { + if (selectionPromptQueue.Enqueue(job.Id)) + { + sessionAutoPromptJobs.Add(job.Id); + } + } + } + } + + private async Task ProcessNextSelectionPromptAsync( + IReadOnlyList active, + IReadOnlyList rules) + { + while (selectionPromptQueue.TryDequeue(out var jobId)) + { + var job = active.FirstOrDefault(candidate => candidate.Id == jobId); + var rule = job is null ? null : rules.FirstOrDefault(candidate => candidate.Id == job.RuleId); + if (job is null || rule is null || deferredSelectionPrompts.Contains(jobId)) + { + continue; + } + + shownAutoPromptCount++; + await ShowSelectionPromptAsync( + job, + rule, + Math.Max(0, active.Count - 1), + shownAutoPromptCount, + Math.Max(shownAutoPromptCount, sessionAutoPromptJobs.Count), + active.Select(static candidate => candidate.Id).ToArray()); + return; + } + } + + private async Task ShowSelectionPromptAsync( + DownloadJob job, + DownloadRule rule, + int otherPendingCount, + int queuePosition, + int queueTotal, + IReadOnlyList currentQueueJobIds) + { + selectionPromptInProgress = true; + currentSelectionJobId = job.Id; + currentSelectionRule = rule; + currentSelectionSnapshot = CreateSelectionPromptSnapshot(job, otherPendingCount + 1); + currentSelectionInvalidated = false; + try + { + var root = pathResolver.Resolve(rule.StorageRoot); + currentSelectionCancellation = new CancellationTokenSource(); + currentSelectionWindow = CreateFolderSelectionWindow(); + var result = await currentSelectionWindow.ShowAsync( + root, + job.SelectedRelativeFolder, + CreateSelectionDescription(job, rule, otherPendingCount, queuePosition, queueTotal), + allowLater: true, + allowSkip: true, + cancellationToken: currentSelectionCancellation.Token, + allowLaterAll: true); + + if (currentSelectionInvalidated) + { + return; + } + + if (result.Action == FolderSelectionAction.Apply) + { + var response = await agent.SendAsync( + "selection.complete", + new SelectionCompletedPayload([job.Id], result.RelativeFolder)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "선택 적용에 실패했습니다."); + } + } + else if (result.Action == FolderSelectionAction.Skip) + { + var response = await agent.SendAsync("selection.skip", new SelectionSkippedPayload(job.Id)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "이동 건너뛰기 적용에 실패했습니다."); + } + } + else if (result.Action == FolderSelectionAction.LaterAll) + { + foreach (var queuedJobId in currentQueueJobIds) + { + deferredSelectionPrompts.Add(queuedJobId); + } + + selectionPromptQueue.Drain(); + sessionAutoPromptJobs.Clear(); + shownAutoPromptCount = 0; + } + else + { + deferredSelectionPrompts.Add(job.Id); + } + } + catch (Exception exception) + { + deferredSelectionPrompts.Add(job.Id); + System.Diagnostics.Debug.WriteLine($"Selection prompt failed open: {exception.GetType().Name}"); + } + finally + { + currentSelectionJobId = null; + currentSelectionRule = null; + currentSelectionWindow = null; + currentSelectionSnapshot = null; + currentSelectionInvalidated = false; + currentSelectionCancellation?.Dispose(); + currentSelectionCancellation = null; + selectionPromptInProgress = false; + } + } + + private async Task RefreshVisibleLivePageAsync( + IReadOnlyList jobs, + IReadOnlyList rules) + { + if (blockingDialogOpen || selectionPromptInProgress) + { + return; + } + + var snapshot = CreateHistorySnapshot(jobs); + if (string.Equals(livePageSnapshot, snapshot, StringComparison.Ordinal)) + { + return; + } + + livePageSnapshot = snapshot; + var tag = (Navigation.SelectedItem as NavigationViewItem)?.Tag as string; + switch (tag) + { + case "dashboard": + RenderDashboard(jobs, rules); + break; + case "history": + historyJobs = jobs; + historyRules = rules.ToDictionary(static rule => rule.Id); + selectedHistoryJobs.IntersectWith(jobs.Select(static job => job.Id)); + RenderHistory(); + break; + case "pending": + await ShowPendingAsync(); + break; + } + } + + private static string CreateSelectionPromptSnapshot(DownloadJob job, int activeCount) + => $"{job.Id:N}:{job.CurrentFileName}:{job.BrowserState}:{job.RoutingState}:{activeCount}"; + + private string CreateSelectionDescription( + DownloadJob job, + DownloadRule rule, + int otherPendingCount, + int queuePosition = 1, + int queueTotal = 1) + => $"{queuePosition} / {queueTotal}\n파일: {DownloadPresentation.DisplayFileName(job)}\n출처 사이트: {GetSourceHost(job)}\n상태: {DescribeStatus(job)}\n규칙: {rule.Name}\n저장 루트: {pathResolver.Resolve(rule.StorageRoot)}\n동시에 대기 중인 다른 파일 {otherPendingCount}개"; + + private void UpdatePendingBadge(int count, int automaticCount) + { + PendingInfoBadge.Value = count; + PendingInfoBadge.Visibility = count > 0 ? Visibility.Visible : Visibility.Collapsed; + if (automaticCount > previousAutomaticCount) + { + trayIcon?.ShowSelectionNotification(count); + } + previousAutomaticCount = automaticCount; + } + +} diff --git a/src/DownloadRouter.App/MainWindow.Pending.cs b/src/DownloadRouter.App/MainWindow.Pending.cs new file mode 100644 index 0000000..c4baad4 --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.Pending.cs @@ -0,0 +1,232 @@ +using DownloadRouter.Core.Jobs; +using DownloadRouter.Core.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private async Task ShowPendingAsync() + { + Prepare("저장 위치 선택 대기", "파일마다 서로 다른 하위 폴더를 선택할 수 있습니다. 완료 전 선택은 저장만 하고, 실제 이동은 다운로드 완료 후 시작합니다."); + try + { + var jobs = AgentClient.ReadData>(await agent.SendAsync("jobs.list")) ?? []; + var rules = (AgentClient.ReadData>(await agent.SendAsync("rules.list")) ?? []) + .ToDictionary(static rule => rule.Id); + var active = DownloadJobQueries.ActiveSelections(jobs); + var previousSessionCount = active.Count(job => + SelectionPromptPolicy.IsPreviousSessionPending(job, DateTimeOffset.UtcNow)); + if (previousSessionCount > 0) + { + ContentPanel.Children.Add(new InfoBar + { + IsOpen = true, + Severity = InfoBarSeverity.Informational, + Title = "이전 세션의 대기 작업", + Message = $"30분 이상 지났거나 브라우저 기록을 확인할 수 없는 {previousSessionCount}개 작업은 자동 팝업 없이 이 탭에서만 유지됩니다.", + }); + } + var groups = active.GroupBy(static job => job.RuleId).ToList(); + + foreach (var group in groups) + { + if (!rules.TryGetValue(group.Key, out var rule)) + { + continue; + } + + AddPendingRuleGroup(rule, group.ToList()); + } + + if (groups.Count == 0) + { + AddMuted("현재 선택을 기다리는 활성 파일이 없습니다. 취소·중단·완료·건너뜀 작업은 이 목록에서 제외됩니다."); + } + } + catch (Exception exception) + { + AddError("선택 대기 목록을 불러오지 못했습니다.", exception); + } + } + + private void AddPendingRuleGroup(DownloadRule rule, IReadOnlyList jobs) + { + var root = pathResolver.Resolve(rule.StorageRoot); + var selectedJobs = new HashSet(); + + ContentPanel.Children.Add(new TextBlock + { + Text = $"{rule.Name} · {jobs.Count}개 파일", + Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style, + TextWrapping = TextWrapping.Wrap, + }); + ContentPanel.Children.Add(new TextBlock + { + Text = $"저장 루트: {root}", + Opacity = 0.75, + TextWrapping = TextWrapping.Wrap, + }); + + foreach (var job in jobs) + { + ContentPanel.Children.Add(CreatePendingCard(job, rule, root, selectedJobs)); + } + + var applySelected = new Button + { + Content = "체크한 파일의 저장 위치 선택", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + applySelected.Click += async (_, _) => + { + if (selectedJobs.Count == 0) + { + await ShowMessageAsync("먼저 적용할 파일의 체크박스를 선택하세요."); + return; + } + + var result = await CreateFolderSelectionWindow().ShowAsync( + root, + selectedRelativeFolder: null, + $"{selectedJobs.Count}개 파일에 같은 하위 폴더를 적용합니다. 체크하지 않은 파일은 변경하지 않습니다.", + allowLater: false, + allowSkip: false); + if (result.Action != FolderSelectionAction.Apply) + { + return; + } + + await ApplySelectionAsync(selectedJobs.ToArray(), result.RelativeFolder); + }; + ContentPanel.Children.Add(applySelected); + ContentPanel.Children.Add(new Border { Height = 1, Opacity = 0.35, Margin = new Thickness(0, 8, 0, 8) }); + } + + private Border CreatePendingCard( + DownloadJob job, + DownloadRule rule, + string root, + ISet selectedJobs) + { + var panel = new StackPanel { Spacing = 8 }; + var selected = new CheckBox { Content = "일괄 적용 대상으로 선택" }; + selected.Checked += (_, _) => selectedJobs.Add(job.Id); + selected.Unchecked += (_, _) => selectedJobs.Remove(job.Id); + panel.Children.Add(selected); + panel.Children.Add(new TextBlock + { + Text = DownloadPresentation.DisplayFileName(job), + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap, + }); + panel.Children.Add(CreateStatusBadge(job)); + if (SelectionPromptPolicy.IsPreviousSessionPending(job, DateTimeOffset.UtcNow)) + { + panel.Children.Add(new TextBlock + { + Text = job.IsBrowserRecordStale + ? "이전 세션의 대기 작업 · 브라우저 기록을 찾을 수 없어 자동 표시하지 않음" + : "이전 세션의 대기 작업 · 30분 자동 팝업 기한 경과", + Opacity = 0.75, + TextWrapping = TextWrapping.Wrap, + }); + } + panel.Children.Add(new TextBlock + { + Text = $"출처 호스트: {GetSourceHost(job)}\n브라우저: {job.Browser}\n다운로드 시작: {job.CreatedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}\n규칙: {rule.Name}\n저장 루트: {root}\n현재 선택: {DisplayFolder(job.SelectedRelativeFolder)}", + TextWrapping = TextWrapping.Wrap, + }); + + var apply = new Button { Content = "이 파일의 저장 위치 선택/변경", HorizontalAlignment = HorizontalAlignment.Stretch }; + apply.Click += async (_, _) => + { + var result = await CreateFolderSelectionWindow().ShowAsync( + root, + job.SelectedRelativeFolder, + $"파일: {DownloadPresentation.DisplayFileName(job)}\n현재 선택: {DisplayFolder(job.SelectedRelativeFolder)}", + allowLater: true, + allowSkip: true); + if (result.Action == FolderSelectionAction.Apply) + { + await ApplySelectionAsync([job.Id], result.RelativeFolder); + } + else if (result.Action == FolderSelectionAction.Skip) + { + await SkipSelectionAsync(job.Id); + } + else if (result.Action == FolderSelectionAction.Later) + { + deferredSelectionPrompts.Add(job.Id); + } + }; + panel.Children.Add(apply); + + var later = new Button { Content = "나중에 선택", HorizontalAlignment = HorizontalAlignment.Stretch }; + later.Click += async (_, _) => + { + deferredSelectionPrompts.Add(job.Id); + await ShowMessageAsync("선택 대기 상태를 유지합니다. 이 파일은 이동되지 않았습니다."); + }; + panel.Children.Add(later); + + var skip = new Button { Content = "이번 파일은 이동하지 않기", HorizontalAlignment = HorizontalAlignment.Stretch }; + skip.Click += async (_, _) => await SkipSelectionAsync(job.Id); + panel.Children.Add(skip); + + return new Border + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(16), + CornerRadius = new CornerRadius(8), + Background = themeManager.GetThemeBrush("CardSurfaceBrush"), + Child = panel, + }; + } + + private async Task ApplySelectionAsync(IReadOnlyList jobIds, string displayedFolder) + { + var relativeFolder = displayedFolder == "." ? string.Empty : displayedFolder; + var response = await agent.SendAsync( + "selection.complete", + new SelectionCompletedPayload(jobIds, relativeFolder)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "선택 적용에 실패했습니다."); + return; + } + + foreach (var jobId in jobIds) + { + deferredSelectionPrompts.Remove(jobId); + } + + await ShowPendingAsync(); + } + + private async Task SkipSelectionAsync(Guid jobId) + { + var confirmed = await ShowConfirmationAsync( + "이번 파일은 이동하지 않기", + "이 작업은 Download Router의 이동만 건너뜁니다. 브라우저가 받은 실제 파일은 삭제하지 않습니다."); + if (!confirmed) + { + return; + } + + var response = await agent.SendAsync("selection.skip", new SelectionSkippedPayload(jobId)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "이동 건너뛰기 적용에 실패했습니다."); + return; + } + + deferredSelectionPrompts.Remove(jobId); + await ShowPendingAsync(); + } + + private static string DisplayFolder(string? relativeFolder) + => string.IsNullOrEmpty(relativeFolder) ? "저장 루트" : relativeFolder; +} diff --git a/src/DownloadRouter.App/MainWindow.Rules.cs b/src/DownloadRouter.App/MainWindow.Rules.cs new file mode 100644 index 0000000..d13e67c --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.Rules.cs @@ -0,0 +1,305 @@ +using DownloadRouter.Core.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.Storage.Pickers; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private Guid? editingRuleId; + + private async Task ShowRulesManagementAsync() + { + Prepare("사이트 규칙", "새 규칙 만들기와 저장된 규칙을 한 페이지의 구분된 섹션에서 관리합니다."); + try + { + var response = await agent.SendAsync("rules.list"); + var rules = AgentClient.ReadData>(response) ?? []; + var editing = editingRuleId is Guid id ? rules.FirstOrDefault(rule => rule.Id == id) : null; + if (editingRuleId is not null && editing is null) + { + editingRuleId = null; + } + + AddRuleEditor(editing); + ContentPanel.Children.Add(new Border { Height = 1, Opacity = 0.35, Margin = new Thickness(0, 12, 0, 12) }); + ContentPanel.Children.Add(new TextBlock + { + Text = $"저장된 규칙 · {rules.Count}개", + Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style, + }); + foreach (var rule in rules) + { + ContentPanel.Children.Add(CreateRuleCard(rule)); + } + + if (rules.Count == 0) + { + AddMuted("아직 저장된 규칙이 없습니다."); + } + } + catch (Exception exception) + { + AddError("규칙 목록을 불러오지 못했습니다.", exception); + } + } + + private void AddRuleEditor(DownloadRule? editing) + { + var panel = new StackPanel { Spacing = 12 }; + panel.Children.Add(new TextBlock + { + Text = editing is null ? "새 규칙 만들기" : $"규칙 편집 · {editing.Name}", + Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style, + TextWrapping = TextWrapping.Wrap, + }); + var name = new TextBox + { + Header = "규칙 이름", + PlaceholderText = "예: 네이버 다운로드", + Text = editing?.Name ?? string.Empty, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var matchValue = new TextBox + { + Header = "대상 사이트 또는 URL 일부", + PlaceholderText = "naver.com", + Text = editing?.MatchValue ?? string.Empty, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var matchType = new ComboBox + { + Header = "매칭 방식", + ItemsSource = Enum.GetValues(), + SelectedItem = editing?.MatchType ?? RuleMatchType.DomainAndSubdomains, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var matchTarget = new ComboBox + { + Header = "매칭 대상", + ItemsSource = Enum.GetValues(), + SelectedItem = editing?.MatchTarget ?? RuleMatchTarget.InitiatingPage, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var storageRoot = new TextBox + { + Header = "저장 루트", + Text = editing?.StorageRoot ?? "{Downloads}\\eslee\\Routed", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var chooseRoot = new Button + { + Content = "폴더 선택…", + HorizontalAlignment = HorizontalAlignment.Left, + }; + chooseRoot.Click += async (_, _) => await PickStorageRootAsync(storageRoot); + var storageMode = new ComboBox + { + Header = "저장 방식", + ItemsSource = Enum.GetValues(), + SelectedItem = editing?.StorageMode ?? StorageMode.Automatic, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var pathStatus = new TextBlock { TextWrapping = TextWrapping.Wrap, Opacity = 0.75 }; + var checkPath = new Button { Content = "경로 읽기/쓰기 진단", HorizontalAlignment = HorizontalAlignment.Left }; + checkPath.Click += (_, _) => pathStatus.Text = DiagnoseStorageRoot(storageRoot.Text); + var preview = new TextBlock + { + Text = "매칭 예시가 여기에 표시됩니다.", + TextWrapping = TextWrapping.Wrap, + }; + matchValue.TextChanged += (_, _) => preview.Text = string.IsNullOrWhiteSpace(matchValue.Text) + ? "매칭 예시가 여기에 표시됩니다." + : $"{matchValue.Text.Trim()} 및 선택한 방식에 맞는 주소에 적용됩니다."; + + var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + var save = new Button { Content = editing is null ? "규칙 저장" : "규칙 수정" }; + save.Click += async (_, _) => + { + try + { + var diagnosis = DiagnoseStorageRoot(storageRoot.Text); + pathStatus.Text = diagnosis; + if (!diagnosis.StartsWith("사용 가능", StringComparison.Ordinal)) + { + return; + } + + var now = DateTimeOffset.UtcNow; + var rule = new DownloadRule( + editing?.Id ?? Guid.NewGuid(), + name.Text.Trim(), + editing?.IsEnabled ?? true, + (RuleMatchType)matchType.SelectedItem, + matchValue.Text.Trim(), + (RuleMatchTarget)matchTarget.SelectedItem, + storageRoot.Text.Trim(), + (StorageMode)storageMode.SelectedItem, + editing?.Priority ?? 0, + editing?.ListOrder ?? 0, + editing?.CreatedAt ?? now, + now); + var response = await agent.SendAsync("rules.upsert", rule); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "규칙 저장에 실패했습니다."); + return; + } + + editingRuleId = null; + await ShowRulesManagementAsync(); + } + catch (Exception exception) + { + await ShowMessageAsync("규칙 저장 실패: " + exception.Message); + } + }; + actions.Children.Add(save); + if (editing is not null) + { + var cancel = new Button { Content = "편집 취소" }; + cancel.Click += async (_, _) => + { + editingRuleId = null; + await ShowRulesManagementAsync(); + }; + actions.Children.Add(cancel); + } + + panel.Children.Add(name); + panel.Children.Add(matchValue); + panel.Children.Add(matchType); + panel.Children.Add(matchTarget); + panel.Children.Add(storageRoot); + panel.Children.Add(chooseRoot); + panel.Children.Add(checkPath); + panel.Children.Add(pathStatus); + panel.Children.Add(storageMode); + panel.Children.Add(preview); + panel.Children.Add(actions); + ContentPanel.Children.Add(new Border + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(20), + CornerRadius = new CornerRadius(10), + Background = themeManager.GetThemeBrush("CardSurfaceBrush"), + Child = panel, + }); + } + + private Border CreateRuleCard(DownloadRule rule) + { + var panel = new StackPanel { Spacing = 8 }; + panel.Children.Add(new TextBlock + { + Text = rule.Name, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap, + }); + panel.Children.Add(new TextBlock + { + Text = $"매칭: {rule.MatchType} · 대상: {rule.MatchTarget}\n값: {rule.MatchValue}\n저장 루트: {rule.StorageRoot}\n저장 방식: {rule.StorageMode}", + TextWrapping = TextWrapping.Wrap, + }); + var enabled = new ToggleSwitch { Header = "규칙 활성화", IsOn = rule.IsEnabled }; + enabled.Toggled += async (_, _) => + { + var response = await agent.SendAsync( + "rules.upsert", + rule with { IsEnabled = enabled.IsOn, UpdatedAt = DateTimeOffset.UtcNow }); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "규칙 상태 변경에 실패했습니다."); + } + }; + panel.Children.Add(enabled); + + var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + var edit = new Button { Content = "편집" }; + edit.Click += async (_, _) => + { + editingRuleId = rule.Id; + await ShowRulesManagementAsync(); + ContentScrollViewer.ChangeView(null, 0, null); + }; + actions.Children.Add(edit); + var delete = new Button { Content = "삭제" }; + delete.Click += async (_, _) => + { + if (!await ShowConfirmationAsync( + "사이트 규칙 삭제", + "이 규칙을 삭제해도 이미 이동된 파일과 다운로드 이력은 삭제되지 않습니다.")) + { + return; + } + + var response = await agent.SendAsync("rules.delete", new RuleDeletePayload(rule.Id)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "규칙 삭제에 실패했습니다."); + return; + } + + if (editingRuleId == rule.Id) + { + editingRuleId = null; + } + await ShowRulesManagementAsync(); + }; + actions.Children.Add(delete); + panel.Children.Add(actions); + return new Border + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(16), + CornerRadius = new CornerRadius(8), + Background = themeManager.GetThemeBrush("CardSurfaceBrush"), + Child = panel, + }; + } + + private async Task PickStorageRootAsync(TextBox target) + { + var picker = new FolderPicker(); + picker.FileTypeFilter.Add("*"); + var handle = WinRT.Interop.WindowNative.GetWindowHandle(this); + WinRT.Interop.InitializeWithWindow.Initialize(picker, handle); + var selected = await picker.PickSingleFolderAsync(); + if (selected is not null) + { + target.Text = selected.Path; + } + } + + private string DiagnoseStorageRoot(string configuredPath) + { + try + { + var fullPath = pathResolver.Resolve(configuredPath.Trim()); + if (!Directory.Exists(fullPath)) + { + return "사용 불가: 폴더가 존재하지 않습니다."; + } + + _ = Directory.EnumerateFileSystemEntries(fullPath).Take(1).ToArray(); + var probe = Path.Combine(fullPath, $".eslee-write-test-{Guid.NewGuid():N}.tmp"); + using (new FileStream( + probe, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 1, + FileOptions.DeleteOnClose)) + { + } + + return $"사용 가능: 읽기/쓰기 확인 · {fullPath}"; + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) + { + return $"사용 불가: {exception.Message}"; + } + } +} diff --git a/src/DownloadRouter.App/MainWindow.Settings.cs b/src/DownloadRouter.App/MainWindow.Settings.cs new file mode 100644 index 0000000..78af969 --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.Settings.cs @@ -0,0 +1,87 @@ +using DownloadRouter.Core.Models; +using DownloadRouter.Core.Startup; +using DownloadRouter.Core.Settings; +using Microsoft.UI.Xaml.Controls; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private readonly AutoStartRegistration autoStartRegistration = new(); + + private Task ShowSettingsManagementAsync() + { + Prepare("일반 설정", "로그인 자동 시작과 창 닫기 동작은 현재 사용자 설정으로 즉시 저장됩니다."); + var executablePath = Environment.ProcessPath + ?? throw new InvalidOperationException("현재 App 실행 파일 경로를 확인할 수 없습니다."); + var autoStart = new ToggleSwitch + { + Header = "Windows 로그인 시 백그라운드로 실행", + OffContent = "사용 안 함", + OnContent = "사용 중", + IsOn = autoStartRegistration.IsEnabled(executablePath), + }; + autoStart.Toggled += async (_, _) => + { + try + { + autoStartRegistration.SetEnabled(autoStart.IsOn, executablePath); + } + catch (Exception exception) + { + await ShowMessageAsync("자동 시작 설정 변경 실패: " + exception.Message); + autoStart.IsOn = autoStartRegistration.IsEnabled(executablePath); + } + }; + ContentPanel.Children.Add(autoStart); + AddMuted("로그인 시 DownloadRouter.App --background가 실행되어 Agent와 트레이를 준비합니다. Native Host의 Agent 자동 시작 fallback도 유지됩니다."); + + var closeBehavior = new ComboBox + { + Header = "창 닫기 버튼 동작", + ItemsSource = new[] { "트레이로 최소화", "프로그램 UI 완전 종료" }, + SelectedIndex = preferences.CloseBehavior == WindowCloseBehavior.MinimizeToTray ? 0 : 1, + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Stretch, + }; + closeBehavior.SelectionChanged += (_, _) => + { + preferences = preferences with + { + CloseBehavior = closeBehavior.SelectedIndex == 1 + ? WindowCloseBehavior.ExitApplication + : WindowCloseBehavior.MinimizeToTray, + }; + preferencesStore.Save(preferences); + }; + ContentPanel.Children.Add(closeBehavior); + AddMuted("기본값은 트레이로 최소화입니다. 트레이 메뉴의 ‘종료’는 설정과 관계없이 UI와 트레이를 종료합니다."); + + var theme = new ComboBox + { + Header = "테마", + ItemsSource = new[] { "시스템 설정", "라이트", "다크" }, + SelectedIndex = preferences.ThemePreference switch + { + AppThemePreference.Light => 1, + AppThemePreference.Dark => 2, + _ => 0, + }, + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Stretch, + }; + theme.SelectionChanged += (_, _) => + { + var selected = theme.SelectedIndex switch + { + 1 => AppThemePreference.Light, + 2 => AppThemePreference.Dark, + _ => AppThemePreference.System, + }; + preferences = preferences with { Theme = AppThemePolicy.ToStorageValue(selected) }; + preferencesStore.Save(preferences); + themeManager.SetPreference(selected); + }; + ContentPanel.Children.Add(theme); + AddMuted("시스템 설정은 ElementTheme.Default를 사용하므로 Windows 앱 테마 변경을 따릅니다. 라이트/다크 선택은 열린 창과 이후 생성되는 저장 위치 선택 창에 즉시 적용됩니다."); + return Task.CompletedTask; + } +} diff --git a/src/DownloadRouter.App/MainWindow.xaml b/src/DownloadRouter.App/MainWindow.xaml index e4bc863..5b900db 100644 --- a/src/DownloadRouter.App/MainWindow.xaml +++ b/src/DownloadRouter.App/MainWindow.xaml @@ -2,9 +2,30 @@ x:Class="DownloadRouter.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - + + + + + + + + + + + + + + + + + + + + + - + + + + + - - + + + + diff --git a/src/DownloadRouter.App/MainWindow.xaml.cs b/src/DownloadRouter.App/MainWindow.xaml.cs index 33e4d00..6048d94 100644 --- a/src/DownloadRouter.App/MainWindow.xaml.cs +++ b/src/DownloadRouter.App/MainWindow.xaml.cs @@ -1,8 +1,10 @@ -using System.Text.Json; using DownloadRouter.Core.Models; using DownloadRouter.Core.Paths; +using DownloadRouter.Core.Settings; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Windows.Storage; +using Windows.System; namespace DownloadRouter.App; @@ -12,15 +14,26 @@ public sealed partial class MainWindow : Window private readonly PathTokenResolver pathResolver = new(new WindowsKnownPathProvider()); private readonly PathBoundaryValidator boundaryValidator = new(); - public MainWindow() + public MainWindow( + AppPreferencesStore preferencesStore, + ThemeManager themeManager, + AppPreferences preferences) { + this.preferencesStore = preferencesStore; + this.themeManager = themeManager; + this.preferences = preferences; InitializeComponent(); + themeManager.RegisterWindow(this); Title = "eslee Download Router"; Navigation.SelectedItem = Navigation.MenuItems[0]; + InitializeLiveUpdates(); + Closed += (_, _) => StopLiveUpdates(); _ = ShowDashboardAsync(); } - private async void Navigation_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + private async void Navigation_SelectionChanged( + NavigationView sender, + NavigationViewSelectionChangedEventArgs args) { if (args.SelectedItemContainer?.Tag is not string tag) { @@ -34,188 +47,22 @@ private Task ShowPageAsync(string tag) => tag switch { "dashboard" => ShowDashboardAsync(), - "rules" => ShowRulesAsync(), + "rules" => ShowRulesManagementAsync(), "history" => ShowHistoryAsync(), "pending" => ShowPendingAsync(), "browsers" => ShowBrowsersAsync(), - "settings" => ShowSettingsAsync(), + "settings" => ShowSettingsManagementAsync(), "diagnostics" => ShowDiagnosticsAsync(), "about" => ShowAboutAsync(), _ => Task.CompletedTask, }; - private async Task ShowDashboardAsync() + private void ContentScrollViewer_SizeChanged(object sender, SizeChangedEventArgs args) { - Prepare("대시보드", "규칙이 적용된 다운로드와 연결 상태를 한눈에 확인합니다."); - try - { - var jobsResponse = await agent.SendAsync("jobs.list"); - var rulesResponse = await agent.SendAsync("rules.list"); - var jobs = AgentClient.ReadData>(jobsResponse) ?? []; - var rules = AgentClient.ReadData>(rulesResponse) ?? []; - AddCard("활성 규칙", rules.Count(static rule => rule.IsEnabled).ToString()); - AddCard("저장 위치 선택 대기", jobs.Count(static job => job.Status == DownloadJobStatus.WaitingForSelection).ToString()); - AddCard("최근 완료", jobs.Count(static job => job.Status == DownloadJobStatus.Completed).ToString()); - AddCard("재시도/실패", jobs.Count(static job => job.Status is DownloadJobStatus.RetryPending or DownloadJobStatus.Failed).ToString()); - } - catch (Exception exception) - { - AddError("Agent에 연결할 수 없습니다. scripts/run-dev.ps1을 실행한 뒤 다시 시도하세요.", exception); - } - } - - private async Task ShowRulesAsync() - { - Prepare("사이트 규칙", "도메인, 정확한 호스트 또는 URL 일부를 기준으로 저장 위치를 지정합니다."); - var name = new TextBox { Header = "규칙 이름", PlaceholderText = "예: 네이버 다운로드" }; - var matchValue = new TextBox { Header = "대상 사이트 또는 URL 일부", PlaceholderText = "naver.com" }; - var storageRoot = new TextBox { Header = "저장 루트", Text = "{Downloads}\\eslee\\Routed" }; - var matchType = new ComboBox { Header = "매칭 방식", ItemsSource = Enum.GetValues(), SelectedIndex = 0 }; - var matchTarget = new ComboBox { Header = "매칭 대상", ItemsSource = Enum.GetValues(), SelectedItem = RuleMatchTarget.InitiatingPage }; - var storageMode = new ComboBox { Header = "저장 방식", ItemsSource = Enum.GetValues(), SelectedIndex = 0 }; - var preview = new TextBlock { Text = "naver.com은 naver.com과 모든 하위 도메인에 일치합니다.", TextWrapping = TextWrapping.Wrap }; - matchValue.TextChanged += (_, _) => preview.Text = string.IsNullOrWhiteSpace(matchValue.Text) - ? "매칭 예시가 여기에 표시됩니다." - : $"{matchValue.Text.Trim()} 및 선택한 방식에 맞는 주소에 적용됩니다."; - var save = new Button { Content = "규칙 저장", HorizontalAlignment = HorizontalAlignment.Left }; - save.Click += async (_, _) => - { - try - { - var now = DateTimeOffset.UtcNow; - var rule = new DownloadRule( - Guid.NewGuid(), - name.Text.Trim(), - true, - (RuleMatchType)matchType.SelectedItem, - matchValue.Text.Trim(), - (RuleMatchTarget)matchTarget.SelectedItem, - storageRoot.Text.Trim(), - (StorageMode)storageMode.SelectedItem, - 0, - 0, - now, - now); - var response = await agent.SendAsync("rules.upsert", rule); - await ShowMessageAsync(response.Success ? "규칙을 저장했습니다." : response.Message ?? "규칙 저장에 실패했습니다."); - await ShowRulesAsync(); - } - catch (Exception exception) - { - await ShowMessageAsync("규칙 저장 실패: " + exception.Message); - } - }; - - ContentPanel.Children.Add(name); - ContentPanel.Children.Add(matchValue); - ContentPanel.Children.Add(matchType); - ContentPanel.Children.Add(matchTarget); - ContentPanel.Children.Add(storageRoot); - ContentPanel.Children.Add(storageMode); - ContentPanel.Children.Add(preview); - ContentPanel.Children.Add(save); - ContentPanel.Children.Add(new Border { Height = 1, Opacity = 0.4 }); - - try - { - var response = await agent.SendAsync("rules.list"); - var rules = AgentClient.ReadData>(response) ?? []; - foreach (var rule in rules) - { - AddCard(rule.Name, $"{rule.MatchType}: {rule.MatchValue} → {rule.StorageRoot} ({rule.StorageMode})"); - } - - if (rules.Count == 0) - { - AddMuted("아직 저장된 규칙이 없습니다."); - } - } - catch (Exception exception) - { - AddError("규칙 목록을 불러오지 못했습니다.", exception); - } - } - - private async Task ShowHistoryAsync() - { - Prepare("다운로드 작업 및 이력", "최근 작업의 성공·실패·대기 상태와 정제된 출처를 표시합니다."); - try - { - var response = await agent.SendAsync("jobs.list"); - var jobs = AgentClient.ReadData>(response) ?? []; - foreach (var job in jobs) - { - AddCard(job.CurrentFileName, $"{job.Browser} · {job.Status} · {job.SanitizedSource ?? "출처 확인 불가"}\n{job.ErrorMessage ?? job.FinalPath ?? string.Empty}"); - } - - if (jobs.Count == 0) - { - AddMuted("아직 추적된 다운로드가 없습니다. 규칙 없는 다운로드는 브라우저 기본 동작을 유지합니다."); - } - } - catch (Exception exception) - { - AddError("다운로드 이력을 불러오지 못했습니다.", exception); - } - } - - private async Task ShowPendingAsync() - { - Prepare("저장 위치 선택 대기", "각 규칙의 루트와 실제 하위 폴더만 선택할 수 있습니다."); - try - { - var jobs = AgentClient.ReadData>(await agent.SendAsync("jobs.list")) ?? []; - var rules = (AgentClient.ReadData>(await agent.SendAsync("rules.list")) ?? []) - .ToDictionary(static rule => rule.Id); - var groups = jobs - .Where(static job => job.Status == DownloadJobStatus.WaitingForSelection) - .GroupBy(static job => job.RuleId) - .ToList(); - - foreach (var group in groups) - { - if (!rules.TryGetValue(group.Key, out var rule)) - { - continue; - } - - var root = pathResolver.Resolve(rule.StorageRoot); - var folders = EnumerateSafeFolders(root); - var picker = new ComboBox - { - Header = $"{rule.Name}: {group.Count()}개 파일", - ItemsSource = folders, - SelectedIndex = folders.Count > 0 ? 0 : -1, - MinWidth = 420, - }; - var apply = new Button { Content = "대기열 전체에 적용" }; - apply.Click += async (_, _) => - { - if (picker.SelectedItem is not string selected) - { - await ShowMessageAsync("하위 폴더를 선택하세요."); - return; - } - - var response = await agent.SendAsync("selection.complete", new SelectionCompletedPayload( - group.Select(static job => job.Id).ToArray(), - selected == "." ? string.Empty : selected)); - await ShowMessageAsync(response.Success ? "선택을 적용했습니다." : response.Message ?? "선택 적용에 실패했습니다."); - await ShowPendingAsync(); - }; - ContentPanel.Children.Add(picker); - ContentPanel.Children.Add(apply); - } - - if (groups.Count == 0) - { - AddMuted("현재 선택을 기다리는 파일이 없습니다."); - } - } - catch (Exception exception) - { - AddError("선택 대기 목록을 불러오지 못했습니다.", exception); - } + var available = Math.Max( + 0, + args.NewSize.Width - ContentViewport.Padding.Left - ContentViewport.Padding.Right); + ContentPanel.Width = Math.Min(1100, available); } private Task ShowBrowsersAsync() @@ -224,15 +71,23 @@ private Task ShowBrowsersAsync() foreach (var browser in BrowserCatalog.Detect()) { var panel = new StackPanel { Spacing = 8 }; - panel.Children.Add(new TextBlock { Text = browser.DisplayName, Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style }); + panel.Children.Add(new TextBlock + { + Text = browser.DisplayName, + Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style, + }); panel.Children.Add(new TextBlock { Text = browser.IsInstalled - ? $"설치됨 · {(browser.IsOfficial ? "정식 지원" : "호환 지원")} · 확장 연결은 브라우저에서 확인 필요" + ? $"설치됨 · {(browser.IsOfficial ? "공식 지원" : "호환 지원")} · 확장 연결은 브라우저에서 확인 필요" : "설치되지 않음", TextWrapping = TextWrapping.Wrap, }); - var open = new Button { Content = "확장 관리 페이지 열기", IsEnabled = browser.IsInstalled }; + var open = new Button + { + Content = "확장 관리 페이지 열기", + IsEnabled = browser.IsInstalled, + }; open.Click += async (_, _) => { try @@ -245,25 +100,21 @@ private Task ShowBrowsersAsync() } }; panel.Children.Add(open); - ContentPanel.Children.Add(new Border { Padding = new Thickness(16), CornerRadius = new CornerRadius(8), Child = panel }); + ContentPanel.Children.Add(new Border + { + Padding = new Thickness(16), + CornerRadius = new CornerRadius(8), + Child = panel, + }); } - AddMuted("설치 순서: 개발자 모드 켜기 → 압축 해제된 확장 로드 → src/DownloadRouter.Extension/dist 선택 → Native Host 등록 → 연결 테스트."); - return Task.CompletedTask; - } - - private Task ShowSettingsAsync() - { - Prepare("일반 설정", "모든 설정과 데이터는 현재 사용자 LocalAppData 아래에만 저장됩니다."); - ContentPanel.Children.Add(new ToggleSwitch { Header = "Windows 로그인 시 Agent 실행", IsOn = false }); - ContentPanel.Children.Add(new ComboBox { Header = "테마", ItemsSource = new[] { "시스템 설정", "라이트", "다크" }, SelectedIndex = 0 }); - AddMuted("시작 프로그램 등록은 기본값이 아니며, 현재 화면의 토글은 다음 단계에서 Agent 설정 저장과 연결됩니다."); + AddMuted("설치 순서: 개발자 모드 켜기 → 압축 해제된 확장 로드 → src/DownloadRouter.Extension/dist 선택 → Native Host 등록 → 연결 테스트"); return Task.CompletedTask; } private async Task ShowDiagnosticsAsync() { - Prepare("진단 및 문제 해결", "민감 URL과 다운로드 파일을 포함하지 않는 로컬 진단 정보를 표시합니다."); + Prepare("진단 및 문제 해결", "민감 URL과 다운로드 파일을 포함하지 않는 로컬 진단 정보만 표시합니다."); var test = new Button { Content = "Agent 연결 테스트" }; test.Click += async (_, _) => { @@ -282,6 +133,12 @@ private async Task ShowDiagnosticsAsync() { var response = await agent.SendAsync("diagnostics.status"); AddCard("현재 진단 상태", response.Data?.GetRawText() ?? "응답 없음"); + var rasterizationScale = ContentPanel.XamlRoot?.RasterizationScale; + AddCard( + "UI 렌더링 배율", + rasterizationScale is null + ? "현재 창의 배율을 확인할 수 없습니다." + : $"{rasterizationScale.Value * 100:0}% (XamlRoot RasterizationScale {rasterizationScale.Value:0.##})"); } catch (Exception exception) { @@ -289,67 +146,87 @@ private async Task ShowDiagnosticsAsync() } } - private Task ShowAboutAsync() - { - Prepare("eslee Download Router", "Chromium 다운로드를 사이트 규칙에 따라 안전하게 정리하는 로컬 Windows 프로그램입니다."); - AddCard("개인정보", "서버 전송, 텔레메트리, 광고 SDK가 없습니다. 다운로드 처리에 필요한 최소 정보만 로컬에 정제해 저장합니다."); - AddCard("지원 범위", "Windows 11 x64 · Whale / Edge / Chrome 정식 지원 · Brave / Vivaldi / Opera 호환 지원 · Firefox 제외"); - AddCard("프로토콜", $"Native Messaging → 현재 사용자 Named Pipe v{ProtocolConstants.CurrentVersion} → 단일 Agent"); - return Task.CompletedTask; - } - - private List EnumerateSafeFolders(string root) + private async Task ShowAboutAsync() { - var result = new List { "." }; - if (!Directory.Exists(root)) + var version = ProductVersionInfo.Read(typeof(App).Assembly, AppContext.BaseDirectory); + Prepare(version.ProductName, "Chromium 다운로드를 사이트 규칙에 따라 안전하게 정리하는 로컬 Windows 프로그램입니다."); + AddCard("현재 버전", version.DisplayVersion); + AddCard("빌드", $"{version.BuildDescription} · {version.InformationalVersion}"); + AddCard("배포 형태", version.DistributionDescription); + AddCard("개인정보", "서버 전송, 텔레메트리, 광고 SDK가 없습니다. 다운로드 처리에 필요한 최소 정보만 로컬에 저장합니다."); + AddCard("지원 범위", "Windows 11 x64 · Whale / Edge / Chrome 공식 지원 · Brave / Vivaldi / Opera 호환 지원 · Firefox 제외"); + AddCard("프로토콜", $"Native Messaging ↔ 현재 사용자 Named Pipe v{ProtocolConstants.CurrentVersion} ↔ 단일 Agent"); + + var openData = new Button { - return result; - } - - var queue = new Queue(); - queue.Enqueue(root); - while (queue.Count > 0 && result.Count < 1000) + Content = "데이터 저장 위치 열기", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + openData.Click += async (_, _) => { - var current = queue.Dequeue(); - foreach (var child in Directory.EnumerateDirectories(current).OrderBy(static path => path, StringComparer.CurrentCultureIgnoreCase)) + try { - if ((File.GetAttributes(child) & FileAttributes.ReparsePoint) != 0) - { - continue; - } - - boundaryValidator.EnsureWithin(root, child); - result.Add(Path.GetRelativePath(root, child)); - queue.Enqueue(child); + var dataDirectory = Path.GetDirectoryName(preferencesStore.ConfigPath) + ?? throw new InvalidOperationException("데이터 저장 위치를 확인할 수 없습니다."); + Directory.CreateDirectory(dataDirectory); + var folder = await StorageFolder.GetFolderFromPathAsync(dataDirectory); + _ = await Launcher.LaunchFolderAsync(folder); } - } + catch (Exception exception) + { + await ShowMessageAsync("데이터 저장 위치 열기 실패: " + exception.Message); + } + }; + ContentPanel.Children.Add(openData); - return result; + var openGitHub = new Button + { + Content = "GitHub 저장소 열기", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + openGitHub.Click += async (_, _) => + _ = await Launcher.LaunchUriAsync(new Uri("https://github.com/esleeeeee/eslee-Download-Router")); + ContentPanel.Children.Add(openGitHub); + await Task.CompletedTask; } private void Prepare(string title, string description) { ContentPanel.Children.Clear(); - ContentPanel.Children.Add(new TextBlock { Text = title, Style = Application.Current.Resources["TitleTextBlockStyle"] as Style }); + ContentPanel.Children.Add(new TextBlock + { + Text = title, + Style = Application.Current.Resources["TitleTextBlockStyle"] as Style, + }); ContentPanel.Children.Add(new TextBlock { Text = description, TextWrapping = TextWrapping.Wrap }); } private void AddCard(string title, string value) { var panel = new StackPanel { Spacing = 4 }; - panel.Children.Add(new TextBlock { Text = title, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }); + panel.Children.Add(new TextBlock + { + Text = title, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); panel.Children.Add(new TextBlock { Text = value, TextWrapping = TextWrapping.Wrap }); ContentPanel.Children.Add(new Border { + HorizontalAlignment = HorizontalAlignment.Stretch, Padding = new Thickness(16), CornerRadius = new CornerRadius(8), - Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Microsoft.UI.Xaml.Media.Brush, + Background = themeManager.GetThemeBrush("CardSurfaceBrush"), Child = panel, }); } private void AddMuted(string text) - => ContentPanel.Children.Add(new TextBlock { Text = text, Opacity = 0.7, TextWrapping = TextWrapping.Wrap }); + => ContentPanel.Children.Add(new TextBlock + { + Text = text, + Opacity = 0.7, + TextWrapping = TextWrapping.Wrap, + }); private void AddError(string prefix, Exception exception) => ContentPanel.Children.Add(new InfoBar @@ -362,13 +239,21 @@ private void AddError(string prefix, Exception exception) private async Task ShowMessageAsync(string message) { - var dialog = new ContentDialog + blockingDialogOpen = true; + try { - Title = "eslee Download Router", - Content = message, - CloseButtonText = "확인", - XamlRoot = ContentPanel.XamlRoot, - }; - await dialog.ShowAsync(); + var dialog = new ContentDialog + { + Title = "eslee Download Router", + Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, + CloseButtonText = "확인", + XamlRoot = ContentPanel.XamlRoot, + }; + await dialog.ShowAsync(); + } + finally + { + blockingDialogOpen = false; + } } } diff --git a/src/DownloadRouter.App/ThemeManager.cs b/src/DownloadRouter.App/ThemeManager.cs new file mode 100644 index 0000000..f63a7ef --- /dev/null +++ b/src/DownloadRouter.App/ThemeManager.cs @@ -0,0 +1,122 @@ +using DownloadRouter.Core.Models; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace DownloadRouter.App; + +public sealed class ThemeManager(AppThemePreference initialPreference) +{ + private readonly HashSet windows = []; + private readonly Dictionary roots = []; + + public AppThemePreference CurrentPreference { get; private set; } = initialPreference; + + public static ElementTheme ToElementTheme(AppThemePreference preference) + => preference switch + { + AppThemePreference.Light => ElementTheme.Light, + AppThemePreference.Dark => ElementTheme.Dark, + _ => ElementTheme.Default, + }; + + public void RegisterWindow(Window window) + { + ArgumentNullException.ThrowIfNull(window); + if (windows.Add(window)) + { + window.Closed += Window_Closed; + if (window.Content is FrameworkElement registeredRoot) + { + roots[window] = registeredRoot; + registeredRoot.ActualThemeChanged += Root_ActualThemeChanged; + } + } + + Apply(window); + } + + public void SetPreference(AppThemePreference preference) + { + CurrentPreference = Enum.IsDefined(preference) ? preference : AppThemePreference.System; + foreach (var window in windows.ToArray()) + { + Apply(window); + } + } + + public Brush? GetThemeBrush(string resourceKey) + { + var activeTheme = roots.Values.FirstOrDefault()?.ActualTheme == ElementTheme.Dark + ? "Dark" + : "Light"; + return Application.Current.Resources.ThemeDictionaries[activeTheme] is ResourceDictionary dictionary + ? dictionary[resourceKey] as Brush + : null; + } + + private void Apply(Window window) + { + if (window.Content is FrameworkElement root) + { + root.RequestedTheme = ToElementTheme(CurrentPreference); + ApplyBackground(root); + ApplyTitleBar(window, root.ActualTheme); + } + } + + private static void ApplyBackground(FrameworkElement root) + { + if (root is not Panel panel) + { + return; + } + + var themeKey = root.ActualTheme == ElementTheme.Dark ? "Dark" : "Light"; + if (Application.Current.Resources.ThemeDictionaries[themeKey] is ResourceDictionary dictionary + && dictionary["WindowBackgroundBrush"] is Brush brush) + { + panel.Background = brush; + } + } + + private void Root_ActualThemeChanged(FrameworkElement sender, object args) + { + ApplyBackground(sender); + var window = roots.FirstOrDefault(pair => ReferenceEquals(pair.Value, sender)).Key; + if (window is not null) + { + ApplyTitleBar(window, sender.ActualTheme); + } + } + + private static void ApplyTitleBar(Window window, ElementTheme theme) + { + var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow( + WinRT.Interop.WindowNative.GetWindowHandle(window)); + var titleBar = AppWindow.GetFromWindowId(windowId).TitleBar; + var dark = theme == ElementTheme.Dark; + titleBar.BackgroundColor = dark ? Color.FromArgb(255, 32, 32, 32) : Color.FromArgb(255, 243, 243, 243); + titleBar.ForegroundColor = dark ? Color.FromArgb(255, 255, 255, 255) : Color.FromArgb(255, 0, 0, 0); + titleBar.ButtonBackgroundColor = titleBar.BackgroundColor; + titleBar.ButtonForegroundColor = titleBar.ForegroundColor; + titleBar.ButtonHoverBackgroundColor = dark ? Color.FromArgb(255, 56, 56, 56) : Color.FromArgb(255, 229, 229, 229); + titleBar.ButtonHoverForegroundColor = titleBar.ForegroundColor; + titleBar.ButtonPressedBackgroundColor = dark ? Color.FromArgb(255, 74, 74, 74) : Color.FromArgb(255, 210, 210, 210); + titleBar.ButtonPressedForegroundColor = titleBar.ForegroundColor; + } + + private void Window_Closed(object sender, WindowEventArgs args) + { + if (sender is Window window) + { + windows.Remove(window); + if (roots.Remove(window, out var root)) + { + root.ActualThemeChanged -= Root_ActualThemeChanged; + } + } + } +} diff --git a/src/DownloadRouter.App/TrayIconHost.cs b/src/DownloadRouter.App/TrayIconHost.cs new file mode 100644 index 0000000..f9ab2f5 --- /dev/null +++ b/src/DownloadRouter.App/TrayIconHost.cs @@ -0,0 +1,331 @@ +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using Microsoft.UI.Dispatching; + +namespace DownloadRouter.App; + +public sealed class TrayIconHost : IDisposable +{ + private const uint TrayMessage = 0x8001; + private const uint IconId = 1; + private const uint NimAdd = 0x00000000; + private const uint NimModify = 0x00000001; + private const uint NimDelete = 0x00000002; + private const uint NimSetVersion = 0x00000004; + private const uint NifMessage = 0x00000001; + private const uint NifIcon = 0x00000002; + private const uint NifTip = 0x00000004; + private const uint NifInfo = 0x00000010; + private const uint NotifyIconVersion4 = 4; + private const uint WmLeftButtonDoubleClick = 0x0203; + private const uint WmRightButtonUp = 0x0205; + private const uint WmContextMenu = 0x007B; + private const uint WmCommand = 0x0111; + private const uint NinSelect = 0x0400; + private const uint MfString = 0x00000000; + private const uint MfSeparator = 0x00000800; + private const uint TpmRightButton = 0x0002; + private const uint TpmReturnCommand = 0x0100; + private const uint OpenCommand = 1001; + private const uint PendingCommand = 1002; + private const uint SettingsCommand = 1003; + private const uint ExitCommand = 1004; + private static readonly ConcurrentDictionary Instances = new(); + private static readonly WindowProcedureDelegate WindowProcedureInstance = WindowProcedure; + + private readonly DispatcherQueue dispatcher; + private readonly Action open; + private readonly Action openPending; + private readonly Action openSettings; + private readonly Action exit; + private readonly string windowClassName = $"eslee.DownloadRouter.Tray.{Environment.ProcessId}"; + private nint windowHandle; + private nint menuHandle; + private bool disposed; + + public TrayIconHost( + DispatcherQueue dispatcher, + Action open, + Action openPending, + Action openSettings, + Action exit) + { + this.dispatcher = dispatcher; + this.open = open; + this.openPending = openPending; + this.openSettings = openSettings; + this.exit = exit; + CreateMessageWindow(); + CreateMenu(); + AddIcon(); + } + + public void ShowSelectionNotification(int pendingCount) + { + var data = CreateIconData(NifInfo); + data.InfoTitle = "저장 위치 선택 대기"; + data.Info = $"선택을 기다리는 다운로드가 {pendingCount}개 있습니다."; + data.InfoFlags = 0x00000001; + _ = ShellNotifyIcon(NimModify, ref data); + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + var data = CreateIconData(0); + _ = ShellNotifyIcon(NimDelete, ref data); + Instances.TryRemove(windowHandle, out _); + if (menuHandle != 0) + { + _ = DestroyMenu(menuHandle); + } + if (windowHandle != 0) + { + _ = DestroyWindow(windowHandle); + } + } + + private void CreateMessageWindow() + { + var windowClass = new WindowClass + { + WindowProcedure = Marshal.GetFunctionPointerForDelegate(WindowProcedureInstance), + Instance = GetModuleHandle(null), + ClassName = windowClassName, + }; + if (RegisterClass(ref windowClass) == 0) + { + throw new InvalidOperationException("The tray message window class could not be registered."); + } + + windowHandle = CreateWindowEx( + 0, + windowClassName, + "eslee Download Router tray", + 0, + 0, + 0, + 0, + 0, + new nint(-3), + 0, + windowClass.Instance, + 0); + if (windowHandle == 0) + { + throw new InvalidOperationException("The tray message window could not be created."); + } + + Instances[windowHandle] = this; + } + + private void CreateMenu() + { + menuHandle = CreatePopupMenu(); + _ = AppendMenu(menuHandle, MfString, OpenCommand, "eslee Download Router 열기"); + _ = AppendMenu(menuHandle, MfString, PendingCommand, "저장 위치 선택 대기 열기"); + _ = AppendMenu(menuHandle, MfString, SettingsCommand, "일반 설정"); + _ = AppendMenu(menuHandle, MfSeparator, 0, string.Empty); + _ = AppendMenu(menuHandle, MfString, ExitCommand, "종료"); + } + + private void AddIcon() + { + var data = CreateIconData(NifMessage | NifIcon | NifTip); + data.CallbackMessage = TrayMessage; + data.Icon = LoadIcon(0, new nint(32512)); + data.Tip = "eslee Download Router"; + if (!ShellNotifyIcon(NimAdd, ref data)) + { + throw new InvalidOperationException("The system tray icon could not be created."); + } + + data.TimeoutOrVersion = NotifyIconVersion4; + _ = ShellNotifyIcon(NimSetVersion, ref data); + } + + private NotifyIconData CreateIconData(uint flags) + => new() + { + Size = (uint)Marshal.SizeOf(), + Window = windowHandle, + Id = IconId, + Flags = flags, + Tip = string.Empty, + Info = string.Empty, + InfoTitle = string.Empty, + }; + + private void HandleNotification(uint notification) + { + if (notification is WmLeftButtonDoubleClick or NinSelect) + { + dispatcher.TryEnqueue(() => open()); + return; + } + + if (notification is not WmRightButtonUp and not WmContextMenu) + { + return; + } + + _ = GetCursorPos(out var point); + _ = SetForegroundWindow(windowHandle); + var command = TrackPopupMenu( + menuHandle, + TpmRightButton | TpmReturnCommand, + point.X, + point.Y, + 0, + windowHandle, + 0); + HandleCommand(command); + } + + private void HandleCommand(uint command) + { + var action = command switch + { + OpenCommand => open, + PendingCommand => openPending, + SettingsCommand => openSettings, + ExitCommand => exit, + _ => null, + }; + if (action is not null) + { + dispatcher.TryEnqueue(() => action()); + } + } + + private static nint WindowProcedure(nint window, uint message, nint wParam, nint lParam) + { + if (message == TrayMessage && Instances.TryGetValue(window, out var instance)) + { + instance.HandleNotification((uint)(lParam.ToInt64() & 0xffff)); + return 0; + } + + if (message == WmCommand && Instances.TryGetValue(window, out instance)) + { + instance.HandleCommand((uint)(wParam.ToInt64() & 0xffff)); + return 0; + } + + return DefWindowProc(window, message, wParam, lParam); + } + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + private delegate nint WindowProcedureDelegate(nint window, uint message, nint wParam, nint lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WindowClass + { + public uint Style; + public nint WindowProcedure; + public int ClassExtra; + public int WindowExtra; + public nint Instance; + public nint Icon; + public nint Cursor; + public nint Background; + public string? MenuName; + public string ClassName; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct NotifyIconData + { + public uint Size; + public nint Window; + public uint Id; + public uint Flags; + public uint CallbackMessage; + public nint Icon; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string Tip; + public uint State; + public uint StateMask; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string Info; + public uint TimeoutOrVersion; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string InfoTitle; + public uint InfoFlags; + public Guid ItemGuid; + public nint BalloonIcon; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Point + { + public int X; + public int Y; + } + + [DllImport("shell32.dll", EntryPoint = "Shell_NotifyIconW", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShellNotifyIcon(uint message, ref NotifyIconData data); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern ushort RegisterClass(ref WindowClass windowClass); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern nint CreateWindowEx( + uint extendedStyle, + string className, + string windowName, + uint style, + int x, + int y, + int width, + int height, + nint parent, + nint menu, + nint instance, + nint parameter); + + [DllImport("user32.dll")] + private static extern nint DefWindowProc(nint window, uint message, nint wParam, nint lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyWindow(nint window); + + [DllImport("user32.dll")] + private static extern nint CreatePopupMenu(); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AppendMenu(nint menu, uint flags, uint id, string text); + + [DllImport("user32.dll")] + private static extern uint TrackPopupMenu( + nint menu, + uint flags, + int x, + int y, + int reserved, + nint window, + nint rectangle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyMenu(nint menu); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetCursorPos(out Point point); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(nint window); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern nint LoadIcon(nint instance, nint iconName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern nint GetModuleHandle(string? moduleName); +} diff --git a/src/DownloadRouter.App/app.manifest b/src/DownloadRouter.App/app.manifest index 81f13ab..6b75f7e 100644 --- a/src/DownloadRouter.App/app.manifest +++ b/src/DownloadRouter.App/app.manifest @@ -1,6 +1,12 @@ + + + true/pm + PerMonitorV2, PerMonitor + + diff --git a/src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs b/src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs new file mode 100644 index 0000000..474151d --- /dev/null +++ b/src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs @@ -0,0 +1,29 @@ +using DownloadRouter.Core.Models; + +namespace DownloadRouter.Core.Jobs; + +public static class DownloadJobQueries +{ + public static IReadOnlyList ActiveSelections(IEnumerable jobs) + => jobs.Where(static job => job.IsSelectionPending).ToList(); + + public static IReadOnlyList AutomaticSelections( + IEnumerable jobs, + DateTimeOffset now) + => jobs + .Where(job => SelectionPromptPolicy.IsAutoPromptEligible(job, now)) + .OrderBy(static job => job.LastBrowserEventAt ?? job.CreatedAt) + .ThenBy(static job => job.CreatedAt) + .ToList(); + + public static DashboardCounts CountDashboard(IEnumerable jobs) + { + var snapshot = jobs.ToList(); + return new DashboardCounts( + snapshot.Count(static job => job.BrowserState == BrowserTransferState.InProgress), + snapshot.Count(static job => job.IsSelectionPending), + snapshot.Count(static job => job.RoutingState == RoutingState.Completed), + snapshot.Count(static job => job.BrowserState is BrowserTransferState.Cancelled or BrowserTransferState.Interrupted), + snapshot.Count(static job => job.RoutingState is RoutingState.RetryPending or RoutingState.Failed)); + } +} diff --git a/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs b/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs index 07456ad..b8ca8c7 100644 --- a/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs +++ b/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs @@ -4,32 +4,53 @@ namespace DownloadRouter.Core.Jobs; public sealed class DownloadJobStateMachine { - private static readonly IReadOnlyDictionary> AllowedTransitions - = new Dictionary> + private static readonly IReadOnlyDictionary> AllowedBrowserTransitions + = new Dictionary> { - [DownloadJobStatus.Detected] = Set(DownloadJobStatus.WaitingForDownload, DownloadJobStatus.WaitingForSelection, DownloadJobStatus.Cancelled, DownloadJobStatus.Failed), - [DownloadJobStatus.WaitingForDownload] = Set(DownloadJobStatus.ReadyToMove, DownloadJobStatus.WaitingForSelection, DownloadJobStatus.Cancelled, DownloadJobStatus.Interrupted, DownloadJobStatus.Failed), - [DownloadJobStatus.WaitingForSelection] = Set(DownloadJobStatus.WaitingForDownload, DownloadJobStatus.ReadyToMove, DownloadJobStatus.Cancelled, DownloadJobStatus.Interrupted, DownloadJobStatus.Failed), - [DownloadJobStatus.ReadyToMove] = Set(DownloadJobStatus.Moving, DownloadJobStatus.Cancelled, DownloadJobStatus.Failed), - [DownloadJobStatus.Moving] = Set(DownloadJobStatus.Completed, DownloadJobStatus.RetryPending, DownloadJobStatus.Failed), - [DownloadJobStatus.RetryPending] = Set(DownloadJobStatus.ReadyToMove, DownloadJobStatus.Moving, DownloadJobStatus.Failed), - [DownloadJobStatus.Interrupted] = Set(DownloadJobStatus.WaitingForDownload, DownloadJobStatus.WaitingForSelection, DownloadJobStatus.ReadyToMove, DownloadJobStatus.Failed), - [DownloadJobStatus.Failed] = Set(DownloadJobStatus.RetryPending), - [DownloadJobStatus.Completed] = Set(), - [DownloadJobStatus.Cancelled] = Set(), + [BrowserTransferState.InProgress] = BrowserSet(BrowserTransferState.Complete, BrowserTransferState.Cancelled, BrowserTransferState.Interrupted), + [BrowserTransferState.Complete] = BrowserSet(), + [BrowserTransferState.Cancelled] = BrowserSet(), + [BrowserTransferState.Interrupted] = BrowserSet(), }; - public bool CanTransition(DownloadJobStatus current, DownloadJobStatus next) - => current == next || (AllowedTransitions.TryGetValue(current, out var allowed) && allowed.Contains(next)); + private static readonly IReadOnlyDictionary> AllowedRoutingTransitions + = new Dictionary> + { + [RoutingState.WaitingForSelection] = RoutingSet(RoutingState.SelectionReady, RoutingState.Skipped, RoutingState.Failed, RoutingState.NotRequired), + [RoutingState.SelectionReady] = RoutingSet(RoutingState.Moving, RoutingState.Skipped, RoutingState.Failed, RoutingState.NotRequired), + [RoutingState.NotRequired] = RoutingSet(RoutingState.Moving, RoutingState.Failed, RoutingState.Skipped), + [RoutingState.Moving] = RoutingSet(RoutingState.Completed, RoutingState.RetryPending, RoutingState.Failed), + [RoutingState.RetryPending] = RoutingSet(RoutingState.Moving, RoutingState.Failed, RoutingState.Skipped), + [RoutingState.Failed] = RoutingSet(RoutingState.RetryPending), + [RoutingState.Completed] = RoutingSet(RoutingState.Moving), + [RoutingState.Skipped] = RoutingSet(RoutingState.SelectionReady, RoutingState.Moving), + }; + + public bool CanTransition(BrowserTransferState current, BrowserTransferState next) + => current == next || (AllowedBrowserTransitions.TryGetValue(current, out var allowed) && allowed.Contains(next)); - public void EnsureCanTransition(DownloadJobStatus current, DownloadJobStatus next) + public bool CanTransition(RoutingState current, RoutingState next) + => current == next || (AllowedRoutingTransitions.TryGetValue(current, out var allowed) && allowed.Contains(next)); + + public void EnsureCanTransition(BrowserTransferState current, BrowserTransferState next) { if (!CanTransition(current, next)) { - throw new InvalidOperationException($"Invalid download job transition: {current} -> {next}."); + throw new InvalidOperationException($"Invalid browser transfer transition: {current} -> {next}."); } } - private static ISet Set(params DownloadJobStatus[] values) - => new HashSet(values); + public void EnsureCanTransition(RoutingState current, RoutingState next) + { + if (!CanTransition(current, next)) + { + throw new InvalidOperationException($"Invalid routing transition: {current} -> {next}."); + } + } + + private static ISet BrowserSet(params BrowserTransferState[] values) + => new HashSet(values); + + private static ISet RoutingSet(params RoutingState[] values) + => new HashSet(values); } diff --git a/src/DownloadRouter.Core/Jobs/DownloadPresentation.cs b/src/DownloadRouter.Core/Jobs/DownloadPresentation.cs new file mode 100644 index 0000000..7c0814a --- /dev/null +++ b/src/DownloadRouter.Core/Jobs/DownloadPresentation.cs @@ -0,0 +1,41 @@ +using DownloadRouter.Core.Models; + +namespace DownloadRouter.Core.Jobs; + +public static class DownloadPresentation +{ + public const string PendingFileName = "파일 이름 확인 중…"; + + public static string DisplayFileName(DownloadJob job) + => TrustedFileName(job.CurrentFileName) ?? PendingFileName; + + public static string? TrustedFileName(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var fileName = Path.GetFileName(value.Trim()); + if (string.IsNullOrWhiteSpace(fileName) + || fileName.Equals("download", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".crdownload", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".partial", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return fileName; + } + + public static bool IsCancelled(DownloadJob job) + => job.BrowserState == BrowserTransferState.Cancelled; + + public static bool CanChangeRoute(DownloadJob job) + => job.BrowserState is BrowserTransferState.InProgress or BrowserTransferState.Complete + && job.RoutingState is RoutingState.WaitingForSelection + or RoutingState.SelectionReady + or RoutingState.Skipped + or RoutingState.Completed; +} diff --git a/src/DownloadRouter.Core/Jobs/SelectionPromptPolicy.cs b/src/DownloadRouter.Core/Jobs/SelectionPromptPolicy.cs new file mode 100644 index 0000000..0349193 --- /dev/null +++ b/src/DownloadRouter.Core/Jobs/SelectionPromptPolicy.cs @@ -0,0 +1,22 @@ +using DownloadRouter.Core.Models; + +namespace DownloadRouter.Core.Jobs; + +public static class SelectionPromptPolicy +{ + public static readonly TimeSpan AutoPromptWindow = TimeSpan.FromMinutes(30); + + public static bool IsAutoPromptEligible(DownloadJob job, DateTimeOffset now) + { + if (!job.IsSelectionPending || job.IsBrowserRecordStale) + { + return false; + } + + var lastActivity = job.LastBrowserEventAt ?? job.CreatedAt; + return lastActivity <= now && now - lastActivity <= AutoPromptWindow; + } + + public static bool IsPreviousSessionPending(DownloadJob job, DateTimeOffset now) + => job.IsSelectionPending && !IsAutoPromptEligible(job, now); +} diff --git a/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs b/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs new file mode 100644 index 0000000..9504dff --- /dev/null +++ b/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs @@ -0,0 +1,74 @@ +namespace DownloadRouter.Core.Jobs; + +public sealed class SelectionPromptQueue +{ + private readonly Queue queue = new(); + private readonly HashSet queued = []; + + public int Count => queue.Count; + + public bool Enqueue(Guid jobId) + { + if (jobId == Guid.Empty || !queued.Add(jobId)) + { + return false; + } + + queue.Enqueue(jobId); + return true; + } + + public bool EnqueueFirst(Guid jobId) + { + if (jobId == Guid.Empty || !queued.Add(jobId)) + { + return false; + } + + var remaining = queue.ToArray(); + queue.Clear(); + queue.Enqueue(jobId); + foreach (var id in remaining) + { + queue.Enqueue(id); + } + + return true; + } + + public bool TryDequeue(out Guid jobId) + { + if (!queue.TryDequeue(out jobId)) + { + return false; + } + + queued.Remove(jobId); + return true; + } + + public bool Remove(Guid jobId) + { + if (!queued.Remove(jobId)) + { + return false; + } + + var remaining = queue.Where(id => id != jobId).ToArray(); + queue.Clear(); + foreach (var id in remaining) + { + queue.Enqueue(id); + } + + return true; + } + + public IReadOnlyList Drain() + { + var result = queue.ToArray(); + queue.Clear(); + queued.Clear(); + return result; + } +} diff --git a/src/DownloadRouter.Core/Jobs/SelectionWindowActivationCoordinator.cs b/src/DownloadRouter.Core/Jobs/SelectionWindowActivationCoordinator.cs new file mode 100644 index 0000000..5209b3a --- /dev/null +++ b/src/DownloadRouter.Core/Jobs/SelectionWindowActivationCoordinator.cs @@ -0,0 +1,99 @@ +namespace DownloadRouter.Core.Jobs; + +public readonly record struct NativeWindowState( + nint Handle, + bool IsVisible, + bool IsIconic, + nint OwnerHandle, + nint ForegroundHandle) +{ + public bool IsForeground => Handle != 0 && ForegroundHandle == Handle; +} + +public interface ISelectionWindowActivationOperations +{ + NativeWindowState Capture(nint windowHandle); + + bool ShowSelectionNormal(nint selectionWindowHandle); + + void ActivateSelection(); + + bool BringSelectionToTop(nint selectionWindowHandle); + + bool TrySetSelectionForeground(nint selectionWindowHandle); + + bool TryAttachInputAndActivate(nint selectionWindowHandle); + + bool RaiseSelectionAboveOtherWindows(nint selectionWindowHandle); + + void FlashSelection(nint selectionWindowHandle); +} + +public sealed record SelectionWindowActivationResult( + NativeWindowState MainBefore, + NativeWindowState SelectionBefore, + NativeWindowState SelectionAfterShow, + NativeWindowState MainAfter, + NativeWindowState SelectionAfter, + bool ShowNormalSucceeded, + bool BringToTopSucceeded, + bool DirectForegroundSucceeded, + bool AttachedForegroundSucceeded, + bool RaisedForegroundSucceeded, + bool FlashFallbackUsed); + +public sealed class SelectionWindowActivationCoordinator( + ISelectionWindowActivationOperations operations) +{ + public SelectionWindowActivationResult Activate( + nint mainWindowHandle, + nint selectionWindowHandle) + { + ArgumentNullException.ThrowIfNull(operations); + if (selectionWindowHandle == 0) + { + throw new ArgumentException("The selection window handle must be valid.", nameof(selectionWindowHandle)); + } + + var mainBefore = operations.Capture(mainWindowHandle); + var selectionBefore = operations.Capture(selectionWindowHandle); + var showNormalSucceeded = operations.ShowSelectionNormal(selectionWindowHandle); + operations.ActivateSelection(); + var bringToTopSucceeded = operations.BringSelectionToTop(selectionWindowHandle); + var selectionAfterShow = operations.Capture(selectionWindowHandle); + + var directForegroundSucceeded = operations.TrySetSelectionForeground(selectionWindowHandle); + var attachedForegroundSucceeded = false; + var raisedForegroundSucceeded = false; + + if (!operations.Capture(selectionWindowHandle).IsForeground) + { + attachedForegroundSucceeded = operations.TryAttachInputAndActivate(selectionWindowHandle); + } + + if (!operations.Capture(selectionWindowHandle).IsForeground) + { + raisedForegroundSucceeded = operations.RaiseSelectionAboveOtherWindows(selectionWindowHandle); + } + + var selectionAfter = operations.Capture(selectionWindowHandle); + var flashFallbackUsed = !selectionAfter.IsForeground; + if (flashFallbackUsed) + { + operations.FlashSelection(selectionWindowHandle); + } + + return new SelectionWindowActivationResult( + mainBefore, + selectionBefore, + selectionAfterShow, + operations.Capture(mainWindowHandle), + selectionAfter, + showNormalSucceeded, + bringToTopSucceeded, + directForegroundSucceeded, + attachedForegroundSucceeded, + raisedForegroundSucceeded, + flashFallbackUsed); + } +} diff --git a/src/DownloadRouter.Core/Models/DomainModels.cs b/src/DownloadRouter.Core/Models/DomainModels.cs index 361ad70..62c9192 100644 --- a/src/DownloadRouter.Core/Models/DomainModels.cs +++ b/src/DownloadRouter.Core/Models/DomainModels.cs @@ -33,6 +33,19 @@ public enum StorageMode SelectSubfolder, } +public enum WindowCloseBehavior +{ + MinimizeToTray, + ExitApplication, +} + +public enum AppThemePreference +{ + System, + Light, + Dark, +} + public enum DownloadJobStatus { Detected, @@ -45,6 +58,27 @@ public enum DownloadJobStatus Failed, Cancelled, Interrupted, + Skipped, +} + +public enum BrowserTransferState +{ + InProgress, + Complete, + Cancelled, + Interrupted, +} + +public enum RoutingState +{ + WaitingForSelection, + SelectionReady, + Moving, + RetryPending, + Completed, + Skipped, + Failed, + NotRequired, } public sealed record DownloadRule( @@ -92,11 +126,44 @@ public sealed record DownloadJob( string? OriginalPath, string? FinalPath, string? SelectedRelativeFolder, - DownloadJobStatus Status, + BrowserTransferState BrowserState, + RoutingState RoutingState, string? ErrorCode, string? ErrorMessage, DateTimeOffset CreatedAt, - DateTimeOffset? CompletedAt); + DateTimeOffset? CompletedAt, + DateTimeOffset? LastBrowserEventAt = null, + bool IsBrowserRecordStale = false) +{ + public DownloadJobStatus Status + => BrowserState switch + { + BrowserTransferState.Cancelled => DownloadJobStatus.Cancelled, + BrowserTransferState.Interrupted => DownloadJobStatus.Interrupted, + _ => RoutingState switch + { + RoutingState.WaitingForSelection => DownloadJobStatus.WaitingForSelection, + RoutingState.SelectionReady when BrowserState == BrowserTransferState.InProgress => DownloadJobStatus.WaitingForDownload, + RoutingState.SelectionReady => DownloadJobStatus.ReadyToMove, + RoutingState.Moving => DownloadJobStatus.Moving, + RoutingState.RetryPending => DownloadJobStatus.RetryPending, + RoutingState.Completed => DownloadJobStatus.Completed, + RoutingState.Skipped => DownloadJobStatus.Skipped, + RoutingState.Failed => DownloadJobStatus.Failed, + RoutingState.NotRequired when BrowserState == BrowserTransferState.InProgress => DownloadJobStatus.WaitingForDownload, + RoutingState.NotRequired => DownloadJobStatus.ReadyToMove, + _ => DownloadJobStatus.Detected, + }, + }; + + public bool IsSelectionPending + => BrowserState is BrowserTransferState.InProgress or BrowserTransferState.Complete + && RoutingState == RoutingState.WaitingForSelection; + + public bool IsTerminal + => BrowserState is BrowserTransferState.Cancelled or BrowserTransferState.Interrupted + || RoutingState is RoutingState.Completed or RoutingState.Skipped or RoutingState.Failed; +} public sealed record AgentCommand( int Version, @@ -134,27 +201,69 @@ public sealed record DownloadChangedPayload( string DownloadId, string State, string? FilePath, - string? Error); + string? Error, + string? FileName = null, + bool IsReconciliation = false); + +public sealed record DownloadMetadataChangedPayload( + string Browser, + string DownloadId, + string? FilePath, + string? FileName); public sealed record SelectionCompletedPayload( IReadOnlyList JobIds, string RelativeFolder); +public sealed record SelectionSkippedPayload(Guid JobId); + +public sealed record JobRouteChangePayload( + Guid JobId, + string RelativeFolder, + bool ConfirmCompletedMove = false); + +public sealed record JobsDeletePayload(IReadOnlyList JobIds); + +public sealed record RuleDeletePayload(Guid RuleId); + +public sealed record ActiveDownloadsPayload(string Browser); + +public sealed record ActiveBrowserDownload(Guid JobId, string DownloadId); + +public sealed record DashboardCounts( + int DownloadsInProgress, + int WaitingForSelection, + int RecentlyCompleted, + int CancelledOrInterrupted, + int RetryOrFailed); + public static class ProtocolConstants { public const int CurrentVersion = 1; public const int MaximumMessageBytes = 1024 * 1024; public const string AgentPipeName = "eslee.download-router.agent.v1"; + public const string AppMutexName = "Local\\eslee.DownloadRouter.App"; + public const string AppActivationEventName = "Local\\eslee.DownloadRouter.App.Activate"; + public const string AppShutdownEventName = "Local\\eslee.DownloadRouter.App.Shutdown"; + public const string AgentShutdownEventName = "Local\\eslee.DownloadRouter.Agent.Shutdown"; public static readonly ISet AllowedCommands = new HashSet(StringComparer.Ordinal) { "ping", "download.started", + "download.metadata", "download.changed", + "download.cancelled", + "download.interrupted", "rules.list", "rules.upsert", + "rules.delete", "jobs.list", + "jobs.delete", + "downloads.active", "selection.complete", + "selection.skip", + "route.change", "job.retry", "diagnostics.status", }; diff --git a/src/DownloadRouter.Core/Models/ProductVersionInfo.cs b/src/DownloadRouter.Core/Models/ProductVersionInfo.cs new file mode 100644 index 0000000..c88b735 --- /dev/null +++ b/src/DownloadRouter.Core/Models/ProductVersionInfo.cs @@ -0,0 +1,52 @@ +using System.Reflection; + +namespace DownloadRouter.Core.Models; + +public sealed record ProductVersionInfo( + string ProductName, + string InformationalVersion, + string SemanticVersion, + string? Commit, + bool IsInstalledBuild) +{ + public const string Name = "eslee Download Router"; + + public string DisplayVersion => $"버전 {SemanticVersion}"; + + public string BuildDescription + => Commit is null ? "빌드 정보 없음" : $"커밋 {Commit}"; + + public string DistributionDescription + => IsInstalledBuild ? "설치형 빌드" : "개발 빌드"; + + public static ProductVersionInfo Read(Assembly assembly, string baseDirectory) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory); + var informational = assembly + .GetCustomAttribute()? + .InformationalVersion; + if (string.IsNullOrWhiteSpace(informational)) + { + informational = assembly.GetName().Version?.ToString(3) ?? "0.0.0"; + } + + var separator = informational.IndexOf('+', StringComparison.Ordinal); + var semantic = separator >= 0 ? informational[..separator] : informational; + var revision = separator >= 0 ? informational[(separator + 1)..] : null; + var commit = string.IsNullOrWhiteSpace(revision) + ? null + : new string(revision.Where(char.IsAsciiLetterOrDigit).Take(7).ToArray()); + if (commit is { Length: 0 }) + { + commit = null; + } + + return new ProductVersionInfo( + Name, + informational, + semantic, + commit, + File.Exists(Path.Combine(baseDirectory, "unins000.exe"))); + } +} diff --git a/src/DownloadRouter.Core/Paths/SafeFolderTreeProvider.cs b/src/DownloadRouter.Core/Paths/SafeFolderTreeProvider.cs new file mode 100644 index 0000000..37b8c00 --- /dev/null +++ b/src/DownloadRouter.Core/Paths/SafeFolderTreeProvider.cs @@ -0,0 +1,75 @@ +namespace DownloadRouter.Core.Paths; + +public sealed record FolderTreeEntry( + string Name, + string FullPath, + string RelativePath, + bool IsAccessible, + string? ErrorMessage = null); + +public sealed record FolderTreeChildren( + IReadOnlyList Entries, + string? ErrorMessage = null); + +public sealed class SafeFolderTreeProvider(PathBoundaryValidator boundaryValidator) +{ + public Task GetChildrenAsync( + string storageRoot, + string parentPath, + CancellationToken cancellationToken = default) + => Task.Run(() => GetChildren(storageRoot, parentPath, cancellationToken), cancellationToken); + + private FolderTreeChildren GetChildren( + string storageRoot, + string parentPath, + CancellationToken cancellationToken) + { + var root = Path.GetFullPath(storageRoot); + var parent = Path.GetFullPath(parentPath); + boundaryValidator.EnsureWithin(root, parent); + if (!Directory.Exists(parent)) + { + return new FolderTreeChildren([], "폴더가 존재하지 않습니다."); + } + + try + { + var result = new List(); + foreach (var child in Directory.EnumerateDirectories(parent) + .OrderBy(static path => Path.GetFileName(path), StringComparer.CurrentCultureIgnoreCase)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var fullPath = Path.GetFullPath(child); + boundaryValidator.EnsureWithin(root, fullPath); + if ((File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) != 0) + { + continue; + } + + result.Add(new FolderTreeEntry( + Path.GetFileName(fullPath), + fullPath, + Path.GetRelativePath(root, fullPath), + true)); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + result.Add(new FolderTreeEntry( + Path.GetFileName(child), + child, + string.Empty, + false, + "이 폴더에 접근할 수 없습니다.")); + } + } + + return new FolderTreeChildren(result); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + return new FolderTreeChildren([], "이 폴더의 하위 항목을 읽을 수 없습니다."); + } + } +} diff --git a/src/DownloadRouter.Core/Settings/AppPreferencesStore.cs b/src/DownloadRouter.Core/Settings/AppPreferencesStore.cs new file mode 100644 index 0000000..be7433a --- /dev/null +++ b/src/DownloadRouter.Core/Settings/AppPreferencesStore.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using DownloadRouter.Core.Models; + +namespace DownloadRouter.Core.Settings; + +public sealed record AppPreferences( + WindowCloseBehavior CloseBehavior = WindowCloseBehavior.MinimizeToTray, + string Theme = "System") +{ + public AppThemePreference ThemePreference => AppThemePolicy.Parse(Theme); +} + +public static class AppThemePolicy +{ + public static AppThemePreference Parse(string? value) + => Enum.TryParse(value, ignoreCase: true, out var result) + && Enum.IsDefined(result) + ? result + : AppThemePreference.System; + + public static string ToStorageValue(AppThemePreference preference) + => Enum.IsDefined(preference) ? preference.ToString() : AppThemePreference.System.ToString(); + + public static string ToElementThemeName(AppThemePreference preference) + => preference switch + { + AppThemePreference.Light => "Light", + AppThemePreference.Dark => "Dark", + _ => "Default", + }; +} + +public sealed class AppPreferencesStore +{ + private readonly string configPath; + + public AppPreferencesStore(string? configPath = null) + { + this.configPath = configPath ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "eslee", + "DownloadRouter", + "config.local.json"); + } + + public string ConfigPath => configPath; + + public AppPreferences Load() + { + try + { + var loaded = File.Exists(configPath) + ? JsonSerializer.Deserialize(File.ReadAllText(configPath), ProtocolJson.Options) + ?? new AppPreferences() + : new AppPreferences(); + return loaded with { Theme = AppThemePolicy.ToStorageValue(loaded.ThemePreference) }; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + return new AppPreferences(); + } + } + + public void Save(AppPreferences preferences) + { + var directory = Path.GetDirectoryName(configPath) + ?? throw new InvalidOperationException("The settings directory could not be resolved."); + Directory.CreateDirectory(directory); + var normalized = preferences with + { + Theme = AppThemePolicy.ToStorageValue(preferences.ThemePreference), + }; + JsonObject document; + try + { + document = File.Exists(configPath) + ? JsonNode.Parse(File.ReadAllText(configPath)) as JsonObject ?? [] + : []; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + document = []; + } + + document["closeBehavior"] = (int)normalized.CloseBehavior; + document["theme"] = normalized.Theme; + var temporary = configPath + ".tmp"; + File.WriteAllText(temporary, document.ToJsonString(ProtocolJson.Options)); + File.Move(temporary, configPath, overwrite: true); + } +} diff --git a/src/DownloadRouter.Core/Startup/AutoStartRegistration.cs b/src/DownloadRouter.Core/Startup/AutoStartRegistration.cs new file mode 100644 index 0000000..29f6fad --- /dev/null +++ b/src/DownloadRouter.Core/Startup/AutoStartRegistration.cs @@ -0,0 +1,40 @@ +using Microsoft.Win32; +using System.Runtime.Versioning; + +namespace DownloadRouter.Core.Startup; + +[SupportedOSPlatform("windows")] +public sealed class AutoStartRegistration( + string subKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run", + string valueName = "eslee Download Router") +{ + public bool IsEnabled(string executablePath) + { + using var key = Registry.CurrentUser.OpenSubKey(subKeyPath, writable: false); + var current = key?.GetValue(valueName) as string; + return string.Equals(current, BuildCommand(executablePath), StringComparison.OrdinalIgnoreCase); + } + + public void SetEnabled(bool enabled, string executablePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + if (!Path.IsPathFullyQualified(executablePath)) + { + throw new ArgumentException("The startup executable path must be absolute.", nameof(executablePath)); + } + + using var key = Registry.CurrentUser.CreateSubKey(subKeyPath, writable: true) + ?? throw new InvalidOperationException("The current-user startup registry key could not be opened."); + if (enabled) + { + key.SetValue(valueName, BuildCommand(executablePath), RegistryValueKind.String); + } + else + { + key.DeleteValue(valueName, throwOnMissingValue: false); + } + } + + public static string BuildCommand(string executablePath) + => $"\"{Path.GetFullPath(executablePath)}\" --background"; +} diff --git a/src/DownloadRouter.Extension/package.json b/src/DownloadRouter.Extension/package.json index b096c65..47bab94 100644 --- a/src/DownloadRouter.Extension/package.json +++ b/src/DownloadRouter.Extension/package.json @@ -11,7 +11,7 @@ "clean": "node scripts/clean.mjs", "build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-manifest.mjs", "lint": "eslint .", - "test": "tsc -p tsconfig.test.json && node --test build-tests/tests/protocol.test.js build-tests/tests/source-attribution.test.js", + "test": "tsc -p tsconfig.test.json && node --test build-tests/tests/download-state.test.js build-tests/tests/file-name.test.js build-tests/tests/native.test.js build-tests/tests/protocol.test.js build-tests/tests/source-attribution.test.js", "check": "npm run lint && npm run test && npm run build" }, "devDependencies": { diff --git a/src/DownloadRouter.Extension/src/background.ts b/src/DownloadRouter.Extension/src/background.ts index d95ca8c..53cf1a4 100644 --- a/src/DownloadRouter.Extension/src/background.ts +++ b/src/DownloadRouter.Extension/src/background.ts @@ -1,11 +1,26 @@ import { detectBrowser } from "./browser.js"; +import { + isUserCancelled, + preferredDownloadError, + safeDownloadError, +} from "./download-state.js"; +import { trustedDownloadFileName } from "./file-name.js"; import { sendNative } from "./native.js"; -import { createRequest } from "./protocol.js"; +import { createRequest, type AgentCommandName } from "./protocol.js"; import { sourceMetadata } from "./source-attribution.js"; const browser = detectBrowser(navigator.userAgent); +const lastErrors = new Map(); +const reportedTerminalStates = new Map(); + +interface ActiveBrowserDownload { + jobId: string; + downloadId: string; +} chrome.downloads.onCreated.addListener((item) => { + lastErrors.delete(item.id); + reportedTerminalStates.delete(item.id); const metadata = sourceMetadata(item); void sendNative( createRequest("download.started", { @@ -18,24 +33,197 @@ chrome.downloads.onCreated.addListener((item) => { chrome.downloads.onChanged.addListener((delta) => { const state = delta.state?.current; - if (state !== "complete" && state !== "interrupted") { + const deltaError = delta.error?.current ?? null; + if (deltaError) { + lastErrors.set(delta.id, deltaError); + } + + console.debug( + `[Download Router] onChanged downloadId=${safeDownloadId(delta.id)} state=${safeDownloadState(state)} error=${safeDownloadError(deltaError)}`, + ); + + if (isUserCancelled(deltaError)) { + reportTerminalWithoutItem(delta.id, "download.cancelled", "cancelled", deltaError); + return; + } + + if (!delta.filename && state !== "complete" && state !== "interrupted") { return; } chrome.downloads.search({ id: delta.id }, (items) => { const item = items[0]; if (!item) { + if (state === "interrupted") { + reportStale(delta.id); + } return; } - void sendNative( - createRequest("download.changed", { - browser, - downloadId: delta.id.toString(), - state, - filePath: item.filename || null, - error: delta.error?.current ?? item.error ?? null, - }), - ); + if (delta.filename) { + reportDownloadMetadata(item); + } + + if (state === "complete") { + reportTerminal(item, "download.changed", "complete", null); + return; + } + + if (state === "interrupted") { + const error = preferredDownloadError(deltaError, item.error, lastErrors.get(delta.id)); + reportTerminal( + item, + isUserCancelled(error) ? "download.cancelled" : "download.interrupted", + isUserCancelled(error) ? "cancelled" : "interrupted", + error, + ); + } }); }); + +chrome.downloads.onErased.addListener((downloadId) => { + console.debug(`[Download Router] onErased downloadId=${safeDownloadId(downloadId)} event=history-erased`); + lastErrors.delete(downloadId); + reportedTerminalStates.delete(downloadId); +}); + +void reconcileActiveDownloads(); + +function reportTerminal( + item: chrome.downloads.DownloadItem, + command: AgentCommandName, + state: "complete" | "interrupted" | "cancelled", + error: string | null, + isReconciliation = false, +): void { + if (!markTerminalReported(item.id, state)) { + return; + } + + void sendNative( + createRequest(command, { + browser, + downloadId: item.id.toString(), + state, + filePath: item.filename || null, + fileName: trustedDownloadFileName(item.filename), + error, + isReconciliation, + }), + ); +} + +function reportTerminalWithoutItem( + downloadId: number, + command: AgentCommandName, + state: "cancelled" | "interrupted", + error: string | null, +): void { + if (!markTerminalReported(downloadId, state)) { + return; + } + + void sendNative(createRequest(command, { + browser, + downloadId: downloadId.toString(), + state, + filePath: null, + fileName: null, + error, + })); +} + +function reportDownloadMetadata(item: chrome.downloads.DownloadItem): void { + void sendNative( + createRequest("download.metadata", { + browser, + downloadId: item.id.toString(), + filePath: item.filename || null, + fileName: trustedDownloadFileName(item.filename), + }), + ); +} + +function reportReconciledState(item: chrome.downloads.DownloadItem): void { + if (item.state === "complete") { + reportTerminal(item, "download.changed", "complete", null, true); + return; + } + + if (item.state === "interrupted") { + const error = preferredDownloadError(null, item.error, lastErrors.get(item.id)); + reportTerminal( + item, + isUserCancelled(error) ? "download.cancelled" : "download.interrupted", + isUserCancelled(error) ? "cancelled" : "interrupted", + error, + true, + ); + return; + } + + void sendNative(createRequest("download.changed", { + browser, + downloadId: item.id.toString(), + state: "in_progress", + filePath: null, + fileName: null, + error: null, + isReconciliation: true, + })); +} + +function reportStale(downloadId: number): void { + void sendNative(createRequest("download.changed", { + browser, + downloadId: downloadId.toString(), + state: "stale", + filePath: null, + fileName: null, + error: null, + isReconciliation: true, + })); +} + +async function reconcileActiveDownloads(): Promise { + const response = await sendNative<{ browser: string }, ActiveBrowserDownload[]>( + createRequest("downloads.active", { browser }), + ); + if (!response?.success || !Array.isArray(response.data)) { + return; + } + + for (const tracked of response.data) { + const id = Number.parseInt(tracked.downloadId, 10); + if (!Number.isSafeInteger(id) || id < 0) { + continue; + } + + chrome.downloads.search({ id }, (items) => { + const item = items[0]; + if (!item) { + reportStale(id); + return; + } + + reportReconciledState(item); + }); + } +} + +function markTerminalReported(id: number, state: "cancelled" | "interrupted" | "complete"): boolean { + if (reportedTerminalStates.get(id) === state) { + return false; + } + + reportedTerminalStates.set(id, state); + return true; +} + +function safeDownloadId(id: number): string { + return Number.isSafeInteger(id) && id >= 0 ? id.toString() : "invalid"; +} + +function safeDownloadState(state: string | undefined): string { + return state === "in_progress" || state === "complete" || state === "interrupted" ? state : "none"; +} diff --git a/src/DownloadRouter.Extension/src/chrome.d.ts b/src/DownloadRouter.Extension/src/chrome.d.ts index 20f4bec..6dc5641 100644 --- a/src/DownloadRouter.Extension/src/chrome.d.ts +++ b/src/DownloadRouter.Extension/src/chrome.d.ts @@ -48,6 +48,10 @@ declare namespace chrome { addListener(callback: (downloadDelta: DownloadDelta) => void): void; }; + const onErased: { + addListener(callback: (downloadId: number) => void): void; + }; + function search(query: { id?: number }, callback: (results: DownloadItem[]) => void): void; } } diff --git a/src/DownloadRouter.Extension/src/download-state.ts b/src/DownloadRouter.Extension/src/download-state.ts new file mode 100644 index 0000000..692d829 --- /dev/null +++ b/src/DownloadRouter.Extension/src/download-state.ts @@ -0,0 +1,31 @@ +export type ReportedDownloadState = "complete" | "interrupted" | "cancelled"; + +export function reportedDownloadState( + browserState: "complete" | "interrupted", + error: string | null | undefined, +): ReportedDownloadState { + if (browserState === "interrupted" && error?.toUpperCase() === "USER_CANCELED") { + return "cancelled"; + } + + return browserState; +} + +export function preferredDownloadError( + deltaError: string | null | undefined, + itemError: string | null | undefined, + previousError: string | null | undefined, +): string | null { + return deltaError ?? itemError ?? previousError ?? null; +} + +export function isUserCancelled(error: string | null | undefined): boolean { + return error?.toUpperCase() === "USER_CANCELED"; +} + +export function safeDownloadError(error: string | null | undefined): string { + const normalized = error?.toUpperCase() ?? "NONE"; + return /^(USER_(?:CANCELED|SHUTDOWN)|CRASH|NETWORK_[A-Z_]+|FILE_[A-Z_]+|SERVER_[A-Z_]+)$/u.test(normalized) + ? normalized + : "UNKNOWN"; +} diff --git a/src/DownloadRouter.Extension/src/file-name.ts b/src/DownloadRouter.Extension/src/file-name.ts new file mode 100644 index 0000000..6e0967f --- /dev/null +++ b/src/DownloadRouter.Extension/src/file-name.ts @@ -0,0 +1,18 @@ +export function trustedDownloadFileName(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + + const parts = trimmed.split(/[\\/]/u); + const fileName = parts.at(-1)?.trim(); + if ( + !fileName || + fileName.toLowerCase() === "download" || + /\.(?:crdownload|partial|tmp)$/iu.test(fileName) + ) { + return null; + } + + return fileName; +} diff --git a/src/DownloadRouter.Extension/src/native.ts b/src/DownloadRouter.Extension/src/native.ts index a2414c0..6bec3f2 100644 --- a/src/DownloadRouter.Extension/src/native.ts +++ b/src/DownloadRouter.Extension/src/native.ts @@ -4,20 +4,72 @@ import { type AgentResponse, } from "./protocol.js"; -export function sendNative(request: AgentRequest): Promise { +export function sendNative( + request: AgentRequest, +): Promise | null> { + const context = nativeDiagnosticContext(request); return new Promise((resolve) => { chrome.runtime.sendNativeMessage(nativeHostName, request, (response: unknown) => { if (chrome.runtime.lastError) { // Fail open: browser downloads must never depend on the local host. + reportNativeFailure(context, classifyNativeRuntimeError(chrome.runtime.lastError.message)); resolve(null); return; } - resolve(isAgentResponse(response) ? response : null); + if (!isAgentResponse(response)) { + reportNativeFailure(context, "invalid-response"); + resolve(null); + return; + } + + if (!response.success) { + reportNativeFailure(context, safeAgentErrorCode(response.errorCode)); + } else { + console.debug(`[Download Router] native-send ${context} result=success`); + } + + resolve(response as AgentResponse); }); }); } +export function classifyNativeRuntimeError(message: string | undefined): string { + const normalized = message?.toLowerCase() ?? ""; + if (normalized.includes("not found")) { + return "host-not-found"; + } + + if (normalized.includes("not permitted") || normalized.includes("forbidden") || normalized.includes("access")) { + return "host-access-denied"; + } + + if (normalized.includes("exited") || normalized.includes("closed")) { + return "host-exited"; + } + + return "native-message-failed"; +} + +export function safeAgentErrorCode(errorCode: string | undefined): string { + if (errorCode && errorCode.length <= 80 && /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u.test(errorCode)) { + return errorCode; + } + + return "agent-request-failed"; +} + +function reportNativeFailure(context: string, code: string): void { + console.warn(`[Download Router] native-send ${context} result=failure code=${code}; browser download unchanged.`); +} + +export function nativeDiagnosticContext(request: AgentRequest): string { + const payload = request.payload as { downloadId?: unknown }; + const rawId = typeof payload.downloadId === "string" ? payload.downloadId : "none"; + const downloadId = /^[0-9]{1,20}$/u.test(rawId) ? rawId : "invalid"; + return `command=${request.command} downloadId=${downloadId}`; +} + function isAgentResponse(value: unknown): value is AgentResponse { if (typeof value !== "object" || value === null) { return false; diff --git a/src/DownloadRouter.Extension/src/protocol.ts b/src/DownloadRouter.Extension/src/protocol.ts index 4b244fa..086204c 100644 --- a/src/DownloadRouter.Extension/src/protocol.ts +++ b/src/DownloadRouter.Extension/src/protocol.ts @@ -4,7 +4,11 @@ export const nativeHostName = "com.eslee.download_router"; export type AgentCommandName = | "ping" | "download.started" - | "download.changed"; + | "download.metadata" + | "download.changed" + | "download.cancelled" + | "download.interrupted" + | "downloads.active"; export interface AgentRequest { version: number; diff --git a/src/DownloadRouter.Extension/src/source-attribution.ts b/src/DownloadRouter.Extension/src/source-attribution.ts index 8ec3c54..97dd49a 100644 --- a/src/DownloadRouter.Extension/src/source-attribution.ts +++ b/src/DownloadRouter.Extension/src/source-attribution.ts @@ -17,7 +17,7 @@ export interface DownloadLike { export function sourceMetadata(item: DownloadLike): DownloadSourceMetadata { const filePath = nonEmpty(item.filename); return { - fileName: baseName(filePath) ?? "download", + fileName: trustedDownloadFileName(filePath) ?? "", filePath, // Chromium's downloads API does not expose the initiating tab ID. Never guess // from the active tab; referrer and file URLs remain separate fallback fields. @@ -42,16 +42,8 @@ export function httpUrlOrNull(value: string | undefined): string | null { } } -function baseName(path: string | null): string | null { - if (path === null) { - return null; - } - - const parts = path.split(/[\\/]/u); - return parts.at(-1) ?? null; -} - function nonEmpty(value: string | undefined): string | null { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : null; } +import { trustedDownloadFileName } from "./file-name.js"; diff --git a/src/DownloadRouter.Extension/tests/download-state.test.ts b/src/DownloadRouter.Extension/tests/download-state.test.ts new file mode 100644 index 0000000..856019c --- /dev/null +++ b/src/DownloadRouter.Extension/tests/download-state.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isUserCancelled, + preferredDownloadError, + reportedDownloadState, + safeDownloadError, +} from "../src/download-state.js"; + +void test("USER_CANCELED is reported as an explicit cancellation", () => { + assert.equal(reportedDownloadState("interrupted", "USER_CANCELED"), "cancelled"); +}); + +void test("other browser errors remain interrupted", () => { + assert.equal(reportedDownloadState("interrupted", "NETWORK_FAILED"), "interrupted"); +}); + +void test("browser error priority is delta then search result then last observed error", () => { + assert.equal(preferredDownloadError("USER_CANCELED", "NETWORK_FAILED", "FILE_FAILED"), "USER_CANCELED"); + assert.equal(preferredDownloadError(null, "NETWORK_FAILED", "FILE_FAILED"), "NETWORK_FAILED"); + assert.equal(preferredDownloadError(undefined, undefined, "FILE_FAILED"), "FILE_FAILED"); + assert.equal(preferredDownloadError(null, null, null), null); +}); + +void test("USER_CANCELED matching and diagnostics are normalized without raw values", () => { + assert.equal(isUserCancelled("user_canceled"), true); + assert.equal(isUserCancelled("USER_SHUTDOWN"), false); + assert.equal(safeDownloadError("NETWORK_FAILED"), "NETWORK_FAILED"); + assert.equal(safeDownloadError("https://example.test/?token=secret"), "UNKNOWN"); +}); diff --git a/src/DownloadRouter.Extension/tests/file-name.test.ts b/src/DownloadRouter.Extension/tests/file-name.test.ts new file mode 100644 index 0000000..ae8f894 --- /dev/null +++ b/src/DownloadRouter.Extension/tests/file-name.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { trustedDownloadFileName } from "../src/file-name.js"; + +void test("temporary Chromium names are never presented as final names", () => { + assert.equal(trustedDownloadFileName("download"), null); + assert.equal(trustedDownloadFileName("C:\\Downloads\\미확인 197533.crdownload"), null); + assert.equal(trustedDownloadFileName("file.partial"), null); + assert.equal(trustedDownloadFileName("file.tmp"), null); +}); + +void test("a downloads API final path yields only its base file name", () => { + assert.equal(trustedDownloadFileName("C:\\Downloads\\실제 파일 이름.zip"), "실제 파일 이름.zip"); +}); diff --git a/src/DownloadRouter.Extension/tests/native.test.ts b/src/DownloadRouter.Extension/tests/native.test.ts new file mode 100644 index 0000000..85dfd46 --- /dev/null +++ b/src/DownloadRouter.Extension/tests/native.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { classifyNativeRuntimeError, nativeDiagnosticContext, safeAgentErrorCode } from "../src/native.js"; +import { createRequest } from "../src/protocol.js"; + +void test("native runtime failures are classified without retaining raw messages", () => { + assert.equal(classifyNativeRuntimeError("Specified native messaging host not found."), "host-not-found"); + assert.equal(classifyNativeRuntimeError("Access to the native messaging host is not permitted."), "host-access-denied"); + assert.equal(classifyNativeRuntimeError("Native host has exited."), "host-exited"); + assert.equal(classifyNativeRuntimeError("C:\\Users\\person\\private.exe?token=secret"), "native-message-failed"); +}); + +void test("only bounded protocol-style agent error codes are logged", () => { + assert.equal(safeAgentErrorCode("agent.unavailable"), "agent.unavailable"); + assert.equal(safeAgentErrorCode("C:\\Users\\person\\private.exe"), "agent-request-failed"); + assert.equal(safeAgentErrorCode("https://example.test/?token=secret"), "agent-request-failed"); +}); + +void test("native diagnostics contain only command and numeric download ID", () => { + const safe = nativeDiagnosticContext(createRequest( + "download.cancelled", + { downloadId: "123", filePath: "C:\\Users\\person\\private.bin", token: "secret" }, + "request-1", + )); + const rejected = nativeDiagnosticContext(createRequest( + "download.changed", + { downloadId: "https://example.test/?token=secret" }, + "request-2", + )); + + assert.equal(safe, "command=download.cancelled downloadId=123"); + assert.equal(rejected, "command=download.changed downloadId=invalid"); + assert.doesNotMatch(safe, /private|secret|Users/u); +}); diff --git a/src/DownloadRouter.Infrastructure/Files/FileMoveService.cs b/src/DownloadRouter.Infrastructure/Files/FileMoveService.cs index 3f37879..69f2a63 100644 --- a/src/DownloadRouter.Infrastructure/Files/FileMoveService.cs +++ b/src/DownloadRouter.Infrastructure/Files/FileMoveService.cs @@ -77,9 +77,8 @@ public async Task MoveAsync( } logger.LogInformation( - "File move completed from {SourceFileName} to {DestinationDirectory}", - Path.GetFileName(sourceFull), - destinationDirectory); + "File move completed for {SourceFileName}", + Path.GetFileName(sourceFull)); return new FileMoveResult(true, destination, null, null, false); } finally diff --git a/src/DownloadRouter.Infrastructure/Logging/JsonLineFileLogger.cs b/src/DownloadRouter.Infrastructure/Logging/JsonLineFileLogger.cs index 1c4425c..665c18c 100644 --- a/src/DownloadRouter.Infrastructure/Logging/JsonLineFileLogger.cs +++ b/src/DownloadRouter.Infrastructure/Logging/JsonLineFileLogger.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using DownloadRouter.Core.Models; using Microsoft.Extensions.Logging; @@ -44,6 +45,13 @@ private void RotateIfNeeded() private sealed class JsonLineFileLogger(string category, Action writer) : ILogger { + private static readonly Regex HttpUrlPattern = new( + "https?://[^\\s\\\"']+", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + private static readonly Regex WindowsPathPattern = new( + "(?(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; @@ -68,14 +76,15 @@ public void Log( category, eventId = eventId.Id, message, - exception = exception is null ? null : Redact(exception.GetType().Name + ": " + exception.Message), + exception = exception?.GetType().Name, }; writer(JsonSerializer.Serialize(payload, ProtocolJson.Options)); } private static string Redact(string value) { - var result = value; + var result = HttpUrlPattern.Replace(value, "[URL_REDACTED]"); + result = WindowsPathPattern.Replace(result, "[PATH_REDACTED]"); foreach (var marker in new[] { "Authorization:", "Bearer ", "token=", "sig=", "signature=" }) { var index = result.IndexOf(marker, StringComparison.OrdinalIgnoreCase); diff --git a/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs b/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs index 7e6f87b..b211296 100644 --- a/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs +++ b/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs @@ -6,7 +6,7 @@ namespace DownloadRouter.Infrastructure.Storage; public sealed class DownloadRouterRepository(AppPaths paths) { - private const int CurrentSchemaVersion = 1; + private const int CurrentSchemaVersion = 4; public async Task InitializeAsync(CancellationToken cancellationToken = default) { @@ -37,7 +37,8 @@ CREATE TABLE IF NOT EXISTS Rules ( Priority INTEGER NOT NULL, ListOrder INTEGER NOT NULL, CreatedAt TEXT NOT NULL, - UpdatedAt TEXT NOT NULL + UpdatedAt TEXT NOT NULL, + IsDeleted INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS IX_Rules_EnabledOrder @@ -59,10 +60,14 @@ CREATE TABLE IF NOT EXISTS DownloadJobs ( FinalPath TEXT NULL, SelectedRelativeFolder TEXT NULL, Status TEXT NOT NULL, + BrowserState TEXT NOT NULL DEFAULT 'InProgress', + RoutingState TEXT NOT NULL DEFAULT 'NotRequired', ErrorCode TEXT NULL, ErrorMessage TEXT NULL, CreatedAt TEXT NOT NULL, CompletedAt TEXT NULL, + LastBrowserEventAt TEXT NULL, + IsBrowserRecordStale INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(RuleId) REFERENCES Rules(Id), UNIQUE(Browser, BrowserDownloadId) ); @@ -100,11 +105,11 @@ FOREIGN KEY(RuleId) REFERENCES Rules(Id) ); INSERT OR IGNORE INTO MigrationHistory(Version, AppliedAt) - VALUES ($version, $appliedAt); + VALUES (1, $appliedAt); """; - command.Parameters.AddWithValue("$version", CurrentSchemaVersion); command.Parameters.AddWithValue("$appliedAt", Format(DateTimeOffset.UtcNow)); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + await MigrateToCurrentVersionAsync(connection, cancellationToken).ConfigureAwait(false); } public async Task> GetRulesAsync(CancellationToken cancellationToken = default) @@ -116,6 +121,7 @@ public async Task> GetRulesAsync(CancellationToken c SELECT Id, Name, IsEnabled, MatchType, MatchValue, MatchTarget, StorageRoot, StorageMode, Priority, ListOrder, CreatedAt, UpdatedAt FROM Rules + WHERE IsDeleted = 0 ORDER BY ListOrder, CreatedAt, Id; """; @@ -153,10 +159,10 @@ public async Task UpsertRuleAsync(DownloadRule rule, CancellationToken cancellat command.CommandText = """ INSERT INTO Rules( Id, Name, IsEnabled, MatchType, MatchValue, MatchTarget, StorageRoot, - StorageMode, Priority, ListOrder, CreatedAt, UpdatedAt) + StorageMode, Priority, ListOrder, CreatedAt, UpdatedAt, IsDeleted) VALUES( $id, $name, $enabled, $matchType, $matchValue, $matchTarget, $storageRoot, - $storageMode, $priority, $listOrder, $createdAt, $updatedAt) + $storageMode, $priority, $listOrder, $createdAt, $updatedAt, 0) ON CONFLICT(Id) DO UPDATE SET Name = excluded.Name, IsEnabled = excluded.IsEnabled, @@ -167,12 +173,35 @@ ON CONFLICT(Id) DO UPDATE SET StorageMode = excluded.StorageMode, Priority = excluded.Priority, ListOrder = excluded.ListOrder, - UpdatedAt = excluded.UpdatedAt; + UpdatedAt = excluded.UpdatedAt, + IsDeleted = 0; """; AddRuleParameters(command, rule); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + public async Task DeleteRuleAsync(Guid ruleId, CancellationToken cancellationToken = default) + { + if (ruleId == Guid.Empty) + { + throw new ArgumentException("A non-empty rule ID is required.", nameof(ruleId)); + } + + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + UPDATE Rules + SET IsDeleted = 1, + IsEnabled = 0, + UpdatedAt = $updatedAt + WHERE Id = $id AND IsDeleted = 0; + """; + command.Parameters.AddWithValue("$id", ruleId.ToString("D")); + command.Parameters.AddWithValue("$updatedAt", Format(DateTimeOffset.UtcNow)); + return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false) == 1; + } + public async Task CreateJobAsync(DownloadJob job, CancellationToken cancellationToken = default) { await using var connection = CreateConnection(); @@ -183,12 +212,14 @@ INSERT INTO DownloadJobs( Id, Browser, BrowserDownloadId, OriginalFileName, CurrentFileName, InitiatingPageUrl, InitialUrl, FinalUrl, ReferrerUrl, SanitizedSource, RuleId, OriginalPath, FinalPath, SelectedRelativeFolder, Status, - ErrorCode, ErrorMessage, CreatedAt, CompletedAt) + BrowserState, RoutingState, ErrorCode, ErrorMessage, CreatedAt, CompletedAt, + LastBrowserEventAt, IsBrowserRecordStale) VALUES( $id, $browser, $browserDownloadId, $originalFileName, $currentFileName, $initiatingPageUrl, $initialUrl, $finalUrl, $referrerUrl, $sanitizedSource, $ruleId, $originalPath, $finalPath, $selectedRelativeFolder, $status, - $errorCode, $errorMessage, $createdAt, $completedAt); + $browserState, $routingState, $errorCode, $errorMessage, $createdAt, $completedAt, + $lastBrowserEventAt, $isBrowserRecordStale); """; AddJobParameters(command, job); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); @@ -263,9 +294,13 @@ UPDATE DownloadJobs SET FinalPath = $finalPath, SelectedRelativeFolder = $selectedRelativeFolder, Status = $status, + BrowserState = $browserState, + RoutingState = $routingState, ErrorCode = $errorCode, ErrorMessage = $errorMessage, - CompletedAt = $completedAt + CompletedAt = $completedAt, + LastBrowserEventAt = $lastBrowserEventAt, + IsBrowserRecordStale = $isBrowserRecordStale WHERE Id = $id; """; AddJobParameters(command, job); @@ -279,6 +314,67 @@ UPDATE DownloadJobs SET await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } + public async Task DeleteJobsAsync( + IReadOnlyCollection jobIds, + CancellationToken cancellationToken = default) + { + var ids = jobIds.Where(static id => id != Guid.Empty).Distinct().ToArray(); + if (ids.Length is < 1 or > 1000) + { + throw new ArgumentOutOfRangeException(nameof(jobIds), "Delete between 1 and 1000 history items."); + } + + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + var placeholders = ids.Select((_, index) => $"$id{index}").ToArray(); + var inClause = string.Join(", ", placeholders); + + await using (var deleteEvents = connection.CreateCommand()) + { + deleteEvents.Transaction = (SqliteTransaction)transaction; + deleteEvents.CommandText = $"DELETE FROM DownloadEvents WHERE JobId IN ({inClause});"; + AddIdParameters(deleteEvents, ids); + await deleteEvents.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + int affected; + await using (var deleteJobs = connection.CreateCommand()) + { + deleteJobs.Transaction = (SqliteTransaction)transaction; + deleteJobs.CommandText = $"DELETE FROM DownloadJobs WHERE Id IN ({inClause});"; + AddIdParameters(deleteJobs, ids); + affected = await deleteJobs.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return affected; + } + + public async Task> GetActiveBrowserDownloadsAsync( + BrowserKind browser, + CancellationToken cancellationToken = default) + { + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT Id, BrowserDownloadId + FROM DownloadJobs + WHERE Browser = $browser + AND BrowserState = 'InProgress'; + """; + command.Parameters.AddWithValue("$browser", browser.ToString()); + var result = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + result.Add(new ActiveBrowserDownload(Guid.Parse(reader.GetString(0)), reader.GetString(1))); + } + + return result; + } + public async Task RecoverInProgressJobsAsync(CancellationToken cancellationToken = default) { await using var connection = CreateConnection(); @@ -287,9 +383,10 @@ public async Task RecoverInProgressJobsAsync(CancellationToken cancellationToken command.CommandText = """ UPDATE DownloadJobs SET Status = 'RetryPending', + RoutingState = 'RetryPending', ErrorCode = 'agent.restarted', ErrorMessage = 'The agent restarted while this file was being moved.' - WHERE Status IN ('ReadyToMove', 'Moving'); + WHERE RoutingState = 'Moving'; """; await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } @@ -298,10 +395,156 @@ UPDATE DownloadJobs SELECT Id, Browser, BrowserDownloadId, OriginalFileName, CurrentFileName, InitiatingPageUrl, InitialUrl, FinalUrl, ReferrerUrl, SanitizedSource, RuleId, OriginalPath, FinalPath, SelectedRelativeFolder, Status, - ErrorCode, ErrorMessage, CreatedAt, CompletedAt + BrowserState, RoutingState, ErrorCode, ErrorMessage, CreatedAt, CompletedAt, + LastBrowserEventAt, IsBrowserRecordStale FROM DownloadJobs """; + private static async Task MigrateToCurrentVersionAsync( + SqliteConnection connection, + CancellationToken cancellationToken) + { + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + var columns = new HashSet(StringComparer.OrdinalIgnoreCase); + await using (var columnCommand = connection.CreateCommand()) + { + columnCommand.Transaction = transaction; + columnCommand.CommandText = "PRAGMA table_info(DownloadJobs);"; + await using var reader = await columnCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + columns.Add(reader.GetString(1)); + } + } + + if (!columns.Contains("BrowserState")) + { + await ExecuteAsync( + connection, + "ALTER TABLE DownloadJobs ADD COLUMN BrowserState TEXT NOT NULL DEFAULT 'InProgress';", + cancellationToken, + transaction).ConfigureAwait(false); + } + + if (!columns.Contains("RoutingState")) + { + await ExecuteAsync( + connection, + "ALTER TABLE DownloadJobs ADD COLUMN RoutingState TEXT NOT NULL DEFAULT 'NotRequired';", + cancellationToken, + transaction).ConfigureAwait(false); + } + + await ExecuteAsync( + connection, + """ + UPDATE DownloadJobs + SET BrowserState = CASE + WHEN Status = 'Cancelled' THEN 'Cancelled' + WHEN Status = 'Interrupted' THEN 'Interrupted' + WHEN OriginalPath IS NOT NULL OR Status IN ('ReadyToMove', 'Moving', 'RetryPending', 'Completed', 'Failed') THEN 'Complete' + ELSE 'InProgress' + END, + RoutingState = CASE + WHEN Status = 'WaitingForSelection' THEN 'WaitingForSelection' + WHEN Status = 'WaitingForDownload' AND SelectedRelativeFolder IS NOT NULL THEN 'SelectionReady' + WHEN Status = 'ReadyToMove' AND SelectedRelativeFolder IS NOT NULL THEN 'SelectionReady' + WHEN Status = 'Moving' THEN 'Moving' + WHEN Status = 'RetryPending' THEN 'RetryPending' + WHEN Status = 'Completed' THEN 'Completed' + WHEN Status = 'Failed' OR Status = 'Interrupted' THEN 'Failed' + WHEN Status = 'Cancelled' THEN 'NotRequired' + ELSE 'NotRequired' + END + WHERE NOT EXISTS (SELECT 1 FROM MigrationHistory WHERE Version = 2); + + INSERT OR IGNORE INTO MigrationHistory(Version, AppliedAt) + VALUES (2, CURRENT_TIMESTAMP); + """, + cancellationToken, + transaction).ConfigureAwait(false); + + var ruleColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + await using (var ruleColumnCommand = connection.CreateCommand()) + { + ruleColumnCommand.Transaction = transaction; + ruleColumnCommand.CommandText = "PRAGMA table_info(Rules);"; + await using var reader = await ruleColumnCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + ruleColumns.Add(reader.GetString(1)); + } + } + + if (!ruleColumns.Contains("IsDeleted")) + { + await ExecuteAsync( + connection, + "ALTER TABLE Rules ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;", + cancellationToken, + transaction).ConfigureAwait(false); + } + + await ExecuteAsync( + connection, + """ + INSERT OR IGNORE INTO MigrationHistory(Version, AppliedAt) + VALUES (3, CURRENT_TIMESTAMP); + """, + cancellationToken, + transaction).ConfigureAwait(false); + + if (!columns.Contains("LastBrowserEventAt")) + { + await ExecuteAsync( + connection, + "ALTER TABLE DownloadJobs ADD COLUMN LastBrowserEventAt TEXT NULL;", + cancellationToken, + transaction).ConfigureAwait(false); + } + + if (!columns.Contains("IsBrowserRecordStale")) + { + await ExecuteAsync( + connection, + "ALTER TABLE DownloadJobs ADD COLUMN IsBrowserRecordStale INTEGER NOT NULL DEFAULT 0;", + cancellationToken, + transaction).ConfigureAwait(false); + } + + await ExecuteAsync( + connection, + """ + UPDATE DownloadJobs + SET LastBrowserEventAt = CreatedAt + WHERE LastBrowserEventAt IS NULL; + + INSERT OR IGNORE INTO MigrationHistory(Version, AppliedAt) + VALUES (4, CURRENT_TIMESTAMP); + """, + cancellationToken, + transaction).ConfigureAwait(false); + + if (CurrentSchemaVersion != 4) + { + throw new InvalidOperationException("Repository migration version is inconsistent."); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task ExecuteAsync( + SqliteConnection connection, + string sql, + CancellationToken cancellationToken, + SqliteTransaction? transaction = null) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + private SqliteConnection CreateConnection() => new(new SqliteConnectionStringBuilder { @@ -354,11 +597,14 @@ private static DownloadJob ReadJob(SqliteDataReader reader) GetNullableString(reader, 11), GetNullableString(reader, 12), GetNullableString(reader, 13), - Enum.Parse(reader.GetString(14)), - GetNullableString(reader, 15), - GetNullableString(reader, 16), - Parse(reader.GetString(17)), - reader.IsDBNull(18) ? null : Parse(reader.GetString(18))); + Enum.Parse(reader.GetString(15)), + Enum.Parse(reader.GetString(16)), + GetNullableString(reader, 17), + GetNullableString(reader, 18), + Parse(reader.GetString(19)), + reader.IsDBNull(20) ? null : Parse(reader.GetString(20)), + reader.IsDBNull(21) ? null : Parse(reader.GetString(21)), + reader.GetBoolean(22)); private static string? GetNullableString(SqliteDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); @@ -396,10 +642,16 @@ private static void AddJobParameters(SqliteCommand command, DownloadJob job) command.Parameters.AddWithValue("$finalPath", Db(job.FinalPath)); command.Parameters.AddWithValue("$selectedRelativeFolder", Db(job.SelectedRelativeFolder)); command.Parameters.AddWithValue("$status", job.Status.ToString()); + command.Parameters.AddWithValue("$browserState", job.BrowserState.ToString()); + command.Parameters.AddWithValue("$routingState", job.RoutingState.ToString()); command.Parameters.AddWithValue("$errorCode", Db(job.ErrorCode)); command.Parameters.AddWithValue("$errorMessage", Db(job.ErrorMessage)); command.Parameters.AddWithValue("$createdAt", Format(job.CreatedAt)); command.Parameters.AddWithValue("$completedAt", job.CompletedAt is null ? DBNull.Value : Format(job.CompletedAt.Value)); + command.Parameters.AddWithValue( + "$lastBrowserEventAt", + job.LastBrowserEventAt is null ? DBNull.Value : Format(job.LastBrowserEventAt.Value)); + command.Parameters.AddWithValue("$isBrowserRecordStale", job.IsBrowserRecordStale); } private static async Task AppendEventAsync( @@ -425,6 +677,14 @@ INSERT INTO DownloadEvents(JobId, EventType, Detail, CreatedAt) private static object Db(string? value) => value is null ? DBNull.Value : value; + private static void AddIdParameters(SqliteCommand command, IReadOnlyList ids) + { + for (var index = 0; index < ids.Count; index++) + { + command.Parameters.AddWithValue($"$id{index}", ids[index].ToString("D")); + } + } + private static string Format(DateTimeOffset value) => value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); private static DateTimeOffset Parse(string value) => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); diff --git a/src/DownloadRouter.NativeHost/Program.cs b/src/DownloadRouter.NativeHost/Program.cs index 2d471a5..816242f 100644 --- a/src/DownloadRouter.NativeHost/Program.cs +++ b/src/DownloadRouter.NativeHost/Program.cs @@ -113,8 +113,8 @@ static bool TryStartAgent() Process.Start(new ProcessStartInfo { FileName = Path.GetFullPath(candidate), - UseShellExecute = false, - CreateNoWindow = true, + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden, WorkingDirectory = Path.GetDirectoryName(Path.GetFullPath(candidate))!, }); return true; diff --git a/tests/DownloadRouter.Core.Tests/AutoStartRegistrationTests.cs b/tests/DownloadRouter.Core.Tests/AutoStartRegistrationTests.cs new file mode 100644 index 0000000..d242c01 --- /dev/null +++ b/tests/DownloadRouter.Core.Tests/AutoStartRegistrationTests.cs @@ -0,0 +1,37 @@ +using DownloadRouter.Core.Startup; +using Microsoft.Win32; + +namespace DownloadRouter.Core.Tests; + +public sealed class AutoStartRegistrationTests +{ + [Fact] + public void RegistrationIsIdempotentAndUsesBackgroundArgument() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var keyPath = $@"Software\eslee\DownloadRouter.Tests\{Guid.NewGuid():N}"; + const string valueName = "AutoStartTest"; + var registration = new AutoStartRegistration(keyPath, valueName); + var executable = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Download Router App.exe")); + try + { + registration.SetEnabled(true, executable); + registration.SetEnabled(true, executable); + + Assert.True(registration.IsEnabled(executable)); + using var key = Registry.CurrentUser.OpenSubKey(keyPath); + Assert.Equal($"\"{executable}\" --background", key!.GetValue(valueName)); + + registration.SetEnabled(false, executable); + Assert.False(registration.IsEnabled(executable)); + } + finally + { + Registry.CurrentUser.DeleteSubKeyTree(keyPath, throwOnMissingSubKey: false); + } + } +} diff --git a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs index 2b85f9e..4f0a198 100644 --- a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs +++ b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs @@ -2,6 +2,12 @@ using DownloadRouter.Core.Ipc; using DownloadRouter.Core.Jobs; using DownloadRouter.Core.Models; +using DownloadRouter.Core.Paths; +using DownloadRouter.Core.Settings; +using System.Reflection; +using System.Reflection.Emit; +using System.Diagnostics; +using System.Xml.Linq; namespace DownloadRouter.Core.Tests; @@ -38,16 +44,478 @@ await Assert.ThrowsAsync(() => public void StateMachineRejectsMovingBeforeDownloadCompletion() { var stateMachine = new DownloadJobStateMachine(); - Assert.False(stateMachine.CanTransition(DownloadJobStatus.WaitingForDownload, DownloadJobStatus.Moving)); + Assert.False(stateMachine.CanTransition(RoutingState.WaitingForSelection, RoutingState.Moving)); Assert.Throws(() => - stateMachine.EnsureCanTransition(DownloadJobStatus.WaitingForDownload, DownloadJobStatus.Moving)); + stateMachine.EnsureCanTransition(RoutingState.WaitingForSelection, RoutingState.Moving)); } [Fact] public void CompletedAndCancelledJobsAreTerminal() { var stateMachine = new DownloadJobStateMachine(); - Assert.False(stateMachine.CanTransition(DownloadJobStatus.Completed, DownloadJobStatus.RetryPending)); - Assert.False(stateMachine.CanTransition(DownloadJobStatus.Cancelled, DownloadJobStatus.WaitingForDownload)); + Assert.False(stateMachine.CanTransition(RoutingState.Completed, RoutingState.RetryPending)); + Assert.False(stateMachine.CanTransition(BrowserTransferState.Cancelled, BrowserTransferState.InProgress)); + } + + [Fact] + public void CancelledJobsAreExcludedFromPendingAndDashboardCounts() + { + var cancelled = CreateJob(BrowserTransferState.Cancelled, RoutingState.NotRequired); + var pending = CreateJob(BrowserTransferState.InProgress, RoutingState.WaitingForSelection); + + var active = DownloadJobQueries.ActiveSelections([cancelled, pending]); + var counts = DownloadJobQueries.CountDashboard([cancelled, pending]); + + Assert.Equal(pending.Id, Assert.Single(active).Id); + Assert.Equal(1, counts.WaitingForSelection); + Assert.Equal(1, counts.CancelledOrInterrupted); + } + + [Fact] + public void SelectionPromptQueueIsFifoAndRejectsDuplicateJobs() + { + var queue = new SelectionPromptQueue(); + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + + Assert.True(queue.Enqueue(first)); + Assert.False(queue.Enqueue(first)); + Assert.True(queue.Enqueue(second)); + Assert.True(queue.TryDequeue(out var dequeuedFirst)); + Assert.True(queue.TryDequeue(out var dequeuedSecond)); + Assert.Equal(first, dequeuedFirst); + Assert.Equal(second, dequeuedSecond); + Assert.False(queue.TryDequeue(out _)); + } + + [Fact] + public void SelectionPromptQueueCanRefreshTheCurrentJobWithoutBreakingFifo() + { + var queue = new SelectionPromptQueue(); + var current = Guid.NewGuid(); + var next = Guid.NewGuid(); + queue.Enqueue(next); + queue.EnqueueFirst(current); + + Assert.True(queue.TryDequeue(out var refreshed)); + Assert.True(queue.TryDequeue(out var queuedNext)); + Assert.Equal(current, refreshed); + Assert.Equal(next, queuedNext); + } + + [Fact] + public void SelectionPromptQueueCanBeHiddenAsOneSessionBatch() + { + var queue = new SelectionPromptQueue(); + var ids = Enumerable.Range(0, 23).Select(_ => Guid.NewGuid()).ToArray(); + foreach (var id in ids) + { + queue.Enqueue(id); + } + + Assert.Equal(ids, queue.Drain()); + Assert.Equal(0, queue.Count); + Assert.False(queue.TryDequeue(out _)); + } + + [Fact] + public void CancelledSelectionIsRemovedWithoutBreakingFifoOrCreatingDuplicates() + { + var queue = new SelectionPromptQueue(); + var cancelled = Guid.NewGuid(); + var next = Guid.NewGuid(); + Assert.True(queue.Enqueue(cancelled)); + Assert.True(queue.Enqueue(next)); + Assert.False(queue.Enqueue(next)); + + Assert.True(queue.Remove(cancelled)); + Assert.False(queue.Remove(cancelled)); + Assert.True(queue.TryDequeue(out var remaining)); + Assert.Equal(next, remaining); + Assert.False(queue.TryDequeue(out _)); + } + + [Fact] + public void AutomaticSelectionPolicyKeepsOldAndStaleJobsInPendingWithoutPopup() + { + var now = DateTimeOffset.UtcNow; + var recent = CreateJob(BrowserTransferState.InProgress, RoutingState.WaitingForSelection) with + { + CreatedAt = now.AddMinutes(-5), + LastBrowserEventAt = now.AddMinutes(-1), + }; + var old = recent with + { + Id = Guid.NewGuid(), + CreatedAt = now.AddHours(-1), + LastBrowserEventAt = now.AddMinutes(-31), + }; + var stale = recent with { Id = Guid.NewGuid(), IsBrowserRecordStale = true }; + var cancelled = recent with { Id = Guid.NewGuid(), BrowserState = BrowserTransferState.Cancelled }; + + var pending = DownloadJobQueries.ActiveSelections([recent, old, stale, cancelled]); + var automatic = DownloadJobQueries.AutomaticSelections([old, stale, recent, cancelled], now); + + Assert.Equal(3, pending.Count); + Assert.Equal(recent.Id, Assert.Single(automatic).Id); + Assert.True(SelectionPromptPolicy.IsPreviousSessionPending(old, now)); + Assert.True(SelectionPromptPolicy.IsPreviousSessionPending(stale, now)); + } + + [Theory] + [InlineData(null, AppThemePreference.System, "Default")] + [InlineData("invalid-old-value", AppThemePreference.System, "Default")] + [InlineData("Light", AppThemePreference.Light, "Light")] + [InlineData("dark", AppThemePreference.Dark, "Dark")] + public void ThemeSettingsMapSafely(string? value, AppThemePreference expected, string elementTheme) + { + var preference = AppThemePolicy.Parse(value); + Assert.Equal(expected, preference); + Assert.Equal(elementTheme, AppThemePolicy.ToElementThemeName(preference)); + } + + [Fact] + public void PreferencesPersistAndInvalidThemeFallsBackWithoutLosingOtherSettings() + { + var directory = Path.Combine(Path.GetTempPath(), "download-router-preferences-tests", Guid.NewGuid().ToString("N")); + var path = Path.Combine(directory, "config.local.json"); + try + { + Directory.CreateDirectory(directory); + File.WriteAllText(path, "{\"dataDirectory\":\"preserve-me\",\"fileStability\":{\"maxWaitSeconds\":30}}"); + var store = new AppPreferencesStore(path); + store.Save(new AppPreferences(WindowCloseBehavior.ExitApplication, "Dark")); + var restored = store.Load(); + Assert.Equal(WindowCloseBehavior.ExitApplication, restored.CloseBehavior); + Assert.Equal(AppThemePreference.Dark, restored.ThemePreference); + var savedJson = File.ReadAllText(path); + Assert.Contains("preserve-me", savedJson, StringComparison.Ordinal); + Assert.Contains("maxWaitSeconds", savedJson, StringComparison.Ordinal); + + File.WriteAllText(path, "{\"closeBehavior\":1,\"theme\":\"retired-theme\"}"); + restored = store.Load(); + Assert.Equal(WindowCloseBehavior.ExitApplication, restored.CloseBehavior); + Assert.Equal(AppThemePreference.System, restored.ThemePreference); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } + + [Fact] + public void ProductVersionUsesAssemblyInformationalVersionAndSurvivesMissingCommit() + { + var current = ProductVersionInfo.Read(typeof(ProductVersionInfo).Assembly, AppContext.BaseDirectory); + var assemblyName = new AssemblyName("NoInformationalVersion") { Version = new Version(7, 2, 1, 0) }; + var dynamicAssembly = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run); + var fallback = ProductVersionInfo.Read(dynamicAssembly, AppContext.BaseDirectory); + + Assert.False(string.IsNullOrWhiteSpace(current.InformationalVersion)); + Assert.StartsWith(current.SemanticVersion, current.InformationalVersion, StringComparison.Ordinal); + Assert.Equal("7.2.1", fallback.SemanticVersion); + Assert.Null(fallback.Commit); + } + + [Fact] + public void AppAndInstallerDeriveTheirVersionFromDirectoryBuildProps() + { + var repositoryRoot = FindRepositoryRoot(); + var document = XDocument.Load(Path.Combine(repositoryRoot, "Directory.Build.props")); + var version = document.Descendants("VersionPrefix").Single().Value; + var appExecutable = Directory + .EnumerateFiles( + Path.Combine(repositoryRoot, "src", "DownloadRouter.App", "bin"), + "DownloadRouter.App.exe", + SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .First(); + var appProductVersion = FileVersionInfo.GetVersionInfo(appExecutable).ProductVersion; + var installerSource = File.ReadAllText(Path.Combine(repositoryRoot, "installer", "DownloadRouter.iss")); + var installerBuild = File.ReadAllText(Path.Combine(repositoryRoot, "scripts", "build-installer.ps1")); + + Assert.Matches("^\\d+\\.\\d+\\.\\d+$", version); + Assert.True( + string.Equals(appProductVersion, version, StringComparison.Ordinal) + || appProductVersion?.StartsWith(version + "+", StringComparison.Ordinal) == true); + Assert.Contains("#ifndef AppVersion", installerSource, StringComparison.Ordinal); + Assert.DoesNotContain("#define AppVersion \"", installerSource, StringComparison.Ordinal); + Assert.Contains("Directory.Build.props", installerBuild, StringComparison.Ordinal); + Assert.Contains("/DAppVersion=", installerBuild, StringComparison.Ordinal); + } + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null && !File.Exists(Path.Combine(current.FullName, "Directory.Build.props"))) + { + current = current.Parent; + } + + return current?.FullName + ?? throw new DirectoryNotFoundException("Repository root was not found from the test output path."); + } + + [Fact] + public void TemporaryDownloadNamesUseThePendingLabelUntilTrustedMetadataArrives() + { + Assert.Null(DownloadPresentation.TrustedFileName("download")); + Assert.Null(DownloadPresentation.TrustedFileName("미확인 197533.crdownload")); + Assert.Null(DownloadPresentation.TrustedFileName("C:\\Downloads\\sample.partial")); + Assert.Equal("최종 이름.zip", DownloadPresentation.TrustedFileName("C:\\Downloads\\최종 이름.zip")); + + var pending = CreateJob(BrowserTransferState.InProgress, RoutingState.WaitingForSelection) with + { + CurrentFileName = "download", + }; + Assert.Equal(DownloadPresentation.PendingFileName, DownloadPresentation.DisplayFileName(pending)); + } + + [Theory] + [InlineData(RoutingState.WaitingForSelection)] + [InlineData(RoutingState.SelectionReady)] + [InlineData(RoutingState.Skipped)] + [InlineData(RoutingState.NotRequired)] + [InlineData(RoutingState.Failed)] + public void CancellationPresentationDependsOnlyOnBrowserState(RoutingState routingState) + { + var job = CreateJob(BrowserTransferState.Cancelled, routingState); + + Assert.True(DownloadPresentation.IsCancelled(job)); + Assert.False(DownloadPresentation.CanChangeRoute(job)); + Assert.Empty(DownloadJobQueries.ActiveSelections([job])); + } + + [Fact] + public void HiddenMainWindowShowsAndActivatesOnlyTheIndependentSelectionWindow() + { + var operations = new FakeSelectionWindowActivationOperations( + mainVisible: false, + mainIconic: false, + selectionVisible: false, + selectionIconic: false) + { + DirectForegroundSucceeds = true, + }; + + var result = new SelectionWindowActivationCoordinator(operations) + .Activate(operations.MainWindowHandle, operations.SelectionWindowHandle); + + Assert.True(result.SelectionAfter.IsVisible); + Assert.False(result.SelectionAfter.IsIconic); + Assert.True(result.SelectionAfter.IsForeground); + Assert.False(result.MainAfter.IsVisible); + Assert.False(result.MainAfter.IsIconic); + Assert.All(operations.MutatedWindowHandles, handle => + Assert.Equal(operations.SelectionWindowHandle, handle)); + } + + [Fact] + public void MinimizedMainWindowRemainsMinimizedWhenAttachedInputActivatesSelection() + { + var operations = new FakeSelectionWindowActivationOperations( + mainVisible: true, + mainIconic: true, + selectionVisible: false, + selectionIconic: false) + { + AttachedForegroundSucceeds = true, + }; + + var result = new SelectionWindowActivationCoordinator(operations) + .Activate(operations.MainWindowHandle, operations.SelectionWindowHandle); + + Assert.False(result.DirectForegroundSucceeded); + Assert.True(result.AttachedForegroundSucceeded); + Assert.True(result.SelectionAfter.IsForeground); + Assert.True(result.MainAfter.IsVisible); + Assert.True(result.MainAfter.IsIconic); + Assert.All(operations.MutatedWindowHandles, handle => + Assert.Equal(operations.SelectionWindowHandle, handle)); + } + + [Fact] + public void IconicSelectionWindowIsShownNormalBeforeForegroundActivation() + { + var operations = new FakeSelectionWindowActivationOperations( + mainVisible: true, + mainIconic: true, + selectionVisible: true, + selectionIconic: true) + { + DirectForegroundSucceeds = true, + }; + + var result = new SelectionWindowActivationCoordinator(operations) + .Activate(operations.MainWindowHandle, operations.SelectionWindowHandle); + + Assert.True(result.SelectionBefore.IsIconic); + Assert.True(result.ShowNormalSucceeded); + Assert.True(result.SelectionAfterShow.IsVisible); + Assert.False(result.SelectionAfterShow.IsIconic); + } + + [Fact] + public void TaskbarFlashIsUsedOnlyAfterAllForegroundAttemptsFail() + { + var operations = new FakeSelectionWindowActivationOperations( + mainVisible: true, + mainIconic: true, + selectionVisible: false, + selectionIconic: false); + + var result = new SelectionWindowActivationCoordinator(operations) + .Activate(operations.MainWindowHandle, operations.SelectionWindowHandle); + + Assert.False(result.DirectForegroundSucceeded); + Assert.False(result.AttachedForegroundSucceeded); + Assert.False(result.RaisedForegroundSucceeded); + Assert.True(result.FlashFallbackUsed); + Assert.True(operations.FlashUsed); + } + + [Fact] + public async Task FolderTreeLoadsOnlyTheExpandedLevelAndRejectsRootEscape() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "download-router-tree-tests", Guid.NewGuid().ToString("N"))).FullName; + try + { + var first = Directory.CreateDirectory(Path.Combine(root, "kr")).FullName; + Directory.CreateDirectory(Path.Combine(first, "모야지")); + Directory.CreateDirectory(Path.Combine(root, new string('긴', 110))); + var provider = new SafeFolderTreeProvider(new DownloadRouter.Core.Paths.PathBoundaryValidator()); + + var rootChildren = await provider.GetChildrenAsync(root, root, CancellationToken.None); + Assert.Contains(rootChildren.Entries, entry => entry.Name == "kr"); + Assert.DoesNotContain(rootChildren.Entries, entry => entry.Name == "모야지"); + Assert.Contains(rootChildren.Entries, entry => entry.Name.Length >= 100); + + var nested = await provider.GetChildrenAsync(root, first, CancellationToken.None); + Assert.Equal("모야지", Assert.Single(nested.Entries).Name); + await Assert.ThrowsAsync(() => + provider.GetChildrenAsync(root, Path.GetDirectoryName(root)!, CancellationToken.None)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static DownloadJob CreateJob(BrowserTransferState browserState, RoutingState routingState) + => new( + Guid.NewGuid(), BrowserKind.Whale, Guid.NewGuid().ToString("N"), "sample.bin", "sample.bin", + null, null, null, null, "https://example.com", Guid.NewGuid(), null, null, null, + browserState, routingState, null, null, DateTimeOffset.UtcNow, null); + + private sealed class FakeSelectionWindowActivationOperations : + ISelectionWindowActivationOperations + { + public nint MainWindowHandle { get; } = new(101); + public nint SelectionWindowHandle { get; } = new(202); + public bool DirectForegroundSucceeds { get; init; } + public bool AttachedForegroundSucceeds { get; init; } + public bool RaisedForegroundSucceeds { get; init; } + public bool FlashUsed { get; private set; } + public List MutatedWindowHandles { get; } = []; + + private bool mainVisible; + private bool mainIconic; + private bool selectionVisible; + private bool selectionIconic; + private nint foregroundHandle = new(303); + + public FakeSelectionWindowActivationOperations( + bool mainVisible, + bool mainIconic, + bool selectionVisible, + bool selectionIconic) + { + this.mainVisible = mainVisible; + this.mainIconic = mainIconic; + this.selectionVisible = selectionVisible; + this.selectionIconic = selectionIconic; + } + + public NativeWindowState Capture(nint windowHandle) + => windowHandle == MainWindowHandle + ? new NativeWindowState( + MainWindowHandle, + mainVisible, + mainIconic, + 0, + foregroundHandle) + : new NativeWindowState( + SelectionWindowHandle, + selectionVisible, + selectionIconic, + 0, + foregroundHandle); + + public bool ShowSelectionNormal(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + selectionVisible = true; + selectionIconic = false; + return true; + } + + public void ActivateSelection() + { + MutatedWindowHandles.Add(SelectionWindowHandle); + } + + public bool BringSelectionToTop(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + return true; + } + + public bool TrySetSelectionForeground(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + if (DirectForegroundSucceeds) + { + foregroundHandle = selectionWindowHandle; + } + + return DirectForegroundSucceeds; + } + + public bool TryAttachInputAndActivate(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + if (AttachedForegroundSucceeds) + { + foregroundHandle = selectionWindowHandle; + } + + return AttachedForegroundSucceeds; + } + + public bool RaiseSelectionAboveOtherWindows(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + if (RaisedForegroundSucceeds) + { + foregroundHandle = selectionWindowHandle; + } + + return RaisedForegroundSucceeds; + } + + public void FlashSelection(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + FlashUsed = true; + } + + private void TrackSelectionMutation(nint windowHandle) + { + Assert.Equal(SelectionWindowHandle, windowHandle); + MutatedWindowHandles.Add(windowHandle); + } } } diff --git a/tests/DownloadRouter.Infrastructure.Tests/JsonLineFileLoggerTests.cs b/tests/DownloadRouter.Infrastructure.Tests/JsonLineFileLoggerTests.cs new file mode 100644 index 0000000..395eca0 --- /dev/null +++ b/tests/DownloadRouter.Infrastructure.Tests/JsonLineFileLoggerTests.cs @@ -0,0 +1,40 @@ +using DownloadRouter.Infrastructure.Logging; +using Microsoft.Extensions.Logging; + +namespace DownloadRouter.Infrastructure.Tests; + +public sealed class JsonLineFileLoggerTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), "download-router-logger-tests", Guid.NewGuid().ToString("N")); + + [Fact] + public void RedactsUrlsPathsTokensAndExceptionMessages() + { + using var provider = new JsonLineFileLoggerProvider(root); + var logger = provider.CreateLogger("privacy-test"); + logger.LogError( + new IOException("private exception C:\\Users\\person\\Downloads\\secret.txt"), + "Native failure at {Url} for {Path} with token={Token}", + "https://example.test/file?token=secret", + "C:\\Users\\person\\Downloads\\secret.txt", + "secret"); + + var line = File.ReadAllText(Path.Combine(root, "download-router.jsonl")); + + Assert.DoesNotContain("example.test", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("C:\\Users", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("private exception", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("token=secret", line, StringComparison.OrdinalIgnoreCase); + Assert.Contains("[URL_REDACTED]", line, StringComparison.Ordinal); + Assert.Contains("[PATH_REDACTED]", line, StringComparison.Ordinal); + Assert.Contains("IOException", line, StringComparison.Ordinal); + } + + public void Dispose() + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs b/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs index 2e8ef8d..4e099f9 100644 --- a/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs +++ b/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs @@ -35,6 +35,95 @@ public async Task MigrationCreatesDatabaseAndRuleRoundTrips() Assert.True(File.Exists(paths.DatabasePath)); } + [Fact] + public async Task VersionOneJobsMigrateToSeparateBrowserAndRoutingStates() + { + var paths = AppPaths.CreateDefault(); + paths.EnsureCreated(); + var ruleId = Guid.NewGuid(); + var jobId = Guid.NewGuid(); + await using (var connection = new SqliteConnection($"Data Source={paths.DatabasePath}")) + { + await connection.OpenAsync(CancellationToken.None); + await using var command = connection.CreateCommand(); + command.CommandText = """ + CREATE TABLE MigrationHistory (Version INTEGER PRIMARY KEY, AppliedAt TEXT NOT NULL); + INSERT INTO MigrationHistory(Version, AppliedAt) VALUES (1, '2026-01-01T00:00:00.0000000+00:00'); + CREATE TABLE Rules ( + Id TEXT PRIMARY KEY, Name TEXT NOT NULL, IsEnabled INTEGER NOT NULL, + MatchType TEXT NOT NULL, MatchValue TEXT NOT NULL, MatchTarget TEXT NOT NULL, + StorageRoot TEXT NOT NULL, StorageMode TEXT NOT NULL, Priority INTEGER NOT NULL, + ListOrder INTEGER NOT NULL, CreatedAt TEXT NOT NULL, UpdatedAt TEXT NOT NULL); + CREATE TABLE DownloadJobs ( + Id TEXT PRIMARY KEY, Browser TEXT NOT NULL, BrowserDownloadId TEXT NOT NULL, + OriginalFileName TEXT NOT NULL, CurrentFileName TEXT NOT NULL, + InitiatingPageUrl TEXT NULL, InitialUrl TEXT NULL, FinalUrl TEXT NULL, + ReferrerUrl TEXT NULL, SanitizedSource TEXT NULL, RuleId TEXT NOT NULL, + OriginalPath TEXT NULL, FinalPath TEXT NULL, SelectedRelativeFolder TEXT NULL, + Status TEXT NOT NULL, ErrorCode TEXT NULL, ErrorMessage TEXT NULL, + CreatedAt TEXT NOT NULL, CompletedAt TEXT NULL, + FOREIGN KEY(RuleId) REFERENCES Rules(Id), UNIQUE(Browser, BrowserDownloadId)); + INSERT INTO Rules VALUES( + $ruleId, 'legacy rule', 1, 'DomainAndSubdomains', 'example.com', 'InitiatingPage', + $root, 'SelectSubfolder', 0, 0, $created, $created); + INSERT INTO DownloadJobs VALUES( + $jobId, 'Whale', 'legacy-1', 'legacy.txt', 'legacy.txt', + NULL, NULL, NULL, NULL, 'https://example.com', $ruleId, + NULL, NULL, NULL, 'WaitingForSelection', NULL, NULL, $created, NULL); + """; + command.Parameters.AddWithValue("$ruleId", ruleId.ToString("D")); + command.Parameters.AddWithValue("$jobId", jobId.ToString("D")); + command.Parameters.AddWithValue("$root", root); + command.Parameters.AddWithValue("$created", DateTimeOffset.UtcNow.ToString("O")); + await command.ExecuteNonQueryAsync(CancellationToken.None); + } + + var repository = new DownloadRouterRepository(paths); + await repository.InitializeAsync(CancellationToken.None); + + var migrated = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.NotNull(migrated); + Assert.Equal(BrowserTransferState.InProgress, migrated.BrowserState); + Assert.Equal(RoutingState.WaitingForSelection, migrated.RoutingState); + + await using var verification = new SqliteConnection($"Data Source={paths.DatabasePath}"); + await verification.OpenAsync(CancellationToken.None); + await using var versionCommand = verification.CreateCommand(); + versionCommand.CommandText = "SELECT COUNT(*) FROM MigrationHistory WHERE Version = 2;"; + Assert.Equal(1L, (long)(await versionCommand.ExecuteScalarAsync(CancellationToken.None))!); + versionCommand.CommandText = "SELECT COUNT(*) FROM MigrationHistory WHERE Version = 3;"; + Assert.Equal(1L, (long)(await versionCommand.ExecuteScalarAsync(CancellationToken.None))!); + versionCommand.CommandText = "SELECT COUNT(*) FROM MigrationHistory WHERE Version = 4;"; + Assert.Equal(1L, (long)(await versionCommand.ExecuteScalarAsync(CancellationToken.None))!); + Assert.NotNull(migrated.LastBrowserEventAt); + Assert.False(migrated.IsBrowserRecordStale); + } + + [Fact] + public async Task SoftDeletingRulePreservesItsJobsAndAllowsNoFurtherMatching() + { + var paths = AppPaths.CreateDefault(); + var repository = new DownloadRouterRepository(paths); + await repository.InitializeAsync(CancellationToken.None); + var now = DateTimeOffset.UtcNow; + var rule = new DownloadRule( + Guid.NewGuid(), "preserved history rule", true, RuleMatchType.ExactHost, "example.com", + RuleMatchTarget.FileUrl, root, StorageMode.SelectSubfolder, 0, 0, now, now); + await repository.UpsertRuleAsync(rule, CancellationToken.None); + var job = new DownloadJob( + Guid.NewGuid(), BrowserKind.Whale, "soft-delete-1", "sample.bin", "sample.bin", + null, null, null, null, "https://example.com", rule.Id, null, null, null, + BrowserTransferState.InProgress, RoutingState.WaitingForSelection, + null, null, now, null); + await repository.CreateJobAsync(job, CancellationToken.None); + + Assert.True(await repository.DeleteRuleAsync(rule.Id, CancellationToken.None)); + + Assert.Empty(await repository.GetRulesAsync(CancellationToken.None)); + Assert.NotNull(await repository.GetRuleAsync(rule.Id, CancellationToken.None)); + Assert.NotNull(await repository.GetJobAsync(job.Id, CancellationToken.None)); + } + public void Dispose() { Environment.SetEnvironmentVariable("DOWNLOAD_ROUTER_DATA_DIR", previousOverride); diff --git a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs index e58efc3..d6adc11 100644 --- a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs +++ b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs @@ -55,9 +55,544 @@ public async Task UnmatchedDownloadFailsOpenAndCreatesNoJob() var response = await handler.HandleAsync(request, CancellationToken.None); Assert.True(response.Success); + Assert.False(response.Data!.Value.GetProperty("tracked").GetBoolean()); + Assert.True(response.Data.Value.GetProperty("failOpen").GetBoolean()); Assert.Empty(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None)); } + [Fact] + public async Task MatchedDownloadCreatesHistoryAndRoutesCompletedFile() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + var source = Path.Combine(incoming, "example.txt"); + await File.WriteAllTextAsync(source, "public test fixture", CancellationToken.None); + var now = DateTimeOffset.UtcNow; + var rule = new DownloadRule( + Guid.NewGuid(), + "example.com smoke rule", + true, + RuleMatchType.DomainAndSubdomains, + "example.com", + RuleMatchTarget.InitiatingPage, + destination, + StorageMode.Automatic, + 0, + 0, + now, + now); + await repository.UpsertRuleAsync(rule, CancellationToken.None); + + var started = new AgentCommand( + ProtocolConstants.CurrentVersion, + Guid.NewGuid(), + "download.started", + JsonSerializer.SerializeToElement( + new DownloadStartedPayload( + "Whale", + "example-download-1", + "example.txt", + null, + "https://example.com/downloads", + "https://example.com/example.txt", + null, + null), + ProtocolJson.Options)); + var startedResponse = await handler.HandleAsync(started, CancellationToken.None); + + Assert.True(startedResponse.Success, startedResponse.Message); + Assert.True(startedResponse.Data!.Value.GetProperty("tracked").GetBoolean()); + + var changed = new AgentCommand( + ProtocolConstants.CurrentVersion, + Guid.NewGuid(), + "download.changed", + JsonSerializer.SerializeToElement( + new DownloadChangedPayload("Whale", "example-download-1", "complete", source, null), + ProtocolJson.Options)); + var changedResponse = await handler.HandleAsync(changed, CancellationToken.None); + + Assert.True(changedResponse.Success, changedResponse.Message); + Assert.Equal("Completed", changedResponse.Data!.Value.GetProperty("status").GetString()); + var job = Assert.Single(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None)); + Assert.Equal(DownloadJobStatus.Completed, job.Status); + Assert.False(File.Exists(source)); + Assert.NotNull(job.FinalPath); + Assert.True(File.Exists(job.FinalPath)); + Assert.Equal("public test fixture", await File.ReadAllTextAsync(job.FinalPath!, CancellationToken.None)); + } + + [Fact] + public async Task SameRuleDownloadsCanSelectDifferentFoldersBeforeAndAfterCompletion() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + Directory.CreateDirectory(Path.Combine(destination, "A")); + Directory.CreateDirectory(Path.Combine(destination, "B")); + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + + var firstId = await StartAsync(handler, "select-1", "first.txt"); + var secondId = await StartAsync(handler, "select-2", "second.txt"); + var firstSource = Path.Combine(incoming, "first.txt"); + var secondSource = Path.Combine(incoming, "second.txt"); + await File.WriteAllTextAsync(firstSource, "first", CancellationToken.None); + await File.WriteAllTextAsync(secondSource, "second", CancellationToken.None); + + var selectFirst = await SendAsync( + handler, + "selection.complete", + new SelectionCompletedPayload([firstId], "A")); + Assert.True(selectFirst.Success, selectFirst.Message); + var firstBeforeCompletion = await repository.GetJobAsync(firstId, CancellationToken.None); + Assert.Equal(BrowserTransferState.InProgress, firstBeforeCompletion!.BrowserState); + Assert.Equal(RoutingState.SelectionReady, firstBeforeCompletion.RoutingState); + + var completeFirst = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "select-1", "complete", firstSource, null)); + Assert.True(completeFirst.Success, completeFirst.Message); + Assert.True(File.Exists(Path.Combine(destination, "A", "first.txt"))); + + var completeSecond = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "select-2", "complete", secondSource, null)); + Assert.True(completeSecond.Success, completeSecond.Message); + Assert.True(File.Exists(secondSource)); + var secondBeforeSelection = await repository.GetJobAsync(secondId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Complete, secondBeforeSelection!.BrowserState); + Assert.Equal(RoutingState.WaitingForSelection, secondBeforeSelection.RoutingState); + + var selectSecond = await SendAsync( + handler, + "selection.complete", + new SelectionCompletedPayload([secondId], "B")); + Assert.True(selectSecond.Success, selectSecond.Message); + Assert.True(File.Exists(Path.Combine(destination, "B", "second.txt"))); + Assert.False(File.Exists(secondSource)); + } + + [Fact] + public async Task BatchSelectionChangesOnlyExplicitlySelectedJobs() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var firstId = await StartAsync(handler, "batch-1", "first.txt"); + var secondId = await StartAsync(handler, "batch-2", "second.txt"); + var thirdId = await StartAsync(handler, "batch-3", "third.txt"); + + var response = await SendAsync( + handler, + "selection.complete", + new SelectionCompletedPayload([firstId, thirdId], string.Empty)); + + Assert.True(response.Success, response.Message); + Assert.Equal(RoutingState.SelectionReady, (await repository.GetJobAsync(firstId, CancellationToken.None))!.RoutingState); + Assert.Equal(RoutingState.WaitingForSelection, (await repository.GetJobAsync(secondId, CancellationToken.None))!.RoutingState); + Assert.Equal(RoutingState.SelectionReady, (await repository.GetJobAsync(thirdId, CancellationToken.None))!.RoutingState); + } + + [Fact] + public async Task UserCancellationIsTerminalPendingIsExcludedAndNoFileIsMoved() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "cancel-1", "cancelled.txt"); + var source = Path.Combine(incoming, "cancelled.txt"); + await File.WriteAllTextAsync(source, "partial content", CancellationToken.None); + + var response = await SendAsync( + handler, + "download.cancelled", + new DownloadChangedPayload("Whale", "cancel-1", "cancelled", source, "USER_CANCELED")); + + Assert.True(response.Success, response.Message); + var job = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Cancelled, job!.BrowserState); + Assert.Equal(RoutingState.NotRequired, job.RoutingState); + Assert.Equal("download.cancelled", job.ErrorCode); + Assert.Equal("cancelled.txt", job.CurrentFileName); + Assert.Empty(DownloadJobQueries.ActiveSelections(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None))); + Assert.Equal(0, DownloadJobQueries.CountDashboard([job]).WaitingForSelection); + Assert.True(File.Exists(source)); + Assert.Empty(Directory.EnumerateFiles(destination)); + } + + [Fact] + public async Task BrowserInterruptionStoresOnlyARecognizedErrorCode() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "interrupt-1", "interrupted.txt"); + + var response = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload( + "Whale", + "interrupt-1", + "interrupted", + null, + "https://example.com/file?token=secret")); + + Assert.True(response.Success, response.Message); + var job = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Interrupted, job!.BrowserState); + Assert.Equal(RoutingState.Failed, job.RoutingState); + Assert.Equal("download.interrupted.unknown", job.ErrorCode); + Assert.DoesNotContain("secret", job.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("no-selection")] + [InlineData("selection-ready")] + [InlineData("later")] + [InlineData("skipped")] + public async Task ExplicitUserCancellationIsIdempotentForEverySelectionFlow(string scenario) + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + Directory.CreateDirectory(Path.Combine(destination, "selected")); + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var downloadId = "cancel-" + scenario; + var jobId = await StartAsync(handler, downloadId, scenario + ".bin"); + if (scenario == "selection-ready") + { + Assert.True((await SendAsync( + handler, + "selection.complete", + new SelectionCompletedPayload([jobId], "selected"))).Success); + } + else if (scenario == "skipped") + { + Assert.True((await SendAsync( + handler, + "selection.skip", + new SelectionSkippedPayload(jobId))).Success); + } + + var source = Path.Combine(incoming, scenario + ".bin"); + await File.WriteAllTextAsync(source, "partial", CancellationToken.None); + var payload = new DownloadChangedPayload("Whale", downloadId, "cancelled", source, "USER_CANCELED"); + var first = await SendAsync(handler, "download.cancelled", payload); + var duplicate = await SendAsync(handler, "download.cancelled", payload); + + Assert.True(first.Success, first.Message); + Assert.True(duplicate.Success, duplicate.Message); + var job = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Cancelled, job!.BrowserState); + Assert.Equal( + scenario == "skipped" ? RoutingState.Skipped : RoutingState.NotRequired, + job.RoutingState); + Assert.Equal("download.cancelled", job.ErrorCode); + Assert.Empty(DownloadJobQueries.ActiveSelections([job])); + Assert.True(File.Exists(source)); + Assert.Empty(Directory.EnumerateFiles(destination, "*", SearchOption.AllDirectories)); + } + + [Fact] + public async Task MissingBrowserRecordBecomesStaleWithoutBeingGuessedCancelled() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "stale-1", "stale.bin"); + + Assert.True((await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "stale-1", "stale", null, null))).Success); + var stale = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.InProgress, stale!.BrowserState); + Assert.Equal(RoutingState.WaitingForSelection, stale.RoutingState); + Assert.True(stale.IsBrowserRecordStale); + Assert.Single(DownloadJobQueries.ActiveSelections([stale])); + Assert.Empty(DownloadJobQueries.AutomaticSelections([stale], DateTimeOffset.UtcNow)); + + Assert.True((await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "stale-1", "in_progress", null, null))).Success); + var restored = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.False(restored!.IsBrowserRecordStale); + Assert.Single(DownloadJobQueries.AutomaticSelections([restored], DateTimeOffset.UtcNow)); + } + + [Fact] + public async Task StartupReconciliationDoesNotMakeAnOldPendingJobAutoPromptEligibleAgain() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "old-reconciled", "old.bin"); + var oldTime = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromMinutes(31)); + var original = await repository.GetJobAsync(jobId, CancellationToken.None); + await repository.UpdateJobAsync( + original! with { CreatedAt = oldTime, LastBrowserEventAt = oldTime }, + "test-aged-pending", + CancellationToken.None); + + var reconciled = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload( + "Whale", "old-reconciled", "in_progress", null, null, IsReconciliation: true)); + + Assert.True(reconciled.Success, reconciled.Message); + var job = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(oldTime, job!.LastBrowserEventAt); + Assert.Single(DownloadJobQueries.ActiveSelections([job])); + Assert.Empty(DownloadJobQueries.AutomaticSelections([job], DateTimeOffset.UtcNow)); + } + + [Fact] + public async Task DeletingHistoryDoesNotDeleteTheDownloadedFile() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "delete-1", "keep.txt"); + var source = Path.Combine(incoming, "keep.txt"); + await File.WriteAllTextAsync(source, "must remain", CancellationToken.None); + + var response = await SendAsync(handler, "jobs.delete", new JobsDeletePayload([jobId])); + + Assert.True(response.Success, response.Message); + Assert.Equal(1, response.Data!.Value.GetProperty("deleted").GetInt32()); + Assert.Equal(0, response.Data.Value.GetProperty("filesDeleted").GetInt32()); + Assert.Null(await repository.GetJobAsync(jobId, CancellationToken.None)); + Assert.True(File.Exists(source)); + Assert.Equal("must remain", await File.ReadAllTextAsync(source, CancellationToken.None)); + } + + [Fact] + public async Task TemporaryNameUpdatesTheSameJobWhenDownloadsApiReportsFinalMetadata() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "metadata-1", "download"); + var initial = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(DownloadPresentation.PendingFileName, DownloadPresentation.DisplayFileName(initial!)); + + var temporary = await SendAsync( + handler, + "download.metadata", + new DownloadMetadataChangedPayload("Whale", "metadata-1", "C:\\Downloads\\미확인 197533.crdownload", null)); + Assert.True(temporary.Success, temporary.Message); + Assert.Equal(DownloadPresentation.PendingFileName, DownloadPresentation.DisplayFileName((await repository.GetJobAsync(jobId, CancellationToken.None))!)); + + var finalMetadata = await SendAsync( + handler, + "download.metadata", + new DownloadMetadataChangedPayload("Whale", "metadata-1", "C:\\Downloads\\실제 이름.zip", "실제 이름.zip")); + Assert.True(finalMetadata.Success, finalMetadata.Message); + Assert.Equal("실제 이름.zip", (await repository.GetJobAsync(jobId, CancellationToken.None))!.CurrentFileName); + + var duplicateStart = await SendAsync( + handler, + "download.started", + new DownloadStartedPayload( + "Whale", "metadata-1", "실제 이름.zip", null, "https://example.com/downloads", + "https://example.com/file", null, null)); + Assert.True(duplicateStart.Success, duplicateStart.Message); + Assert.True(duplicateStart.Data!.Value.GetProperty("existing").GetBoolean()); + Assert.Single(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None)); + } + + [Fact] + public async Task RouteCanChangeBeforeCompletionAndMovesImmediatelyAfterCompletion() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + Directory.CreateDirectory(Path.Combine(destination, "kr", "모야지")); + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "route-before-complete", "tree.bin"); + + var changed = await SendAsync( + handler, + "route.change", + new JobRouteChangePayload(jobId, Path.Combine("kr", "모야지"))); + Assert.True(changed.Success, changed.Message); + var selected = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.InProgress, selected!.BrowserState); + Assert.Equal(RoutingState.SelectionReady, selected.RoutingState); + + var source = Path.Combine(incoming, "tree.bin"); + await File.WriteAllTextAsync(source, "tree route", CancellationToken.None); + var completed = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "route-before-complete", "complete", source, null, "tree.bin")); + Assert.True(completed.Success, completed.Message); + Assert.True(File.Exists(Path.Combine(destination, "kr", "모야지", "tree.bin"))); + } + + [Fact] + public async Task CompletedFileRequiresConfirmationAndCanMoveAgainWithoutOverwrite() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + Directory.CreateDirectory(Path.Combine(destination, "other")); + await CreateRuleAsync(repository, destination, StorageMode.Automatic); + var jobId = await StartAsync(handler, "reroute-complete", "completed.txt"); + var source = Path.Combine(incoming, "completed.txt"); + await File.WriteAllTextAsync(source, "completed route", CancellationToken.None); + Assert.True((await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "reroute-complete", "complete", source, null))).Success); + + var confirmationRequired = await SendAsync( + handler, + "route.change", + new JobRouteChangePayload(jobId, "other")); + Assert.False(confirmationRequired.Success); + Assert.Equal("route.confirmation-required", confirmationRequired.ErrorCode); + + var movedAgain = await SendAsync( + handler, + "route.change", + new JobRouteChangePayload(jobId, "other", ConfirmCompletedMove: true)); + Assert.True(movedAgain.Success, movedAgain.Message); + Assert.True(File.Exists(Path.Combine(destination, "other", "completed.txt"))); + Assert.Equal("completed route", await File.ReadAllTextAsync(Path.Combine(destination, "other", "completed.txt"), CancellationToken.None)); + } + + [Fact] + public async Task RuleEditAndSoftDeletePreserveIdentityCreationTimeAndHistory() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + var rule = await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "rule-history", "history.bin"); + var attemptedCreatedAt = rule.CreatedAt.AddDays(10); + + var edited = await SendAsync( + handler, + "rules.upsert", + rule with { Name = "edited rule", IsEnabled = false, CreatedAt = attemptedCreatedAt }); + Assert.True(edited.Success, edited.Message); + var restored = await repository.GetRuleAsync(rule.Id, CancellationToken.None); + Assert.Equal(rule.Id, restored!.Id); + Assert.Equal(rule.CreatedAt, restored.CreatedAt); + Assert.Equal("edited rule", restored.Name); + Assert.False(restored.IsEnabled); + + var deleted = await SendAsync(handler, "rules.delete", new RuleDeletePayload(rule.Id)); + Assert.True(deleted.Success, deleted.Message); + Assert.Empty(await repository.GetRulesAsync(CancellationToken.None)); + Assert.NotNull(await repository.GetJobAsync(jobId, CancellationToken.None)); + } + + [Fact] + public async Task CancelledJobCannotChangeRouteRegardlessOfPreviousSelection() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + Directory.CreateDirectory(Path.Combine(destination, "A")); + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "cancel-route", "cancel.bin"); + Assert.True((await SendAsync( + handler, + "selection.complete", + new SelectionCompletedPayload([jobId], "A"))).Success); + Assert.True((await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "cancel-route", "cancelled", null, "USER_CANCELED"))).Success); + + var response = await SendAsync( + handler, + "route.change", + new JobRouteChangePayload(jobId, string.Empty)); + + Assert.False(response.Success); + Assert.Equal("route.change-not-allowed", response.ErrorCode); + var cancelled = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Cancelled, cancelled!.BrowserState); + Assert.Equal(RoutingState.NotRequired, cancelled.RoutingState); + } + + [Fact] + public async Task SkippingSelectionKeepsTheCompletedFileInItsBrowserLocation() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "skip-route", "keep-here.txt"); + + var skipped = await SendAsync(handler, "selection.skip", new SelectionSkippedPayload(jobId)); + Assert.True(skipped.Success, skipped.Message); + + var source = Path.Combine(incoming, "keep-here.txt"); + await File.WriteAllTextAsync(source, "do not move", CancellationToken.None); + var completed = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "skip-route", "complete", source, null, "keep-here.txt")); + + Assert.True(completed.Success, completed.Message); + var job = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Complete, job!.BrowserState); + Assert.Equal(RoutingState.Skipped, job.RoutingState); + Assert.Null(job.FinalPath); + Assert.True(File.Exists(source)); + Assert.Empty(Directory.EnumerateFiles(destination)); + } + + private static async Task CreateRuleAsync( + DownloadRouterRepository repository, + string destination, + StorageMode storageMode) + { + var now = DateTimeOffset.UtcNow; + var rule = new DownloadRule( + Guid.NewGuid(), "selection rule", true, RuleMatchType.DomainAndSubdomains, "example.com", + RuleMatchTarget.InitiatingPage, destination, storageMode, 0, 0, now, now); + await repository.UpsertRuleAsync(rule, CancellationToken.None); + return rule; + } + + private static async Task StartAsync( + AgentCommandHandler handler, + string downloadId, + string fileName) + { + var response = await SendAsync( + handler, + "download.started", + new DownloadStartedPayload( + "Whale", downloadId, fileName, null, "https://example.com/downloads", + $"https://example.com/{fileName}", null, null)); + Assert.True(response.Success, response.Message); + return response.Data!.Value.GetProperty("jobId").GetGuid(); + } + + private static Task SendAsync( + AgentCommandHandler handler, + string command, + object payload) + => handler.HandleAsync( + new AgentCommand( + ProtocolConstants.CurrentVersion, + Guid.NewGuid(), + command, + JsonSerializer.SerializeToElement(payload, ProtocolJson.Options)), + CancellationToken.None); + private async Task<(AgentCommandHandler Handler, DownloadRouterRepository Repository)> CreateHandlerAsync() { var paths = AppPaths.CreateDefault(); @@ -72,11 +607,17 @@ public async Task UnmatchedDownloadFailsOpenAndCreatesNoJob() boundary, new DownloadJobStateMachine(), new FileMoveService(boundary, NullLogger.Instance), + new NoOpSelectionUiLauncher(), paths, NullLogger.Instance); return (handler, repository); } + private sealed class NoOpSelectionUiLauncher : ISelectionUiLauncher + { + public bool RequestSelectionUi() => true; + } + public void Dispose() { Environment.SetEnvironmentVariable("DOWNLOAD_ROUTER_DATA_DIR", previousOverride);