diff --git a/docs/PORTING_PLAN.md b/docs/PORTING_PLAN.md index f49f837..1230c8b 100644 --- a/docs/PORTING_PLAN.md +++ b/docs/PORTING_PLAN.md @@ -57,6 +57,14 @@ - [ ] 성능 측정(메모리, CPU, 대용량 큐) - [ ] macOS/Windows/Linux 빌드 파이프라인 +### Phase 6 (신규): Native Messaging 안정화 전환 +- 목표 산출물: `브라우저 -> Native Host -> gdown` 경로를 표준 경로로 확정 +- Step 1: Native Host 스캐폴드(프로토콜/manifest/설치 스크립트) [진행] +- Step 2: 확장 우클릭/자동 다운로드 경로를 Native Host로 연결 [진행] +- Step 3: 앱 제어(다운로드 추가/포커스) 채널 정식화 +- Step 4: 링크 자동 후킹을 Native Host 경로로 이관 +- Step 5: 오류 복구/로깅/설정 UX 정리 + ## 5. 리스크 및 대응 - aria2 바이너리 번들/서명: 플랫폼별 바이너리 동봉 규칙 문서화 + CI 검증 - Electron API 차이: 기능별 대체표를 먼저 만들고 Tauri plugin으로 대응 @@ -88,6 +96,6 @@ - `src/App.vue`: Motrix 스타일 사이드바/목록/추가 모달/액션 버튼 - `src/style.css`: 작업 액션 UI 스타일 보강 - 다음 우선순위: - 1. Motrix `Task Detail` 동등 기능(파일/피어/트래커/활동) 구현 - 2. 설정 저장소 도입(local persist + aria2 global option 적용) - 3. 선택 기반 배치 작업 액션 및 리스트 인터랙션 개선 + 1. Native Messaging Host MVP 구축 (확장 연동 전 단계 산출물) + 2. Motrix `Task Detail` 동등 기능(파일/피어/트래커/활동) 구현 + 3. 설정 저장소 도입(local persist + aria2 global option 적용) diff --git a/docs/TODO.md b/docs/TODO.md index 1020c8c..fcfb5c0 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -7,32 +7,65 @@ - 작업 후 반드시 이 파일과 `PORTING_PLAN.md` 동시 업데이트 ## 현재 진행률 -- 전체: 약 30% -- 엔진/RPC: 60%+ -- UI 동등성: 35~40% -- 고급 기능: 10~20% +- 전체: 약 45% +- 엔진/RPC: 70%+ +- UI 동등성: 55~60% +- 고급 기능: 40~50% ## In Progress -- [ ] Task Detail 패널 1차 포팅 +- [~] Native Messaging 기반 브라우저 연동 전환 (Step-by-step) + - [x] Step 1: Native Host 스캐폴드(프로토콜/설치 스크립트/템플릿 manifest) + - [x] Step 2: 확장에서 Native Host 1차 연결(우클릭 + 자동 경로 공통 addUri) + - [~] Step 3: gdown 앱 제어 채널 정식화(다운로드 추가/포커스) + - [ ] Step 4: 링크 자동 후킹 경로 Native Host로 전환 + - [ ] Step 5: 장애 복구/재시도/로깅 정리 + - [ ] Step 6: 설정 UI/배포 문서 정리 +- [~] Task Detail 패널 1차 포팅 + - [x] General + - [x] Activity + - [x] Files + - [x] Peers + - [x] Trackers + - [~] 하단 액션바/상세 인터랙션 Motrix 동등화 + - [x] 하단 액션바(재개/중지/삭제/폴더/링크) + - [x] 탭 아이콘/간격/텍스트 밀도 디테일 튜닝 + - [x] 피어 %/클라이언트 파싱 고도화 + +## Next +- [~] 설정 저장소(local) 연결 + - [x] RPC Port / Secret / Binary Path 저장/복원 + - [x] Download Dir / Split / Concurrent 저장/복원 + - [x] 테마/고급 설정 키 저장/복원 +- [x] aria2 global option 반영 커맨드 + - [x] `changeGlobalOption` 대응 +- [ ] 리스트 인터랙션 강화 + - [ ] 행 선택 상태 + - [ ] 다중 선택 + - [ ] 선택 항목 일괄 액션 +- [ ] Task Detail 패널 1차 포팅 마무리 - [ ] General - [ ] Activity - [ ] Files - [ ] Peers - [ ] Trackers -## Next -- [~] 설정 저장소(local) 연결 - - [x] RPC Port / Secret / Binary Path 저장/복원 - - [x] Download Dir / Split / Concurrent 저장/복원 - - [ ] 테마/고급 설정 키 저장/복원 -- [ ] aria2 global option 반영 커맨드 - - [ ] `changeGlobalOption` 대응 -- [ ] 리스트 인터랙션 강화 - - [ ] 행 선택 상태 - - [ ] 다중 선택 - - [ ] 선택 항목 일괄 액션 - ## Done +- [x] Native Host 설치/삭제/스모크 스크립트 추가 (`tools/native-host/install-macos.sh`, `uninstall-macos.sh`, `smoke.mjs`) +- [x] Native Messaging Host 1차 스캐폴드 추가 (`tools/native-host/*`) +- [x] 앱 종료 시 aria2 종료 루틴 보강 (강제 정리 + 메인 윈도우 close 이벤트 훅) +- [x] Browser extension 연동 UX: `gdown://focus` 딥링크 수신 시 앱 창 `show/unminimize/focus` 처리 +- [x] Add Task 모달 고급 옵션(접기/펼치기) 구현: 사용자 에이전트/권한 부여/리퍼러/쿠키/프록시/다운로드로 이동 +- [x] Add Task 고급 옵션 값의 aria2 전달 연결 (`header`, `all-proxy`, `user-agent`) +- [x] aria2 addUri/addTorrent 요청에 `options`/`position` 확장 지원 +- [x] 작업 카드 `i` 버튼 우측 슬라이드 Task Info 패널 구현 (General/Activity/Trackers/Peers/Files) +- [x] Task Detail API 확장: 토렌트 생성일/코멘트/트래커 목록 필드 추가 +- [x] 고급 설정 RPC 확장 연동 보조 기능 구현 (RPC URL/토큰 복사, RPC 연결 테스트) +- [x] 엔진 실행 정책 정식화: 외부 aria2 자동 재사용 제거, 포트 충돌 시 명시적 오류 처리 +- [x] 작업 추가 임시 재시도 로직 제거, TLS 설정 기반 단일 경로로 정리 +- [x] 설정 화면 저장/적용 시 하단 중앙 토스트 피드백 추가 및 애니메이션 적용 +- [x] 다운로드 필터에 `다운로드 완료` 추가 및 완료 항목 자동 분류 +- [x] 다운로드 중 항목 삭제 신뢰성 개선 (`forceRemove -> remove -> removeDownloadResult`) +- [x] 고급 설정 탭 1차 구현 (업데이트/프록시/트래커/RPC/포트/프로토콜/UA) + 런타임 즉시 반영 - [x] Motrix `TaskActions.vue` / `TaskItemActions.vue` 분석 기반 아이콘 기능 매핑 적용 - [x] 항목 아이콘 기능 확장: 폴더 열기(네이티브 파일관리자), 링크 복사(URI 우선), 정보 표시 - [x] 다운로드 화면을 스크린샷 기준으로 재정렬(라이트 톤, 좌측 작업 패널, 상단 아이콘 툴바) diff --git a/package-lock.json b/package-lock.json index 058628e..ee382c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "@tauri-apps/api": "^2.10.1", - "@tauri-apps/plugin-dialog": "^2.4.2", + "@tauri-apps/plugin-deep-link": "^2.4.3", + "@tauri-apps/plugin-dialog": "^2.6.0", "vue": "^3.5.25" }, "devDependencies": { @@ -1103,6 +1104,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.7.tgz", + "integrity": "sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", @@ -1125,6 +1135,7 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1469,6 +1480,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1581,6 +1593,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1602,6 +1615,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -1683,6 +1697,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", diff --git a/package.json b/package.json index a439424..2373abc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gdown", "private": true, - "version": "0.0.0", + "version": "0.1.1", "type": "module", "engines": { "node": ">=24 <25" @@ -10,13 +10,19 @@ "dev": "vite", "build": "vue-tsc -b && vite build", "preview": "vite preview", + "pretauri:dev": "bash scripts/cleanup-stale-aria2.sh 16800", "tauri:dev": "tauri dev", + "pretauri:build": "bash scripts/cleanup-stale-aria2.sh 16800", "tauri:build": "bash scripts/version-bump.sh && tauri build", "version:bump": "bash scripts/version-bump.sh", - "sync:aria2": "bash scripts/sync-aria2-from-motrix.sh" + "sync:aria2": "bash scripts/sync-aria2-from-motrix.sh", + "native-host:smoke": "cd tools/native-host && npm run smoke", + "native-host:install": "bash tools/native-host/install-macos.sh alaohbbicffclloghmknhlmfdbobcigc", + "native-host:uninstall": "bash tools/native-host/uninstall-macos.sh" }, "dependencies": { "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-deep-link": "^2.4.3", "@tauri-apps/plugin-dialog": "^2.6.0", "vue": "^3.5.25" }, diff --git a/scripts/cleanup-stale-aria2.sh b/scripts/cleanup-stale-aria2.sh new file mode 100644 index 0000000..250aba6 --- /dev/null +++ b/scripts/cleanup-stale-aria2.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORT="${1:-16800}" + +# Kill stale aria2 process bound to the target RPC port. +PIDS="$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t 2>/dev/null | sort -u || true)" +if [[ -z "$PIDS" ]]; then + exit 0 +fi + +for pid in $PIDS; do + cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)" + if [[ "$cmd" == *aria2c* ]]; then + kill -15 "$pid" 2>/dev/null || true + fi +done + +sleep 0.3 + +PIDS="$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t 2>/dev/null | sort -u || true)" +if [[ -n "$PIDS" ]]; then + for pid in $PIDS; do + cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)" + if [[ "$cmd" == *aria2c* ]]; then + kill -9 "$pid" 2>/dev/null || true + fi + done +fi diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2ded603..6836301 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -86,6 +86,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-log", ] @@ -431,6 +432,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -520,6 +541,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -708,6 +735,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dpi" version = "0.1.2" @@ -1296,6 +1332,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2247,6 +2289,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "pango" version = "0.18.3" @@ -3025,6 +3077,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -3787,6 +3849,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -4034,6 +4117,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -4244,9 +4336,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4867,6 +4971,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 75329f5..e3fafbc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.0" +version = "0.1.1" description = "A Tauri App" authors = ["you"] license = "" @@ -24,5 +24,6 @@ log = "0.4" tauri = { version = "2.10.0", features = [] } tauri-plugin-log = "2" tauri-plugin-dialog = "2" +tauri-plugin-deep-link = "2" reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls"] } base64 = "0.22" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5b5e8de..6d48bde 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ ], "permissions": [ "core:default", - "dialog:allow-open" + "dialog:allow-open", + "deep-link:default" ] } diff --git a/src-tauri/resources/engine/darwin/arm64/aria2c b/src-tauri/resources/engine/darwin/arm64/aria2c index f4a2740..de57414 100755 Binary files a/src-tauri/resources/engine/darwin/arm64/aria2c and b/src-tauri/resources/engine/darwin/arm64/aria2c differ diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index 56e2939..e3d1145 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -2,12 +2,14 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::collections::BTreeMap; use std::env; +use std::io::ErrorKind; use std::net::{SocketAddr, TcpStream}; use std::path::Path; use std::process::{Child, Command, Stdio}; use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{Manager, State}; #[derive(Debug, Deserialize)] @@ -36,6 +38,8 @@ pub struct Aria2AddUriRequest { pub out: Option, pub dir: Option, pub split: Option, + pub options: Option>, + pub position: Option, } #[derive(Debug, Deserialize)] @@ -46,6 +50,8 @@ pub struct Aria2AddTorrentRequest { pub out: Option, pub dir: Option, pub split: Option, + pub options: Option>, + pub position: Option, } #[derive(Debug, Deserialize)] @@ -55,6 +61,20 @@ pub struct Aria2TaskCommandRequest { pub gid: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Aria2TaskDetailRequest { + pub rpc: Aria2RpcConfig, + pub gid: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Aria2ChangeGlobalOptionRequest { + pub rpc: Aria2RpcConfig, + pub options: BTreeMap, +} + #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct EngineStatusResponse { @@ -86,6 +106,63 @@ pub struct Aria2TaskSnapshot { pub stopped: Vec, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Aria2TaskFile { + pub path: String, + pub length: String, + pub completed_length: String, + pub selected: String, + pub uris: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Aria2TaskPeer { + pub peer_id: String, + pub ip: String, + pub port: String, + pub bitfield: String, + pub am_choking: String, + pub peer_choking: String, + pub download_speed: String, + pub upload_speed: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Aria2TaskServer { + pub uri: String, + pub current_uri: String, + pub download_speed: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Aria2TaskDetail { + pub gid: String, + pub status: String, + pub total_length: String, + pub completed_length: String, + pub upload_length: String, + pub download_speed: String, + pub upload_speed: String, + pub num_seeders: String, + pub connections: String, + pub piece_length: String, + pub num_pieces: String, + pub dir: String, + pub info_hash: String, + pub creation_date: String, + pub comment: String, + pub error_code: String, + pub error_message: String, + pub trackers: Vec, + pub files: Vec, + pub peers: Vec, + pub servers: Vec, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct TorrentFilePayload { @@ -143,6 +220,162 @@ impl EngineState { } } +fn reset_runtime_after_stop(runtime: &mut EngineRuntime) { + runtime.child = None; + runtime.external_reuse = false; + runtime.rpc_port = None; + runtime.started_at = None; +} + +fn force_kill_pid(pid: u32) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + let status = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F", "/T"]) + .status() + .map_err(|err| format!("failed to execute taskkill for pid {pid}: {err}"))?; + if !status.success() { + return Err(format!("taskkill failed for pid {pid} with status {status}")); + } + return Ok(()); + } + + #[cfg(not(target_os = "windows"))] + { + let status = Command::new("kill") + .args(["-9", &pid.to_string()]) + .status() + .map_err(|err| format!("failed to execute kill -9 for pid {pid}: {err}"))?; + if !status.success() { + return Err(format!("kill -9 failed for pid {pid} with status {status}")); + } + Ok(()) + } +} + +fn list_listening_pids_on_port(port: u16) -> Result, String> { + #[cfg(target_os = "windows")] + { + let _ = port; + Ok(vec![]) + } + + #[cfg(not(target_os = "windows"))] + { + let target = format!(":{port}"); + let output = Command::new("lsof") + .args(["-nP", "-iTCP", target.as_str(), "-sTCP:LISTEN", "-t"]) + .output() + .map_err(|err| format!("failed to execute lsof for port {port}: {err}"))?; + + if !output.status.success() && output.stdout.is_empty() { + return Ok(vec![]); + } + + let text = String::from_utf8_lossy(&output.stdout); + let mut pids: Vec = text + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect(); + pids.sort_unstable(); + pids.dedup(); + Ok(pids) + } +} + +fn try_release_local_port(port: u16) -> Result<(), String> { + if !is_local_port_open(port) { + return Ok(()); + } + + let pids = list_listening_pids_on_port(port)?; + for pid in &pids { + #[cfg(target_os = "windows")] + { + let status = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T"]) + .status() + .map_err(|err| format!("failed to execute taskkill for pid {pid}: {err}"))?; + if !status.success() { + let _ = force_kill_pid(*pid); + } + } + + #[cfg(not(target_os = "windows"))] + { + let status = Command::new("kill") + .args(["-15", &pid.to_string()]) + .status() + .map_err(|err| format!("failed to execute kill -15 for pid {pid}: {err}"))?; + if !status.success() { + let _ = force_kill_pid(*pid); + } + } + } + + std::thread::sleep(Duration::from_millis(350)); + if is_local_port_open(port) { + for pid in list_listening_pids_on_port(port)? { + let _ = force_kill_pid(pid); + } + std::thread::sleep(Duration::from_millis(200)); + } + Ok(()) +} + +fn terminate_child_process(child: &mut Child) -> Result<(), String> { + match child.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) => {} + Err(err) => return Err(format!("failed to inspect aria2 process before stop: {err}")), + } + + let pid = child.id(); + if let Err(err) = child.kill() { + if err.kind() != ErrorKind::NotFound { + return Err(format!("failed to stop aria2 engine: {err}")); + } + } + + let deadline = Instant::now() + Duration::from_millis(900); + while Instant::now() < deadline { + match child.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) => std::thread::sleep(Duration::from_millis(40)), + Err(err) => { + if err.kind() == ErrorKind::InvalidInput || err.kind() == ErrorKind::NotFound { + return Ok(()); + } + return Err(format!("failed while waiting aria2 stop: {err}")); + } + } + } + + let _ = force_kill_pid(pid); + let _ = child.wait(); + Ok(()) +} + +fn stop_engine_runtime(runtime: &mut EngineRuntime) -> Result<(), String> { + let mut error: Option = None; + if let Some(mut child) = runtime.child.take() { + if let Err(err) = terminate_child_process(&mut child) { + error = Some(err); + } + } + reset_runtime_after_stop(runtime); + if let Some(err) = error { + return Err(err); + } + Ok(()) +} + +pub fn stop_engine_for_exit(state: &EngineState) { + if let Ok(mut runtime) = state.runtime.lock() { + let _ = stop_engine_runtime(&mut runtime); + } +} + fn build_engine_args(request: &EngineStartRequest) -> Vec { let mut args = vec![ "--enable-rpc=true".to_string(), @@ -152,6 +385,7 @@ fn build_engine_args(request: &EngineStartRequest) -> Vec { "--max-concurrent-downloads=".to_string() + &request.max_concurrent_downloads.unwrap_or(5).to_string(), "--split=".to_string() + &request.split.unwrap_or(8).to_string(), "--continue=true".to_string(), + "--check-certificate=false".to_string(), ]; if let Some(secret) = &request.rpc_secret { @@ -420,7 +654,124 @@ fn map_tasks(result: Value) -> Vec { .unwrap_or_default() } -fn build_rpc_options(out: Option<&String>, dir: Option<&String>, split: Option) -> Value { +fn map_task_files(status_result: &Value) -> Vec { + status_result + .get("files") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|file| Aria2TaskFile { + path: value_to_string(file.get("path")), + length: value_to_string(file.get("length")), + completed_length: value_to_string(file.get("completedLength")), + selected: value_to_string(file.get("selected")), + uris: file + .get("uris") + .and_then(Value::as_array) + .map(|uris| { + uris + .iter() + .filter_map(|u| u.get("uri").and_then(Value::as_str).map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(), + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn map_task_peers(result: &Value) -> Vec { + result + .as_array() + .map(|items| { + items + .iter() + .map(|peer| Aria2TaskPeer { + peer_id: value_to_string(peer.get("peerId")), + ip: value_to_string(peer.get("ip")), + port: value_to_string(peer.get("port")), + bitfield: value_to_string(peer.get("bitfield")), + am_choking: value_to_string(peer.get("amChoking")), + peer_choking: value_to_string(peer.get("peerChoking")), + download_speed: value_to_string(peer.get("downloadSpeed")), + upload_speed: value_to_string(peer.get("uploadSpeed")), + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn map_task_servers(result: &Value) -> Vec { + result + .as_array() + .map(|items| { + items + .iter() + .map(|server| { + let current_uri = server + .get("currentUri") + .and_then(Value::as_object) + .and_then(|obj| obj.get("uri")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + Aria2TaskServer { + uri: value_to_string(server.get("uri")), + current_uri, + download_speed: value_to_string(server.get("downloadSpeed")), + } + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn map_task_trackers(status_result: &Value) -> Vec { + let mut trackers: Vec = vec![]; + if let Some(announce_list) = status_result + .get("bittorrent") + .and_then(|v| v.get("announceList")) + .and_then(Value::as_array) + { + for tier in announce_list { + if let Some(items) = tier.as_array() { + for tracker in items { + if let Some(url) = tracker.as_str() { + let trimmed = url.trim(); + if !trimmed.is_empty() { + trackers.push(trimmed.to_string()); + } + } + } + } + } + } + + if trackers.is_empty() { + if let Some(primary) = status_result + .get("bittorrent") + .and_then(|v| v.get("announce")) + .and_then(Value::as_str) + { + let trimmed = primary.trim(); + if !trimmed.is_empty() { + trackers.push(trimmed.to_string()); + } + } + } + trackers.sort(); + trackers.dedup(); + trackers +} + +fn build_rpc_options( + out: Option<&String>, + dir: Option<&String>, + split: Option, + extra: Option<&BTreeMap>, +) -> Value { let mut options = serde_json::Map::new(); if let Some(value) = out { if !value.trim().is_empty() { @@ -435,6 +786,11 @@ fn build_rpc_options(out: Option<&String>, dir: Option<&String>, split: Option { @@ -481,21 +827,13 @@ pub fn engine_start( let args = build_engine_args(&request); - // Reuse existing engine when the target RPC port is already occupied. - // This mirrors Motrix-style behavior where an already-running aria2 instance is reused. if is_local_port_open(rpc_port) { - runtime.child = None; - runtime.external_reuse = true; - runtime.rpc_port = Some(rpc_port); - runtime.binary_path = Some(format!("external://127.0.0.1:{rpc_port}")); - runtime.args = args; - runtime.started_at = Some( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|err| format!("failed to get system time: {err}"))? - .as_secs(), - ); - return Ok(EngineState::status(&runtime)); + try_release_local_port(rpc_port)?; + if is_local_port_open(rpc_port) { + return Err(format!( + "RPC 포트 {rpc_port} 가 이미 사용 중입니다. 점유 프로세스 정리 후 다시 시도하세요." + )); + } } let candidates = collect_binary_candidates(&app, request.binary_path.as_deref()); @@ -565,16 +903,7 @@ pub fn engine_stop(state: State<'_, EngineState>) -> Result Result Result Result Result { + let gid = request.gid.trim(); + if gid.is_empty() { + return Err("gid is required".to_string()); + } + + let client = Client::new(); + let status_result = + call_aria2_rpc(&client, &request.rpc, "aria2.tellStatus", vec![json!(gid)]).await?; + let peers_result = call_aria2_rpc(&client, &request.rpc, "aria2.getPeers", vec![json!(gid)]) + .await + .unwrap_or_else(|_| json!([])); + let servers_result = call_aria2_rpc(&client, &request.rpc, "aria2.getServers", vec![json!(gid)]) + .await + .unwrap_or_else(|_| json!([])); + + Ok(Aria2TaskDetail { + gid: value_to_string(status_result.get("gid")), + status: value_to_string(status_result.get("status")), + total_length: value_to_string(status_result.get("totalLength")), + completed_length: value_to_string(status_result.get("completedLength")), + upload_length: value_to_string(status_result.get("uploadLength")), + download_speed: value_to_string(status_result.get("downloadSpeed")), + upload_speed: value_to_string(status_result.get("uploadSpeed")), + num_seeders: value_to_string(status_result.get("numSeeders")), + connections: value_to_string(status_result.get("connections")), + piece_length: value_to_string(status_result.get("pieceLength")), + num_pieces: value_to_string(status_result.get("numPieces")), + dir: value_to_string(status_result.get("dir")), + info_hash: value_to_string(status_result.get("infoHash")), + creation_date: value_to_string( + status_result + .get("bittorrent") + .and_then(|v| v.get("creationDate")), + ), + comment: value_to_string(status_result.get("bittorrent").and_then(|v| v.get("comment"))), + error_code: value_to_string(status_result.get("errorCode")), + error_message: value_to_string(status_result.get("errorMessage")), + trackers: map_task_trackers(&status_result), + files: map_task_files(&status_result), + peers: map_task_peers(&peers_result), + servers: map_task_servers(&servers_result), + }) +} + #[tauri::command] pub async fn aria2_pause_task(request: Aria2TaskCommandRequest) -> Result { let gid = request.gid.trim(); @@ -705,29 +1100,43 @@ pub async fn aria2_remove_task(request: Aria2TaskCommandRequest) -> Result { + return result + .as_str() + .map(|value| value.to_string()) + .ok_or_else(|| format!("aria2.forceRemove returned unexpected result: {result}")) + } + Err(err) if !is_gid_not_found_error(&err) => return Err(err), + Err(_) => {} + }; + match call_aria2_rpc(&client, &request.rpc, "aria2.remove", vec![json!(gid)]).await { + Ok(result) => { + return result + .as_str() + .map(|value| value.to_string()) + .ok_or_else(|| format!("aria2.remove returned unexpected result: {result}")) + } + Err(err) if !is_gid_not_found_error(&err) => return Err(err), + Err(_) => {} + }; + + match call_aria2_rpc( + &client, + &request.rpc, + "aria2.removeDownloadResult", + vec![json!(gid)], + ) + .await + { Ok(result) => result .as_str() .map(|value| value.to_string()) - .ok_or_else(|| format!("aria2.remove returned unexpected result: {result}")), - Err(err) if is_gid_not_found_error(&err) => { - match call_aria2_rpc( - &client, - &request.rpc, - "aria2.removeDownloadResult", - vec![json!(gid)], - ) - .await - { - Ok(result) => result - .as_str() - .map(|value| value.to_string()) - .ok_or_else(|| format!("aria2.removeDownloadResult returned unexpected result: {result}")), - Err(fallback_err) if is_gid_not_found_error(&fallback_err) => Ok(gid.to_string()), - Err(fallback_err) => Err(fallback_err), - } - } - Err(err) => Err(err), + .ok_or_else(|| format!("aria2.removeDownloadResult returned unexpected result: {result}")), + Err(fallback_err) if is_gid_not_found_error(&fallback_err) => Ok(gid.to_string()), + Err(fallback_err) => Err(fallback_err), } } @@ -769,6 +1178,27 @@ pub async fn aria2_resume_all(config: Aria2RpcConfig) -> Result .ok_or_else(|| format!("aria2.unpauseAll returned unexpected result: {result}")) } +#[tauri::command] +pub async fn aria2_change_global_option( + request: Aria2ChangeGlobalOptionRequest, +) -> Result { + let client = Client::new(); + let options = serde_json::to_value(request.options) + .map_err(|err| format!("global option 직렬화 실패: {err}"))?; + let result = call_aria2_rpc( + &client, + &request.rpc, + "aria2.changeGlobalOption", + vec![options], + ) + .await?; + + result + .as_str() + .map(|value| value.to_string()) + .ok_or_else(|| format!("aria2.changeGlobalOption returned unexpected result: {result}")) +} + #[tauri::command] pub fn load_torrent_file(path: String) -> Result { if !path.to_ascii_lowercase().ends_with(".torrent") { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bc72f4e..1202517 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,17 +1,106 @@ mod engine; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use tauri::Manager; use engine::{ - aria2_add_torrent, aria2_add_uri, aria2_list_tasks, aria2_pause_all, aria2_pause_task, - aria2_remove_task, aria2_remove_task_record, aria2_resume_all, aria2_resume_task, - detect_aria2_binary, engine_start, engine_status, engine_stop, load_torrent_file, - open_path_in_file_manager, EngineState, + aria2_add_torrent, aria2_add_uri, aria2_change_global_option, aria2_get_task_detail, + aria2_list_tasks, aria2_pause_all, aria2_pause_task, aria2_remove_task, + aria2_remove_task_record, aria2_resume_all, aria2_resume_task, detect_aria2_binary, + engine_start, engine_status, engine_stop, + load_torrent_file, + open_path_in_file_manager, stop_engine_for_exit, EngineState, }; +#[tauri::command] +async fn focus_main_window(app: tauri::AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or_else(|| "메인 창을 찾을 수 없습니다.".to_string())?; + + window + .show() + .map_err(|error| format!("창 표시 실패: {error}"))?; + window + .unminimize() + .map_err(|error| format!("창 복원 실패: {error}"))?; + window + .set_focus() + .map_err(|error| format!("창 포커스 실패: {error}"))?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExternalAddRequest { + url: String, + out: Option, + dir: Option, + referer: Option, + user_agent: Option, + authorization: Option, + cookie: Option, + proxy: Option, + split: Option, +} + +fn external_add_queue_path() -> Result { + let home = std::env::var("HOME").map_err(|err| format!("HOME 경로 확인 실패: {err}"))?; + Ok(PathBuf::from(home).join(".gdown").join("external_add_queue.jsonl")) +} + +#[tauri::command] +fn take_external_add_requests() -> Result, String> { + let path = external_add_queue_path()?; + let parent = path + .parent() + .ok_or_else(|| "큐 디렉터리 경로를 계산할 수 없습니다.".to_string())?; + fs::create_dir_all(parent).map_err(|err| format!("큐 디렉터리 생성 실패: {err}"))?; + + if !path.exists() { + return Ok(Vec::new()); + } + + let content = fs::read_to_string(&path).map_err(|err| format!("큐 읽기 실패: {err}"))?; + let mut requests: Vec = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(item) = serde_json::from_str::(trimmed) { + requests.push(item); + } + } + + let mut file = fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&path) + .map_err(|err| format!("큐 초기화 실패: {err}"))?; + file + .write_all(b"") + .map_err(|err| format!("큐 파일 쓰기 실패: {err}"))?; + + Ok(requests) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .manage(EngineState::default()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_deep_link::init()) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + if window.label() == "main" { + let state = window.state::(); + stop_engine_for_exit(&state); + } + } + }) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -29,6 +118,8 @@ pub fn run() { detect_aria2_binary, aria2_add_torrent, aria2_add_uri, + aria2_change_global_option, + aria2_get_task_detail, aria2_list_tasks, aria2_pause_task, aria2_resume_task, @@ -37,7 +128,9 @@ pub fn run() { aria2_pause_all, aria2_resume_all, load_torrent_file, - open_path_in_file_manager + open_path_in_file_manager, + focus_main_window, + take_external_add_requests ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index df6e727..f7b1051 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "gdown", - "version": "0.1.0", + "version": "0.1.1", "identifier": "com.tauri.dev", "build": { "frontendDist": "../dist", @@ -39,5 +39,14 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": [ + "gdown" + ] + } + } } } diff --git a/src/App.vue b/src/App.vue index 04a38a6..c52efd7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,30 +3,39 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { getCurrentWindow } from '@tauri-apps/api/window' import type { UnlistenFn } from '@tauri-apps/api/event' import { open as openDialog } from '@tauri-apps/plugin-dialog' +import { getCurrent as getCurrentDeepLink, onOpenUrl } from '@tauri-apps/plugin-deep-link' import { addAria2Torrent, addAria2Uri, + changeAria2GlobalOption, detectAria2Binary, getEngineStatus, + getAria2TaskDetail, listAria2Tasks, loadTorrentFile, pauseAllAria2, pauseAria2Task, openPathInFileManager, + focusMainWindow, + takeExternalAddRequests, removeAria2Task, removeAria2TaskRecord, resumeAllAria2, resumeAria2Task, startEngine, + stopEngine, type Aria2Task, + type Aria2TaskDetail, type Aria2TaskSnapshot, type EngineStatus, + type ExternalAddRequest, } from './lib/engineApi' -type TaskFilter = 'all' | 'active' | 'waiting' | 'stopped' +type TaskFilter = 'all' | 'active' | 'waiting' | 'stopped' | 'completed' type AddTab = 'url' | 'torrent' type AppPage = 'downloads' | 'settings' type SettingsTab = 'basic' | 'advanced' | 'lab' +type TaskInfoTab = 'general' | 'activity' | 'trackers' | 'peers' | 'files' type PersistedSettings = { binaryPath?: string rpcPort?: number @@ -35,6 +44,37 @@ type PersistedSettings = { split?: number maxConcurrentDownloads?: number autoRefresh?: boolean + theme?: 'auto' | 'light' | 'dark' + hideWindowOnStartup?: boolean + traySpeed?: boolean + showDockProgress?: boolean + language?: string + runOnLogin?: boolean + rememberWindow?: boolean + autoResume?: boolean + uploadLimit?: number + downloadLimit?: number + magnetAsTorrent?: boolean + autoDownloadTorrentMeta?: boolean + btForceEncryption?: boolean + keepSeeding?: boolean + seedRatio?: number + autoCheckUpdate?: boolean + proxyEnabled?: boolean + proxyServer?: string + proxyBypass?: string + trackerSources?: string[] + trackerList?: string + autoSyncTracker?: boolean + lastSyncTrackerTime?: number + lastCheckUpdateTime?: number + btListenPort?: number + dhtListenPort?: number + enableUpnp?: boolean + protocolMagnet?: boolean + protocolThunder?: boolean + userAgent?: string + skipTlsVerify?: boolean } const SETTINGS_STORAGE_KEY = 'gdown.settings.v1' @@ -42,6 +82,8 @@ const SETTINGS_STORAGE_KEY = 'gdown.settings.v1' const binaryPath = ref('aria2c') const rpcPort = ref(6800) const rpcSecret = ref('') +const activeRpcPort = ref(6800) +const activeRpcSecret = ref('') const downloadDir = ref('') const split = ref(8) const maxConcurrentDownloads = ref(5) @@ -52,6 +94,9 @@ const loadingAddTask = ref(false) const loadingTaskAction = ref(false) const errorMessage = ref('') const successMessage = ref('') +const toastVisible = ref(false) +const toastMessage = ref('') +const toastType = ref<'success' | 'error'>('success') const autoRefresh = ref(true) const filter = ref('active') const page = ref('downloads') @@ -72,12 +117,43 @@ const settingAutoDownloadTorrentMeta = ref(true) const settingBtForceEncryption = ref(false) const settingKeepSeeding = ref(true) const settingSeedRatio = ref(1) +const settingAutoCheckUpdate = ref(true) +const settingProxyEnabled = ref(false) +const settingProxyServer = ref('') +const settingProxyBypass = ref('') +const settingTrackerSources = ref([ + 'https://cdn.jsdelivr.net/gh/ngosang/trackerslist/trackers_best_ip.txt', + 'https://cdn.jsdelivr.net/gh/ngosang/trackerslist/trackers_best.txt', +]) +const settingTrackerSourceInput = ref(settingTrackerSources.value.join('\n')) +const settingTrackerList = ref('') +const settingAutoSyncTracker = ref(true) +const settingLastSyncTrackerTime = ref(0) +const settingLastCheckUpdateTime = ref(0) +const settingBtListenPort = ref(21301) +const settingDhtListenPort = ref(26701) +const settingEnableUpnp = ref(true) +const settingProtocolMagnet = ref(true) +const settingProtocolThunder = ref(false) +const settingUserAgent = ref('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122 Safari/537.36') +const settingSkipTlsVerify = ref(true) +const trackerSyncing = ref(false) +const rpcTestLoading = ref(false) +const rpcTestStatus = ref<'idle' | 'ok' | 'fail'>('idle') +const rpcTestMessage = ref('') const showAddModal = ref(false) const addTab = ref('url') const addUrl = ref('') const addOut = ref('') const addSplit = ref(64) +const addShowAdvanced = ref(false) +const addUserAgent = ref('') +const addAuthorization = ref('') +const addReferer = ref('') +const addCookie = ref('') +const addProxy = ref('') +const addNavigateToDownloading = ref(true) const torrentBase64 = ref('') const torrentFileName = ref('') const torrentFileExt = ref('') @@ -85,6 +161,11 @@ const torrentFileSize = ref(0) const appDropActive = ref(false) const modalDropActive = ref(false) const torrentFileInput = ref(null) +const taskInfoVisible = ref(false) +const taskInfoLoading = ref(false) +const taskInfoTab = ref('general') +const taskInfoTask = ref(null) +const taskInfoDetail = ref(null) const status = ref({ running: false, @@ -102,14 +183,188 @@ const tasks = ref({ let refreshTimer: number | null = null let unlistenDragDrop: UnlistenFn | null = null +let unlistenDeepLink: UnlistenFn | null = null +let toastTimer: number | null = null +let externalAddPollTimer: number | null = null + +async function focusAppWindowForDeepLink() { + try { + await focusMainWindow() + } catch { + const appWindow = getCurrentWindow() + try { + await appWindow.show() + await appWindow.unminimize() + await appWindow.setFocus() + } catch { + // ignore focus fallback failures + } + } +} + +type ExternalAddPayload = ExternalAddRequest + +function parseExternalAddDeepLink(rawUrl: string): ExternalAddPayload | null { + try { + const parsed = new URL(rawUrl) + if (parsed.protocol !== 'gdown:') return null + if (parsed.hostname !== 'add') return null + const url = parsed.searchParams.get('url')?.trim() || '' + if (!url) return null + + const splitRaw = parsed.searchParams.get('split') ?? '' + const split = Number.parseInt(splitRaw, 10) + + return { + url, + out: parsed.searchParams.get('out')?.trim() || undefined, + dir: parsed.searchParams.get('dir')?.trim() || undefined, + referer: parsed.searchParams.get('referer')?.trim() || undefined, + userAgent: parsed.searchParams.get('userAgent')?.trim() || undefined, + authorization: parsed.searchParams.get('authorization')?.trim() || undefined, + cookie: parsed.searchParams.get('cookie')?.trim() || undefined, + proxy: parsed.searchParams.get('proxy')?.trim() || undefined, + split: Number.isFinite(split) && split > 0 ? split : undefined, + } + } catch { + return null + } +} + +function applyExternalAddPayload(payload: ExternalAddPayload) { + showAddModal.value = true + addTab.value = 'url' + const current = addUrl.value.trim() + if (!current) { + addUrl.value = payload.url + } else if (!current.split('\n').map((line) => line.trim()).includes(payload.url)) { + addUrl.value = `${addUrl.value}\n${payload.url}` + } + + if (payload.out) addOut.value = payload.out + if (payload.dir) downloadDir.value = payload.dir + if (payload.referer) addReferer.value = payload.referer + if (payload.userAgent) addUserAgent.value = payload.userAgent + if (payload.authorization) addAuthorization.value = payload.authorization + if (payload.cookie) addCookie.value = payload.cookie + if (payload.proxy) addProxy.value = payload.proxy + if (payload.split) addSplit.value = payload.split + + if (payload.referer || payload.userAgent || payload.authorization || payload.cookie || payload.proxy) { + addShowAdvanced.value = true + } + page.value = 'downloads' + filter.value = 'active' + pushSuccess('외부 다운로드 요청이 추가 창에 채워졌습니다. 확인을 눌러 시작하세요.') +} + +async function pollExternalAddQueue() { + try { + const requests = await takeExternalAddRequests() + if (!Array.isArray(requests) || requests.length === 0) return + await focusAppWindowForDeepLink() + requests.forEach((payload) => { + if (payload?.url?.trim()) { + applyExternalAddPayload(payload) + } + }) + } catch { + // ignore queue poll failures + } +} + +async function handleDeepLinkUrls(urls: string[] | null | undefined) { + if (!Array.isArray(urls)) return + const addPayloads: ExternalAddPayload[] = [] + let shouldFocus = false + + urls.forEach((url) => { + if (typeof url !== 'string') return + if (url.startsWith('gdown://focus')) { + shouldFocus = true + return + } + const payload = parseExternalAddDeepLink(url) + if (payload) { + addPayloads.push(payload) + shouldFocus = true + } + }) + + if (!shouldFocus) return + await focusAppWindowForDeepLink() + addPayloads.forEach((payload) => applyExternalAddPayload(payload)) +} + +function isCompletedTask(task: Aria2Task): boolean { + if (task.status === 'complete') return true + const total = Number(task.totalLength) + const done = Number(task.completedLength) + return Number.isFinite(total) && Number.isFinite(done) && total > 0 && done >= total +} + +const completedTasks = computed(() => tasks.value.stopped.filter((task) => isCompletedTask(task))) +const stoppedTasks = computed(() => tasks.value.stopped.filter((task) => !isCompletedTask(task))) const filteredTasks = computed(() => { if (filter.value === 'active') return tasks.value.active if (filter.value === 'waiting') return tasks.value.waiting - if (filter.value === 'stopped') return tasks.value.stopped - return [...tasks.value.active, ...tasks.value.waiting, ...tasks.value.stopped] + if (filter.value === 'stopped') return stoppedTasks.value + if (filter.value === 'completed') return completedTasks.value + return [...tasks.value.active, ...tasks.value.waiting, ...stoppedTasks.value, ...completedTasks.value] }) const filteredTaskCount = computed(() => filteredTasks.value.length) +const taskInfoLiveTask = computed(() => { + const gid = taskInfoTask.value?.gid + if (!gid) return null + return [...tasks.value.active, ...tasks.value.waiting, ...tasks.value.stopped].find((task) => task.gid === gid) ?? null +}) +const taskInfoActionTask = computed(() => taskInfoLiveTask.value ?? taskInfoTask.value) +const taskInfoProgress = computed(() => { + if (!taskInfoLiveTask.value) return 0 + return progress(taskInfoLiveTask.value) +}) +const taskInfoRatio = computed(() => { + const completed = Number(taskInfoDetail.value?.completedLength ?? '0') + const uploaded = Number(taskInfoDetail.value?.uploadLength ?? '0') + if (!Number.isFinite(completed) || completed <= 0 || !Number.isFinite(uploaded)) return '0' + return (uploaded / completed).toFixed(2) +}) +const taskInfoSelectedFilesText = computed(() => { + const files = taskInfoDetail.value?.files ?? [] + const selected = files.filter((file) => file.selected === 'true' || file.selected === '1') + const selectedCount = selected.length + const size = selected.reduce((sum, file) => sum + (Number(file.length) || 0), 0) + if (selectedCount === 0) return '파일 0개 선택됨' + return `파일 ${selectedCount}개 선택됨, 총 ${formatBytesNumber(size)}` +}) +const rpcEndpointText = computed(() => `http://127.0.0.1:${sanitizePort(rpcPort.value, 6800)}/jsonrpc`) +const rpcTokenText = computed(() => { + const secret = rpcSecret.value.trim() + return secret ? `token:${secret}` : '' +}) +const lastSyncTrackerTimeText = computed(() => { + if (settingLastSyncTrackerTime.value <= 0) return '동기화 이력 없음' + return new Date(settingLastSyncTrackerTime.value).toLocaleString() +}) +const lastCheckUpdateTimeText = computed(() => { + if (settingLastCheckUpdateTime.value <= 0) return '업데이트 확인 이력 없음' + return new Date(settingLastCheckUpdateTime.value).toLocaleString() +}) + +function showToast(type: 'success' | 'error', message: string) { + toastType.value = type + toastMessage.value = message + toastVisible.value = true + + if (toastTimer !== null) { + window.clearTimeout(toastTimer) + } + toastTimer = window.setTimeout(() => { + toastVisible.value = false + toastTimer = null + }, 2200) +} function loadSettingsFromStorage() { try { @@ -125,6 +380,44 @@ function loadSettingsFromStorage() { maxConcurrentDownloads.value = parsed.maxConcurrentDownloads } if (typeof parsed.autoRefresh === 'boolean') autoRefresh.value = parsed.autoRefresh + if (parsed.theme === 'auto' || parsed.theme === 'light' || parsed.theme === 'dark') { + settingTheme.value = parsed.theme + } + if (typeof parsed.hideWindowOnStartup === 'boolean') settingHideWindowOnStartup.value = parsed.hideWindowOnStartup + if (typeof parsed.traySpeed === 'boolean') settingTraySpeed.value = parsed.traySpeed + if (typeof parsed.showDockProgress === 'boolean') settingShowDockProgress.value = parsed.showDockProgress + if (typeof parsed.language === 'string') settingLanguage.value = parsed.language + if (typeof parsed.runOnLogin === 'boolean') settingRunOnLogin.value = parsed.runOnLogin + if (typeof parsed.rememberWindow === 'boolean') settingRememberWindow.value = parsed.rememberWindow + if (typeof parsed.autoResume === 'boolean') settingAutoResume.value = parsed.autoResume + if (typeof parsed.uploadLimit === 'number') settingUploadLimit.value = parsed.uploadLimit + if (typeof parsed.downloadLimit === 'number') settingDownloadLimit.value = parsed.downloadLimit + if (typeof parsed.magnetAsTorrent === 'boolean') settingMagnetAsTorrent.value = parsed.magnetAsTorrent + if (typeof parsed.autoDownloadTorrentMeta === 'boolean') { + settingAutoDownloadTorrentMeta.value = parsed.autoDownloadTorrentMeta + } + if (typeof parsed.btForceEncryption === 'boolean') settingBtForceEncryption.value = parsed.btForceEncryption + if (typeof parsed.keepSeeding === 'boolean') settingKeepSeeding.value = parsed.keepSeeding + if (typeof parsed.seedRatio === 'number') settingSeedRatio.value = parsed.seedRatio + if (typeof parsed.autoCheckUpdate === 'boolean') settingAutoCheckUpdate.value = parsed.autoCheckUpdate + if (typeof parsed.proxyEnabled === 'boolean') settingProxyEnabled.value = parsed.proxyEnabled + if (typeof parsed.proxyServer === 'string') settingProxyServer.value = parsed.proxyServer + if (typeof parsed.proxyBypass === 'string') settingProxyBypass.value = parsed.proxyBypass + if (Array.isArray(parsed.trackerSources)) { + settingTrackerSources.value = parsed.trackerSources.filter((v): v is string => typeof v === 'string') + settingTrackerSourceInput.value = settingTrackerSources.value.join('\n') + } + if (typeof parsed.trackerList === 'string') settingTrackerList.value = parsed.trackerList + if (typeof parsed.autoSyncTracker === 'boolean') settingAutoSyncTracker.value = parsed.autoSyncTracker + if (typeof parsed.lastSyncTrackerTime === 'number') settingLastSyncTrackerTime.value = parsed.lastSyncTrackerTime + if (typeof parsed.lastCheckUpdateTime === 'number') settingLastCheckUpdateTime.value = parsed.lastCheckUpdateTime + if (typeof parsed.btListenPort === 'number') settingBtListenPort.value = parsed.btListenPort + if (typeof parsed.dhtListenPort === 'number') settingDhtListenPort.value = parsed.dhtListenPort + if (typeof parsed.enableUpnp === 'boolean') settingEnableUpnp.value = parsed.enableUpnp + if (typeof parsed.protocolMagnet === 'boolean') settingProtocolMagnet.value = parsed.protocolMagnet + if (typeof parsed.protocolThunder === 'boolean') settingProtocolThunder.value = parsed.protocolThunder + if (typeof parsed.userAgent === 'string') settingUserAgent.value = parsed.userAgent + if (typeof parsed.skipTlsVerify === 'boolean') settingSkipTlsVerify.value = parsed.skipTlsVerify } catch { // ignore malformed settings } @@ -139,6 +432,37 @@ function saveSettingsToStorage() { split: split.value, maxConcurrentDownloads: maxConcurrentDownloads.value, autoRefresh: autoRefresh.value, + theme: settingTheme.value, + hideWindowOnStartup: settingHideWindowOnStartup.value, + traySpeed: settingTraySpeed.value, + showDockProgress: settingShowDockProgress.value, + language: settingLanguage.value, + runOnLogin: settingRunOnLogin.value, + rememberWindow: settingRememberWindow.value, + autoResume: settingAutoResume.value, + uploadLimit: settingUploadLimit.value, + downloadLimit: settingDownloadLimit.value, + magnetAsTorrent: settingMagnetAsTorrent.value, + autoDownloadTorrentMeta: settingAutoDownloadTorrentMeta.value, + btForceEncryption: settingBtForceEncryption.value, + keepSeeding: settingKeepSeeding.value, + seedRatio: settingSeedRatio.value, + autoCheckUpdate: settingAutoCheckUpdate.value, + proxyEnabled: settingProxyEnabled.value, + proxyServer: settingProxyServer.value, + proxyBypass: settingProxyBypass.value, + trackerSources: settingTrackerSources.value, + trackerList: settingTrackerList.value, + autoSyncTracker: settingAutoSyncTracker.value, + lastSyncTrackerTime: settingLastSyncTrackerTime.value, + lastCheckUpdateTime: settingLastCheckUpdateTime.value, + btListenPort: settingBtListenPort.value, + dhtListenPort: settingDhtListenPort.value, + enableUpnp: settingEnableUpnp.value, + protocolMagnet: settingProtocolMagnet.value, + protocolThunder: settingProtocolThunder.value, + userAgent: settingUserAgent.value, + skipTlsVerify: settingSkipTlsVerify.value, } localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(payload)) } @@ -146,17 +470,19 @@ function saveSettingsToStorage() { function pushError(message: string) { successMessage.value = '' errorMessage.value = message + showToast('error', message) } function pushSuccess(message: string) { errorMessage.value = '' successMessage.value = message + showToast('success', message) } function rpcConfig() { return { - rpcListenPort: rpcPort.value, - rpcSecret: rpcSecret.value.trim() || undefined, + rpcListenPort: activeRpcPort.value, + rpcSecret: activeRpcSecret.value.trim() || undefined, } } @@ -187,6 +513,31 @@ function formatSpeed(value: string): string { return `${mb.toFixed(1)} MB/s` } +function sleep(ms: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, ms) + }) +} + +function formatUnixSeconds(value: string): string { + const n = Number(value) + if (!Number.isFinite(n) || n <= 0) return '-' + return new Date(n * 1000).toLocaleString() +} + +function fileExtFromPath(path: string): string { + const idx = path.lastIndexOf('.') + if (idx < 0) return '-' + return path.slice(idx + 1).toLowerCase() || '-' +} + +function fileNameFromPath(path: string): string { + const normalized = path.replace(/\\/g, '/') + const idx = normalized.lastIndexOf('/') + if (idx < 0) return normalized + return normalized.slice(idx + 1) +} + function progress(task: Aria2Task): number { const total = Number(task.totalLength) const done = Number(task.completedLength) @@ -218,6 +569,26 @@ async function refreshTasks(silent = false) { } } +async function refreshTaskInfo(silent = false) { + if (!taskInfoVisible.value || !taskInfoTask.value) return + if (!status.value.running) return + if (taskInfoLoading.value && silent) return + + if (!silent) taskInfoLoading.value = true + try { + taskInfoDetail.value = await getAria2TaskDetail({ + rpc: rpcConfig(), + gid: taskInfoTask.value.gid, + }) + } catch (error) { + if (!silent) { + pushError(String(error)) + } + } finally { + if (!silent) taskInfoLoading.value = false + } +} + function updateRefreshTimer() { if (refreshTimer !== null) { window.clearInterval(refreshTimer) @@ -228,6 +599,7 @@ function updateRefreshTimer() { refreshTimer = window.setInterval(() => { void refreshEngineStatus() void refreshTasks(true) + void refreshTaskInfo(true) }, 3000) } @@ -249,6 +621,8 @@ async function autoStartEngine(silent = true) { maxConcurrentDownloads: maxConcurrentDownloads.value, split: split.value, }) + activeRpcPort.value = rpcPort.value + activeRpcSecret.value = rpcSecret.value.trim() saveSettingsToStorage() if (!silent) pushSuccess('aria2 engine started.') await refreshTasks() @@ -385,8 +759,172 @@ async function onCopyTaskLink(task: Aria2Task) { } } +async function copyRpcEndpoint() { + try { + await navigator.clipboard.writeText(rpcEndpointText.value) + pushSuccess('RPC URL을 복사했습니다.') + } catch (error) { + pushError(`RPC URL 복사 실패: ${error}`) + } +} + +async function copyRpcToken() { + if (!rpcTokenText.value) { + pushError('RPC 비밀이 비어 있습니다.') + return + } + try { + await navigator.clipboard.writeText(rpcTokenText.value) + pushSuccess('RPC 토큰을 복사했습니다.') + } catch (error) { + pushError(`RPC 토큰 복사 실패: ${error}`) + } +} + +async function testRpcConnection() { + rpcTestLoading.value = true + rpcTestStatus.value = 'idle' + rpcTestMessage.value = '' + try { + await listAria2Tasks({ + rpcListenPort: sanitizePort(rpcPort.value, 6800), + rpcSecret: rpcSecret.value.trim() || undefined, + }) + rpcTestStatus.value = 'ok' + rpcTestMessage.value = '연결 성공' + pushSuccess('RPC 연결 성공') + } catch (error) { + rpcTestStatus.value = 'fail' + rpcTestMessage.value = String(error) + pushError(`RPC 연결 실패: ${error}`) + } finally { + rpcTestLoading.value = false + } +} + function onShowTaskInfo(task: Aria2Task) { - pushSuccess(`gid=${task.gid} / status=${task.status} / dir=${task.dir || '-'}`) + taskInfoTask.value = task + taskInfoTab.value = 'general' + taskInfoVisible.value = true + void refreshTaskInfo() +} + +function closeTaskInfo() { + taskInfoVisible.value = false + taskInfoTask.value = null + taskInfoDetail.value = null +} + +function statusLabel(status: string | undefined): string { + if (!status) return '-' + const upper = status.toUpperCase() + if (upper === 'ACTIVE') return 'DOWNLOADING' + if (upper === 'COMPLETE') return 'SEEDING' + return upper +} + +function parsePeerClient(peerId: string): string { + const value = peerId.trim() + if (!value) return '-' + + const matched = /^-([A-Za-z0-9]{2})([0-9]{4})-/.exec(value) + if (!matched) { + return value.slice(0, 12) + } + + const prefix = matched[1] ?? '' + const versionRaw = (matched[2] ?? '').padEnd(4, '0') + const v0 = versionRaw.charAt(0) + const v1 = versionRaw.charAt(1) + const v2 = versionRaw.charAt(2) + const v3 = versionRaw.charAt(3) + const version = `${v0}.${v1}.${v2}${v3 ? `.${v3}` : ''}` + const map: Record = { + AG: 'Ares', + AR: 'Arctic', + AT: 'Artemis', + AV: 'Avicora', + AX: 'BitPump', + AZ: 'Azureus', + BC: 'BitComet', + BF: 'Bitflu', + BR: 'BitRocket', + BT: 'BT', + DE: 'Deluge', + HL: 'Halite', + KT: 'KTorrent', + LT: 'libtorrent', + MT: 'MoonlightTorrent', + qB: 'qBittorrent', + TR: 'Transmission', + UT: 'uTorrent', + UM: 'uTorrent Mac', + XL: 'Xunlei', + } + const name = map[prefix] ?? prefix + return `${name} ${version}` +} + +function countBitsInHex(hex: string): number { + let count = 0 + for (const ch of hex) { + const nibble = Number.parseInt(ch, 16) + if (!Number.isFinite(nibble) || nibble < 0) continue + count += nibble.toString(2).split('').filter((bit) => bit === '1').length + } + return count +} + +function peerPercent(peer: { bitfield: string }, detail: Aria2TaskDetail | null): string { + const bitfield = peer.bitfield?.trim() + if (!bitfield) return '-' + const havePieces = countBitsInHex(bitfield) + if (havePieces <= 0) return '0' + + const pieces = Number(detail?.numPieces ?? '0') + if (Number.isFinite(pieces) && pieces > 0) { + return `${Math.min(100, Math.round((havePieces / pieces) * 100))}` + } + + const totalBits = bitfield.length * 4 + if (totalBits <= 0) return '-' + return `${Math.min(100, Math.round((havePieces / totalBits) * 100))}` +} + +async function onTaskInfoPrimaryAction() { + const task = taskInfoActionTask.value + if (!task) return + await onTaskAction(task, taskPrimaryAction(task)) +} + +async function onTaskInfoRemove() { + const task = taskInfoActionTask.value + if (!task) return + await onTaskAction(task, task.status === 'stopped' ? 'purge' : 'remove') +} + +async function inspectAddedTask(gid: string) { + try { + await sleep(900) + const detail = await getAria2TaskDetail({ + rpc: rpcConfig(), + gid, + }) + if (detail.status === 'error') { + page.value = 'downloads' + filter.value = 'stopped' + const message = detail.errorMessage || `errorCode=${detail.errorCode || '-'}` + pushError(`작업 추가 실패: ${message}`) + return + } + if (detail.status === 'removed') { + page.value = 'downloads' + filter.value = 'stopped' + pushError('작업이 즉시 제거되었습니다. 링크/권한/RPC 설정을 확인하세요.') + } + } catch { + // ignore follow-up inspect failures + } } function openAddModal() { @@ -402,9 +940,175 @@ function openSettingsPage() { page.value = 'settings' } -function saveSettings() { +function sanitizePort(value: number, fallback: number): number { + if (!Number.isFinite(value)) return fallback + const rounded = Math.round(value) + return Math.max(1, Math.min(65535, rounded)) +} + +function normalizeLineItems(value: string): string[] { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +function trackerTextToAria2Value(value: string): string { + const lines = normalizeLineItems(value).filter((line) => !line.startsWith('#')) + return lines.join(',') +} + +function toLimitBytesPerSecond(kbPerSecond: number): string { + if (!Number.isFinite(kbPerSecond) || kbPerSecond <= 0) return '0' + return String(Math.floor(kbPerSecond * 1024)) +} + +function boolToAria(value: boolean): string { + return value ? 'true' : 'false' +} + +function randomPort(): number { + return Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025 +} + +function randomSecret(length = 24): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789' + let result = '' + for (let i = 0; i < length; i += 1) { + result += chars[Math.floor(Math.random() * chars.length)] ?? 'a' + } + return result +} + +async function syncTrackerFromSources() { + const sources = normalizeLineItems(settingTrackerSourceInput.value) + settingTrackerSources.value = sources + if (sources.length === 0) { + pushError('트래커 소스 URL을 하나 이상 입력하세요.') + return + } + + trackerSyncing.value = true + try { + const responses = await Promise.all(sources.map(async (source) => fetch(source))) + const texts = await Promise.all( + responses.map(async (response, idx) => { + if (!response.ok) { + throw new Error(`트래커 소스 응답 실패(${response.status}): ${sources[idx]}`) + } + return response.text() + }), + ) + + const merged = new Set() + texts.forEach((text) => { + text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .forEach((line) => merged.add(line)) + }) + + if (merged.size === 0) { + throw new Error('동기화된 트래커 항목이 없습니다.') + } + + settingTrackerList.value = Array.from(merged).join('\n') + settingLastSyncTrackerTime.value = Date.now() + saveSettingsToStorage() + pushSuccess(`트래커 ${merged.size}개를 동기화했습니다.`) + } catch (error) { + pushError(String(error)) + } finally { + trackerSyncing.value = false + } +} + +function checkUpdateNow() { + settingLastCheckUpdateTime.value = Date.now() saveSettingsToStorage() - pushSuccess('설정이 저장되었습니다.') + pushSuccess('업데이트 확인을 실행했습니다. (업데이터 연동은 후속 구현)') +} + +function fillUserAgentPreset(kind: 'aria2' | 'transmission' | 'chrome') { + if (kind === 'aria2') { + settingUserAgent.value = 'aria2/1.37.0' + return + } + if (kind === 'transmission') { + settingUserAgent.value = 'Transmission/4.0.6' + return + } + settingUserAgent.value = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122 Safari/537.36' +} + +function buildRuntimeGlobalOptions(): Record { + const proxyServer = settingProxyEnabled.value ? settingProxyServer.value.trim() : '' + return { + 'max-overall-upload-limit': toLimitBytesPerSecond(settingUploadLimit.value), + 'max-overall-download-limit': toLimitBytesPerSecond(settingDownloadLimit.value), + 'max-concurrent-downloads': String(Math.max(1, Math.round(maxConcurrentDownloads.value))), + split: String(Math.max(1, Math.round(split.value))), + 'all-proxy': proxyServer, + 'no-proxy': settingProxyEnabled.value ? settingProxyBypass.value.trim() : '', + 'bt-save-metadata': boolToAria(settingMagnetAsTorrent.value), + 'bt-force-encryption': boolToAria(settingBtForceEncryption.value), + 'pause-metadata': boolToAria(!settingAutoDownloadTorrentMeta.value), + 'seed-ratio': settingKeepSeeding.value ? String(Math.max(0, settingSeedRatio.value)) : '0', + 'bt-tracker': trackerTextToAria2Value(settingTrackerList.value), + 'listen-port': String(sanitizePort(settingBtListenPort.value, 21301)), + 'dht-listen-port': String(sanitizePort(settingDhtListenPort.value, 26701)), + 'enable-dht': boolToAria(settingProtocolMagnet.value), + 'enable-dht6': boolToAria(settingProtocolMagnet.value), + 'enable-peer-exchange': boolToAria(settingProtocolMagnet.value), + 'enable-upnp': boolToAria(settingEnableUpnp.value), + 'user-agent': settingUserAgent.value.trim(), + 'check-certificate': boolToAria(!settingSkipTlsVerify.value), + } +} + +async function saveSettings() { + const rpcChanged = activeRpcPort.value !== rpcPort.value || activeRpcSecret.value !== rpcSecret.value.trim() + settingTrackerSources.value = normalizeLineItems(settingTrackerSourceInput.value) + saveSettingsToStorage() + + if (rpcChanged && status.value.running) { + if (status.value.binaryPath?.startsWith('external://')) { + pushError('외부 aria2 재사용 상태에서는 RPC 포트/비밀 즉시 적용이 불가합니다. 외부 aria2를 재시작하세요.') + return + } + + try { + await stopEngine() + const restarted = await autoStartEngine(true) + if (!restarted) { + pushError('RPC 설정 적용을 위해 엔진 재시작에 실패했습니다.') + return + } + await refreshTasks() + pushSuccess('RPC 설정을 적용하기 위해 엔진을 재시작했습니다.') + } catch (error) { + pushError(`RPC 설정 반영 실패: ${error}`) + } + return + } + + if (!status.value.running) { + activeRpcPort.value = rpcPort.value + activeRpcSecret.value = rpcSecret.value.trim() + pushSuccess('설정을 저장했습니다. 엔진 시작 시 반영됩니다.') + return + } + + try { + await changeAria2GlobalOption({ + rpc: rpcConfig(), + options: buildRuntimeGlobalOptions(), + }) + pushSuccess('설정을 저장하고 즉시 적용했습니다. 일부 항목은 새 작업/재시작 후 완전 반영됩니다.') + } catch (error) { + pushError(`설정은 저장됐지만 런타임 적용에 실패했습니다: ${error}`) + } } async function pickDownloadFolder() { @@ -428,6 +1132,7 @@ async function pickDownloadFolder() { function closeAddModal() { showAddModal.value = false modalDropActive.value = false + addShowAdvanced.value = false } function openAddModalForTorrent() { @@ -483,7 +1188,7 @@ async function applyTorrentPath(path: string) { torrentFileSize.value = payload.size torrentFileExt.value = fileExt(payload.name) if (!addOut.value) { - addOut.value = payload.name.replace(/\\.torrent$/i, '') + addOut.value = payload.name.replace(/\.torrent$/i, '') } openAddModalForTorrent() } @@ -546,6 +1251,50 @@ function extractDroppedFile(event: DragEvent): File | null { return null } +function normalizeTaskUri(uri: string): string { + const trimmed = uri.trim() + if (!trimmed) return '' + + if (trimmed.startsWith('magnet:') || trimmed.startsWith('thunder:')) { + return trimmed + } + + try { + return new URL(trimmed).toString() + } catch { + if (/^(https?|ftp):\/\//i.test(trimmed)) { + return trimmed.replace(/ /g, '%20') + } + return trimmed + } +} + +function guessFileNameFromUri(uri: string): string { + try { + const parsed = new URL(uri) + const path = parsed.pathname || '' + const name = path.split('/').filter(Boolean).pop() || '' + return decodeURIComponent(name) || uri + } catch { + return uri + } +} + +function prependPendingTask(gid: string, fileName: string, uri: string) { + const exists = [...tasks.value.active, ...tasks.value.waiting, ...tasks.value.stopped].some((task) => task.gid === gid) + if (exists) return + tasks.value.active.unshift({ + gid, + status: 'active', + totalLength: '0', + completedLength: '0', + downloadSpeed: '0', + dir: downloadDir.value.trim() || '-', + fileName: fileName.trim() || '-', + uri: uri.trim(), + }) +} + function onWindowDragEnter(event: DragEvent) { event.preventDefault() appDropActive.value = true @@ -581,10 +1330,29 @@ async function onSubmitAddTask() { loadingAddTask.value = true try { + const headers = [ + addUserAgent.value.trim() ? `User-Agent: ${addUserAgent.value.trim()}` : '', + addReferer.value.trim() ? `Referer: ${addReferer.value.trim()}` : '', + addCookie.value.trim() ? `Cookie: ${addCookie.value.trim()}` : '', + addAuthorization.value.trim() ? `Authorization: ${addAuthorization.value.trim()}` : '', + ].filter((line) => line.length > 0) + + const addOptions: Record = {} + if (addProxy.value.trim()) { + addOptions['all-proxy'] = addProxy.value.trim() + } + if (addUserAgent.value.trim()) { + addOptions['user-agent'] = addUserAgent.value.trim() + } + addOptions['check-certificate'] = settingSkipTlsVerify.value ? 'false' : 'true' + if (headers.length > 0) { + addOptions.header = headers + } + if (addTab.value === 'url') { const uris = addUrl.value .split('\n') - .map((line) => line.trim()) + .map((line) => normalizeTaskUri(line)) .filter((line) => line.length > 0) if (uris.length === 0) { pushError('URL을 입력하세요.') @@ -598,8 +1366,11 @@ async function onSubmitAddTask() { out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined, dir: downloadDir.value.trim() || undefined, split: addSplit.value, + options: Object.keys(addOptions).length > 0 ? addOptions : undefined, }) gids.push(gid) + void inspectAddedTask(gid) + prependPendingTask(gid, uris.length === 1 ? addOut.value || guessFileNameFromUri(uri) : guessFileNameFromUri(uri), uri) } pushSuccess(`${gids.length}개 작업이 추가되었습니다.`) } else { @@ -613,12 +1384,27 @@ async function onSubmitAddTask() { out: addOut.value.trim() || undefined, dir: downloadDir.value.trim() || undefined, split: addSplit.value, + options: Object.keys(addOptions).length > 0 ? addOptions : undefined, }) pushSuccess(`토렌트가 추가되었습니다. gid=${gid}`) + void inspectAddedTask(gid) + prependPendingTask(gid, addOut.value || torrentFileName.value.replace(/\.torrent$/i, ''), '') + } + + if (addNavigateToDownloading.value) { + page.value = 'downloads' + filter.value = 'active' } addUrl.value = '' addOut.value = '' + addShowAdvanced.value = false + addUserAgent.value = '' + addAuthorization.value = '' + addReferer.value = '' + addCookie.value = '' + addProxy.value = '' + addNavigateToDownloading.value = true torrentBase64.value = '' torrentFileName.value = '' torrentFileExt.value = '' @@ -634,11 +1420,23 @@ async function onSubmitAddTask() { onMounted(async () => { loadSettingsFromStorage() + activeRpcPort.value = rpcPort.value + activeRpcSecret.value = rpcSecret.value.trim() await refreshEngineStatus() if (!status.value.running) { await autoStartEngine(true) } await refreshTasks() + if (status.value.running) { + try { + await changeAria2GlobalOption({ + rpc: rpcConfig(), + options: buildRuntimeGlobalOptions(), + }) + } catch { + // ignore bootstrap sync failures + } + } updateRefreshTimer() window.addEventListener('dragover', onWindowDragOver) @@ -666,6 +1464,24 @@ onMounted(async () => { } } }) + + try { + const startupDeepLinks = await getCurrentDeepLink() + await handleDeepLinkUrls(startupDeepLinks) + unlistenDeepLink = await onOpenUrl(async (urls) => { + await handleDeepLinkUrls(urls) + }) + } catch { + // ignore deep-link bootstrap failures + } + + if (externalAddPollTimer !== null) { + window.clearInterval(externalAddPollTimer) + } + externalAddPollTimer = window.setInterval(() => { + void pollExternalAddQueue() + }, 700) + void pollExternalAddQueue() }) onUnmounted(() => { @@ -682,6 +1498,21 @@ onUnmounted(() => { unlistenDragDrop() unlistenDragDrop = null } + + if (unlistenDeepLink) { + unlistenDeepLink() + unlistenDeepLink = null + } + + if (toastTimer !== null) { + window.clearTimeout(toastTimer) + toastTimer = null + } + + if (externalAddPollTimer !== null) { + window.clearInterval(externalAddPollTimer) + externalAddPollTimer = null + } }) @@ -695,11 +1526,6 @@ onUnmounted(() => { - +
+
+
자동 업데이트
+ +
+ 마지막 확인: {{ lastCheckUpdateTimeText }} + +
+
+ +
+
프록시
+ +