feat: external capture queue and modal-first add flow (v0.1.1)
This commit is contained in:
@@ -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 적용)
|
||||
|
||||
67
docs/TODO.md
67
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] 다운로드 화면을 스크린샷 기준으로 재정렬(라이트 톤, 좌측 작업 패널, 상단 아이콘 툴바)
|
||||
|
||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -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",
|
||||
|
||||
10
package.json
10
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"
|
||||
},
|
||||
|
||||
29
scripts/cleanup-stale-aria2.sh
Normal file
29
scripts/cleanup-stale-aria2.sh
Normal file
@@ -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
|
||||
115
src-tauri/Cargo.lock
generated
115
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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<String>,
|
||||
pub dir: Option<String>,
|
||||
pub split: Option<u16>,
|
||||
pub options: Option<BTreeMap<String, Value>>,
|
||||
pub position: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -46,6 +50,8 @@ pub struct Aria2AddTorrentRequest {
|
||||
pub out: Option<String>,
|
||||
pub dir: Option<String>,
|
||||
pub split: Option<u16>,
|
||||
pub options: Option<BTreeMap<String, Value>>,
|
||||
pub position: Option<u32>,
|
||||
}
|
||||
|
||||
#[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<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EngineStatusResponse {
|
||||
@@ -86,6 +106,63 @@ pub struct Aria2TaskSnapshot {
|
||||
pub stopped: Vec<Aria2TaskSummary>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub files: Vec<Aria2TaskFile>,
|
||||
pub peers: Vec<Aria2TaskPeer>,
|
||||
pub servers: Vec<Aria2TaskServer>,
|
||||
}
|
||||
|
||||
#[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<Vec<u32>, 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<u32> = text
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().parse::<u32>().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<String> = 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<String> {
|
||||
let mut args = vec![
|
||||
"--enable-rpc=true".to_string(),
|
||||
@@ -152,6 +385,7 @@ fn build_engine_args(request: &EngineStartRequest) -> Vec<String> {
|
||||
"--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<Aria2TaskSummary> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build_rpc_options(out: Option<&String>, dir: Option<&String>, split: Option<u16>) -> Value {
|
||||
fn map_task_files(status_result: &Value) -> Vec<Aria2TaskFile> {
|
||||
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::<Vec<String>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect::<Vec<Aria2TaskFile>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn map_task_peers(result: &Value) -> Vec<Aria2TaskPeer> {
|
||||
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::<Vec<Aria2TaskPeer>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn map_task_servers(result: &Value) -> Vec<Aria2TaskServer> {
|
||||
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::<Vec<Aria2TaskServer>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn map_task_trackers(status_result: &Value) -> Vec<String> {
|
||||
let mut trackers: Vec<String> = 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<u16>,
|
||||
extra: Option<&BTreeMap<String, Value>>,
|
||||
) -> 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<u
|
||||
if let Some(value) = split {
|
||||
options.insert("split".to_string(), json!(value.to_string()));
|
||||
}
|
||||
if let Some(extra_options) = extra {
|
||||
for (key, value) in extra_options {
|
||||
options.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(options)
|
||||
}
|
||||
|
||||
@@ -455,16 +811,6 @@ pub fn engine_start(
|
||||
.lock()
|
||||
.map_err(|err| format!("failed to lock engine state: {err}"))?;
|
||||
|
||||
if runtime.external_reuse {
|
||||
if let Some(port) = runtime.rpc_port {
|
||||
if is_local_port_open(port) {
|
||||
return Ok(EngineState::status(&runtime));
|
||||
}
|
||||
}
|
||||
runtime.external_reuse = false;
|
||||
runtime.rpc_port = None;
|
||||
}
|
||||
|
||||
if let Some(child) = runtime.child.as_mut() {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
@@ -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<EngineStatusResponse
|
||||
.lock()
|
||||
.map_err(|err| format!("failed to lock engine state: {err}"))?;
|
||||
|
||||
if let Some(mut child) = runtime.child.take() {
|
||||
child
|
||||
.kill()
|
||||
.map_err(|err| format!("failed to stop aria2 engine: {err}"))?;
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
runtime.external_reuse = false;
|
||||
runtime.rpc_port = None;
|
||||
runtime.started_at = None;
|
||||
stop_engine_runtime(&mut runtime)?;
|
||||
Ok(EngineState::status(&runtime))
|
||||
}
|
||||
|
||||
@@ -619,10 +948,18 @@ pub async fn aria2_add_uri(request: Aria2AddUriRequest) -> Result<String, String
|
||||
let client = Client::new();
|
||||
|
||||
let mut params = vec![json!([request.uri.trim()])];
|
||||
let options = build_rpc_options(request.out.as_ref(), request.dir.as_ref(), request.split);
|
||||
let options = build_rpc_options(
|
||||
request.out.as_ref(),
|
||||
request.dir.as_ref(),
|
||||
request.split,
|
||||
request.options.as_ref(),
|
||||
);
|
||||
if options.as_object().map(|obj| !obj.is_empty()).unwrap_or(false) {
|
||||
params.push(options);
|
||||
}
|
||||
if let Some(position) = request.position {
|
||||
params.push(json!(position));
|
||||
}
|
||||
|
||||
let result = call_aria2_rpc(&client, &request.rpc, "aria2.addUri", params).await?;
|
||||
result
|
||||
@@ -638,12 +975,24 @@ pub async fn aria2_add_torrent(request: Aria2AddTorrentRequest) -> Result<String
|
||||
}
|
||||
|
||||
let client = Client::new();
|
||||
let options = build_rpc_options(request.out.as_ref(), request.dir.as_ref(), request.split);
|
||||
let options = build_rpc_options(
|
||||
request.out.as_ref(),
|
||||
request.dir.as_ref(),
|
||||
request.split,
|
||||
request.options.as_ref(),
|
||||
);
|
||||
|
||||
let mut params = vec![json!(request.torrent_base64.trim())];
|
||||
if options.as_object().map(|obj| !obj.is_empty()).unwrap_or(false) {
|
||||
params.push(json!([]));
|
||||
params.push(options);
|
||||
if let Some(position) = request.position {
|
||||
params.push(json!(position));
|
||||
}
|
||||
} else if let Some(position) = request.position {
|
||||
params.push(json!([]));
|
||||
params.push(json!({}));
|
||||
params.push(json!(position));
|
||||
}
|
||||
|
||||
let result = call_aria2_rpc(&client, &request.rpc, "aria2.addTorrent", params).await?;
|
||||
@@ -670,6 +1019,52 @@ pub async fn aria2_list_tasks(config: Aria2RpcConfig) -> Result<Aria2TaskSnapsho
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aria2_get_task_detail(request: Aria2TaskDetailRequest) -> Result<Aria2TaskDetail, String> {
|
||||
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<String, String> {
|
||||
let gid = request.gid.trim();
|
||||
@@ -705,29 +1100,43 @@ pub async fn aria2_remove_task(request: Aria2TaskCommandRequest) -> Result<Strin
|
||||
return Err("gid is required".to_string());
|
||||
}
|
||||
let client = Client::new();
|
||||
// Download 중 항목이 즉시 지워지지 않는 케이스를 줄이기 위해 forceRemove를 우선 시도한다.
|
||||
match call_aria2_rpc(&client, &request.rpc, "aria2.forceRemove", vec![json!(gid)]).await {
|
||||
Ok(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<String, String>
|
||||
.ok_or_else(|| format!("aria2.unpauseAll returned unexpected result: {result}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aria2_change_global_option(
|
||||
request: Aria2ChangeGlobalOptionRequest,
|
||||
) -> Result<String, String> {
|
||||
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<TorrentFilePayload, String> {
|
||||
if !path.to_ascii_lowercase().ends_with(".torrent") {
|
||||
|
||||
@@ -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<String>,
|
||||
dir: Option<String>,
|
||||
referer: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
authorization: Option<String>,
|
||||
cookie: Option<String>,
|
||||
proxy: Option<String>,
|
||||
split: Option<u32>,
|
||||
}
|
||||
|
||||
fn external_add_queue_path() -> Result<PathBuf, String> {
|
||||
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<Vec<ExternalAddRequest>, 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<ExternalAddRequest> = Vec::new();
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(item) = serde_json::from_str::<ExternalAddRequest>(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::<EngineState>();
|
||||
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");
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1218
src/App.vue
1218
src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -46,12 +46,63 @@ export interface Aria2TaskSnapshot {
|
||||
stopped: Aria2Task[]
|
||||
}
|
||||
|
||||
export interface Aria2TaskDetailFile {
|
||||
path: string
|
||||
length: string
|
||||
completedLength: string
|
||||
selected: string
|
||||
uris: string[]
|
||||
}
|
||||
|
||||
export interface Aria2TaskDetailPeer {
|
||||
peerId: string
|
||||
ip: string
|
||||
port: string
|
||||
bitfield: string
|
||||
amChoking: string
|
||||
peerChoking: string
|
||||
downloadSpeed: string
|
||||
uploadSpeed: string
|
||||
}
|
||||
|
||||
export interface Aria2TaskDetailServer {
|
||||
uri: string
|
||||
currentUri: string
|
||||
downloadSpeed: string
|
||||
}
|
||||
|
||||
export interface Aria2TaskDetail {
|
||||
gid: string
|
||||
status: string
|
||||
totalLength: string
|
||||
completedLength: string
|
||||
uploadLength: string
|
||||
downloadSpeed: string
|
||||
uploadSpeed: string
|
||||
numSeeders: string
|
||||
connections: string
|
||||
pieceLength: string
|
||||
numPieces: string
|
||||
dir: string
|
||||
infoHash: string
|
||||
creationDate: string
|
||||
comment: string
|
||||
errorCode: string
|
||||
errorMessage: string
|
||||
trackers: string[]
|
||||
files: Aria2TaskDetailFile[]
|
||||
peers: Aria2TaskDetailPeer[]
|
||||
servers: Aria2TaskDetailServer[]
|
||||
}
|
||||
|
||||
export interface AddUriPayload {
|
||||
rpc: Aria2RpcConfig
|
||||
uri: string
|
||||
out?: string
|
||||
dir?: string
|
||||
split?: number
|
||||
options?: Record<string, string | string[]>
|
||||
position?: number
|
||||
}
|
||||
|
||||
export interface AddTorrentPayload {
|
||||
@@ -60,6 +111,8 @@ export interface AddTorrentPayload {
|
||||
out?: string
|
||||
dir?: string
|
||||
split?: number
|
||||
options?: Record<string, string | string[]>
|
||||
position?: number
|
||||
}
|
||||
|
||||
export interface TaskCommandPayload {
|
||||
@@ -67,12 +120,29 @@ export interface TaskCommandPayload {
|
||||
gid: string
|
||||
}
|
||||
|
||||
export interface ChangeGlobalOptionPayload {
|
||||
rpc: Aria2RpcConfig
|
||||
options: Record<string, string>
|
||||
}
|
||||
|
||||
export interface TorrentFilePayload {
|
||||
name: string
|
||||
base64: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface ExternalAddRequest {
|
||||
url: string
|
||||
out?: string
|
||||
dir?: string
|
||||
referer?: string
|
||||
userAgent?: string
|
||||
authorization?: string
|
||||
cookie?: string
|
||||
proxy?: string
|
||||
split?: number
|
||||
}
|
||||
|
||||
export async function getEngineStatus(): Promise<EngineStatus> {
|
||||
return invoke<EngineStatus>('engine_status')
|
||||
}
|
||||
@@ -93,6 +163,10 @@ export async function listAria2Tasks(config: Aria2RpcConfig): Promise<Aria2TaskS
|
||||
return invoke<Aria2TaskSnapshot>('aria2_list_tasks', { config })
|
||||
}
|
||||
|
||||
export async function getAria2TaskDetail(payload: TaskCommandPayload): Promise<Aria2TaskDetail> {
|
||||
return invoke<Aria2TaskDetail>('aria2_get_task_detail', { request: payload })
|
||||
}
|
||||
|
||||
export async function addAria2Uri(payload: AddUriPayload): Promise<string> {
|
||||
return invoke<string>('aria2_add_uri', { request: payload })
|
||||
}
|
||||
@@ -129,6 +203,18 @@ export async function resumeAllAria2(config: Aria2RpcConfig): Promise<string> {
|
||||
return invoke<string>('aria2_resume_all', { config })
|
||||
}
|
||||
|
||||
export async function changeAria2GlobalOption(payload: ChangeGlobalOptionPayload): Promise<string> {
|
||||
return invoke<string>('aria2_change_global_option', { request: payload })
|
||||
}
|
||||
|
||||
export async function openPathInFileManager(path: string): Promise<void> {
|
||||
return invoke<void>('open_path_in_file_manager', { path })
|
||||
}
|
||||
|
||||
export async function focusMainWindow(): Promise<void> {
|
||||
return invoke<void>('focus_main_window')
|
||||
}
|
||||
|
||||
export async function takeExternalAddRequests(): Promise<ExternalAddRequest[]> {
|
||||
return invoke<ExternalAddRequest[]>('take_external_add_requests')
|
||||
}
|
||||
|
||||
331
src/style.css
331
src/style.css
@@ -235,6 +235,11 @@ body {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.downloads-main {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -294,6 +299,46 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
border: 1px solid rgba(39, 180, 122, 0.3);
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 26px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 160;
|
||||
min-width: 240px;
|
||||
max-width: 420px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28);
|
||||
pointer-events: none;
|
||||
animation: toast-in 180ms ease-out;
|
||||
}
|
||||
|
||||
.app-toast.success {
|
||||
background: #1f3a2f;
|
||||
border-color: #2e7050;
|
||||
color: #c2f1da;
|
||||
}
|
||||
|
||||
.app-toast.error {
|
||||
background: #4c2a31;
|
||||
border-color: #7a434f;
|
||||
color: #ffd3da;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -421,6 +466,187 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-info-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: min(640px, 100%);
|
||||
height: 100%;
|
||||
background: #2f333a !important;
|
||||
border: 1px solid #4a4f5b !important;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
}
|
||||
|
||||
.task-info-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #4a4f5b;
|
||||
}
|
||||
|
||||
.task-info-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #d9deeb;
|
||||
}
|
||||
|
||||
.task-info-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #4a4f5b;
|
||||
}
|
||||
|
||||
.task-info-tabs button {
|
||||
min-width: 34px;
|
||||
height: 32px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #9da8be;
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.task-info-tabs button.active {
|
||||
color: #dfe3ff;
|
||||
border-color: #5b62f3;
|
||||
background: #3a3f73;
|
||||
}
|
||||
|
||||
.task-info-body {
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.task-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
gap: 10px 12px;
|
||||
align-items: center;
|
||||
font-size: 0.84rem;
|
||||
color: #d3d9e8;
|
||||
}
|
||||
|
||||
.task-info-grid .k {
|
||||
color: #aab4c8;
|
||||
}
|
||||
|
||||
.task-info-divider {
|
||||
margin: 14px 0 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #4a4f5b;
|
||||
color: #bcc6da;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.inline-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.piece-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: 3px;
|
||||
padding: 8px;
|
||||
border: 1px solid #4a4f5b;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.piece-grid span {
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
background: #555b68;
|
||||
}
|
||||
|
||||
.piece-grid span.done {
|
||||
background: #35bf57;
|
||||
}
|
||||
|
||||
.task-info-progress-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.task-info-list {
|
||||
border: 1px solid #4a4f5b;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: #2b2f36;
|
||||
min-height: 240px;
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.task-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid #4a4f5b;
|
||||
}
|
||||
|
||||
.task-info-table th,
|
||||
.task-info-table td {
|
||||
border-bottom: 1px solid #454a56;
|
||||
padding: 8px;
|
||||
color: #cfd7e8;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-info-table th {
|
||||
background: #252930;
|
||||
color: #aeb8cc;
|
||||
}
|
||||
|
||||
.task-info-files-meta {
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
color: #98a2b8;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.task-info-slide-enter-active,
|
||||
.task-info-slide-leave-active {
|
||||
transition: transform 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.task-info-slide-enter-from,
|
||||
.task-info-slide-leave-to {
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.task-info-footer {
|
||||
border-top: 1px solid #4a4f5b;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.task-info-actions-pill {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
background: #343844;
|
||||
border: 1px solid #505662;
|
||||
border-radius: 999px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.add-modal input,
|
||||
.add-modal textarea {
|
||||
height: 33px;
|
||||
@@ -622,11 +848,65 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.modal-footer {
|
||||
border-top: 1px solid #484f5b;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-advanced {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border-top: 1px solid #3f4450;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.proxy-help-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.proxy-help-row a {
|
||||
color: #a8b2c8;
|
||||
font-size: 0.74rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.proxy-help-row a:hover {
|
||||
color: #d4dcf2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.modal-advanced-toggle {
|
||||
font-size: 0.8rem;
|
||||
color: #c5cee2 !important;
|
||||
}
|
||||
|
||||
.modal-advanced-toggle input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.expand-advanced-enter-active,
|
||||
.expand-advanced-leave-active {
|
||||
transition: opacity 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.expand-advanced-enter-from,
|
||||
.expand-advanced-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
@@ -776,7 +1056,8 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.settings-group input,
|
||||
.settings-group select {
|
||||
.settings-group select,
|
||||
.settings-group textarea {
|
||||
height: 33px;
|
||||
border: 1px solid #434955;
|
||||
border-radius: 4px;
|
||||
@@ -787,12 +1068,19 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.settings-group input:focus,
|
||||
.settings-group select:focus {
|
||||
.settings-group select:focus,
|
||||
.settings-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #656cf5;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-group textarea {
|
||||
min-height: 66px;
|
||||
height: auto;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.settings-group select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
@@ -848,6 +1136,45 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
min-height: 33px;
|
||||
}
|
||||
|
||||
.inline-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-action-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.muted-line {
|
||||
color: #97a2ba;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.rpc-helper {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rpc-test-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rpc-ok {
|
||||
color: #8ad9af;
|
||||
}
|
||||
|
||||
.rpc-fail {
|
||||
color: #f0a3b1;
|
||||
}
|
||||
|
||||
.settings-placeholder {
|
||||
padding: 20px;
|
||||
background: #242831;
|
||||
|
||||
3
tools/native-host/.runtime/run-host-macos.sh
Executable file
3
tools/native-host/.runtime/run-host-macos.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec "/opt/homebrew/bin/node" "/Volumes/WD/Users/yommi/Work/tauri_projects/gdown/tools/native-host/host.mjs"
|
||||
24
tools/native-host/README.md
Normal file
24
tools/native-host/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# gdown Native Messaging Host (Step 1 MVP)
|
||||
|
||||
## Install (macOS + Chrome)
|
||||
|
||||
```bash
|
||||
cd tools/native-host
|
||||
bash install-macos.sh <EXTENSION_ID>
|
||||
```
|
||||
|
||||
If extension ID is omitted, default ID is used.
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
cd tools/native-host
|
||||
npm run smoke
|
||||
```
|
||||
|
||||
## Remove
|
||||
|
||||
```bash
|
||||
cd tools/native-host
|
||||
bash uninstall-macos.sh
|
||||
```
|
||||
182
tools/native-host/host.mjs
Normal file
182
tools/native-host/host.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { appendFile, mkdir } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
let readBuffer = Buffer.alloc(0)
|
||||
let pendingRequests = 0
|
||||
let stdinEnded = false
|
||||
|
||||
function sendMessage(payload) {
|
||||
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
const len = Buffer.alloc(4)
|
||||
len.writeUInt32LE(body.length, 0)
|
||||
process.stdout.write(len)
|
||||
process.stdout.write(body)
|
||||
}
|
||||
|
||||
function parseHeaders(lines = []) {
|
||||
const map = new Map()
|
||||
for (const line of lines) {
|
||||
const idx = line.indexOf(':')
|
||||
if (idx <= 0) continue
|
||||
const key = line.slice(0, idx).trim().toLowerCase()
|
||||
const value = line.slice(idx + 1).trim()
|
||||
if (!key || !value) continue
|
||||
map.set(key, value)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function parseCookieHeader(lines = []) {
|
||||
const headers = parseHeaders(lines)
|
||||
return headers.get('cookie') || ''
|
||||
}
|
||||
|
||||
function queueFilePath() {
|
||||
return join(homedir(), '.gdown', 'external_add_queue.jsonl')
|
||||
}
|
||||
|
||||
async function enqueueExternalAdd(payload) {
|
||||
const filePath = queueFilePath()
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await appendFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function focusGdownApp() {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
resolve({ ok: true, note: 'focus noop on non-macos in step1 host' })
|
||||
return
|
||||
}
|
||||
|
||||
const attempts = [
|
||||
['osascript', ['-e', 'tell application id "com.tauri.dev" to activate']],
|
||||
['osascript', ['-e', 'tell application "gdown" to activate']],
|
||||
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "gdown" to true']],
|
||||
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "Gdown" to true']],
|
||||
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "app" to true']],
|
||||
['open', ['-b', 'com.tauri.dev']],
|
||||
['open', ['-a', 'gdown']],
|
||||
]
|
||||
|
||||
const run = (index) => {
|
||||
if (index >= attempts.length) {
|
||||
resolve({ ok: false, message: 'all focus strategies failed' })
|
||||
return
|
||||
}
|
||||
|
||||
const [bin, args] = attempts[index]
|
||||
execFile(bin, args, (error) => {
|
||||
if (!error) {
|
||||
resolve({ ok: true, strategy: `${bin} ${args.join(' ')}` })
|
||||
return
|
||||
}
|
||||
run(index + 1)
|
||||
})
|
||||
}
|
||||
|
||||
run(0)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleRequest(message) {
|
||||
const action = String(message?.action || '').trim()
|
||||
|
||||
if (action === 'ping') {
|
||||
return {
|
||||
ok: true,
|
||||
version: '0.1.0',
|
||||
host: 'org.gdown.nativehost',
|
||||
capabilities: ['ping', 'addUri', 'focus'],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'addUri') {
|
||||
const url = String(message?.url || '').trim()
|
||||
if (!url) return { ok: false, error: 'url is required' }
|
||||
const referer = String(message?.referer || '').trim()
|
||||
const userAgent = String(message?.userAgent || '').trim()
|
||||
const out = String(message?.out || '').trim()
|
||||
const dir = String(message?.dir || '').trim()
|
||||
const cookie = String(message?.cookie || '').trim()
|
||||
const authorization = String(message?.authorization || '').trim()
|
||||
const proxy = String(message?.proxy || '').trim()
|
||||
const split = Number(message?.split || 0)
|
||||
|
||||
const parsedCookie = parseCookieHeader(Array.isArray(message?.headers) ? message.headers : [])
|
||||
const cookieValue = cookie || parsedCookie
|
||||
|
||||
await enqueueExternalAdd({
|
||||
url,
|
||||
referer: referer || undefined,
|
||||
userAgent: userAgent || undefined,
|
||||
out: out || undefined,
|
||||
dir: dir || undefined,
|
||||
cookie: cookieValue || undefined,
|
||||
authorization: authorization || undefined,
|
||||
proxy: proxy || undefined,
|
||||
split: Number.isFinite(split) && split > 0 ? Math.round(split) : undefined,
|
||||
})
|
||||
await focusGdownApp()
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
pending: true,
|
||||
mode: 'prompt',
|
||||
requestId: `pending-${Date.now()}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'focus') {
|
||||
return focusGdownApp()
|
||||
}
|
||||
|
||||
return { ok: false, error: `unknown action: ${action || '(empty)'}` }
|
||||
}
|
||||
|
||||
async function drainBuffer() {
|
||||
while (readBuffer.length >= 4) {
|
||||
const bodyLength = readBuffer.readUInt32LE(0)
|
||||
if (readBuffer.length < 4 + bodyLength) return
|
||||
const body = readBuffer.subarray(4, 4 + bodyLength)
|
||||
readBuffer = readBuffer.subarray(4 + bodyLength)
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(body.toString('utf8'))
|
||||
} catch (error) {
|
||||
sendMessage({ ok: false, error: `invalid JSON payload: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
pendingRequests += 1
|
||||
const result = await handleRequest(payload)
|
||||
sendMessage(result)
|
||||
} catch (error) {
|
||||
sendMessage({ ok: false, error: String(error) })
|
||||
} finally {
|
||||
pendingRequests -= 1
|
||||
if (stdinEnded && pendingRequests === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on('data', async (chunk) => {
|
||||
readBuffer = Buffer.concat([readBuffer, chunk])
|
||||
await drainBuffer()
|
||||
})
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
stdinEnded = true
|
||||
if (pendingRequests === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Emit one line for manual shell smoke visibility (not used by native messaging framing).
|
||||
process.stderr.write(`[native-host] started pid=${process.pid}\n`)
|
||||
41
tools/native-host/install-macos.sh
Executable file
41
tools/native-host/install-macos.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
EXTENSION_ID="${1:-alaohbbicffclloghmknhlmfdbobcigc}"
|
||||
HOST_NAME="org.gdown.nativehost"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RUNNER_PATH="$SCRIPT_DIR/.runtime/run-host-macos.sh"
|
||||
TEMPLATE_PATH="$SCRIPT_DIR/manifest/${HOST_NAME}.json.template"
|
||||
CHROME_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
||||
OUT_PATH="$CHROME_DIR/${HOST_NAME}.json"
|
||||
NODE_PATH="$(command -v node || true)"
|
||||
|
||||
if [[ ! -f "$TEMPLATE_PATH" ]]; then
|
||||
echo "template not found: $TEMPLATE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$NODE_PATH" ]]; then
|
||||
echo "node not found in current shell PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$CHROME_DIR"
|
||||
mkdir -p "$SCRIPT_DIR/.runtime"
|
||||
|
||||
cat > "$RUNNER_PATH" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec "$NODE_PATH" "$SCRIPT_DIR/host.mjs"
|
||||
EOF
|
||||
|
||||
chmod +x "$RUNNER_PATH"
|
||||
|
||||
sed \
|
||||
-e "s|__ABSOLUTE_HOST_PATH__|$RUNNER_PATH|g" \
|
||||
-e "s|__EXTENSION_ID__|$EXTENSION_ID|g" \
|
||||
"$TEMPLATE_PATH" > "$OUT_PATH"
|
||||
|
||||
echo "installed: $OUT_PATH"
|
||||
echo "extension id: $EXTENSION_ID"
|
||||
echo "node path: $NODE_PATH"
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "org.gdown.nativehost",
|
||||
"description": "gdown Native Messaging Host",
|
||||
"path": "__ABSOLUTE_HOST_PATH__",
|
||||
"type": "stdio",
|
||||
"allowed_origins": [
|
||||
"chrome-extension://__EXTENSION_ID__/"
|
||||
]
|
||||
}
|
||||
13
tools/native-host/package.json
Normal file
13
tools/native-host/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "gdown-native-host",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24 <25"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node ./host.mjs",
|
||||
"smoke": "node ./smoke.mjs"
|
||||
}
|
||||
}
|
||||
5
tools/native-host/run-host.sh
Executable file
5
tools/native-host/run-host.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec node "$SCRIPT_DIR/host.mjs"
|
||||
41
tools/native-host/smoke.mjs
Normal file
41
tools/native-host/smoke.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
function encodeMessage(payload) {
|
||||
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
const len = Buffer.alloc(4)
|
||||
len.writeUInt32LE(body.length, 0)
|
||||
return Buffer.concat([len, body])
|
||||
}
|
||||
|
||||
function decodeMessages(buffer) {
|
||||
const messages = []
|
||||
let offset = 0
|
||||
while (offset + 4 <= buffer.length) {
|
||||
const len = buffer.readUInt32LE(offset)
|
||||
if (offset + 4 + len > buffer.length) break
|
||||
const body = buffer.subarray(offset + 4, offset + 4 + len)
|
||||
messages.push(JSON.parse(body.toString('utf8')))
|
||||
offset += 4 + len
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, ['host.mjs'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
child.stdout.on('data', (chunk) => chunks.push(chunk))
|
||||
|
||||
child.stdin.write(encodeMessage({ action: 'ping' }))
|
||||
setTimeout(() => {
|
||||
child.stdin.end()
|
||||
}, 120)
|
||||
|
||||
child.on('exit', () => {
|
||||
const out = Buffer.concat(chunks)
|
||||
const messages = decodeMessages(out)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(messages, null, 2))
|
||||
})
|
||||
13
tools/native-host/uninstall-macos.sh
Executable file
13
tools/native-host/uninstall-macos.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST_NAME="org.gdown.nativehost"
|
||||
CHROME_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
||||
OUT_PATH="$CHROME_DIR/${HOST_NAME}.json"
|
||||
|
||||
if [[ -f "$OUT_PATH" ]]; then
|
||||
rm -f "$OUT_PATH"
|
||||
echo "removed: $OUT_PATH"
|
||||
else
|
||||
echo "not found: $OUT_PATH"
|
||||
fi
|
||||
Reference in New Issue
Block a user