diff --git a/docs/PORTING_PLAN.md b/docs/PORTING_PLAN.md index f63e3f6..f49f837 100644 --- a/docs/PORTING_PLAN.md +++ b/docs/PORTING_PLAN.md @@ -25,17 +25,24 @@ - [x] Tauri + Vue 프로젝트 초기화 - [x] Rust command: `engine_start`, `engine_stop`, `engine_status` - [x] Vue 대시보드에서 엔진 제어 UI 연결 -- [ ] aria2 번들 바이너리 경로 자동 탐지 (`resources/engine/*`) -- [ ] 에러 메시지 분류(파일 없음, 권한 오류, 포트 충돌) +- [x] aria2 바이너리 경로 자동 탐지 커맨드 추가 (`detect_aria2_binary`) +- [x] 시작 실패 에러 분류(파일 없음, 권한 오류, 즉시 종료) +- [x] aria2 번들 바이너리 리소스 구조 확정 (`src-tauri/resources/engine/*`) +- [~] 실배포 번들 검증 (플랫폼별 smoke test 진행 중) ### Phase 2: 다운로드 RPC/큐 -- [ ] aria2 JSON-RPC client 계층 구현 (Rust 또는 Vue 중 1안 선택) -- [ ] 작업 추가/중지/재시도/삭제 -- [ ] 작업 목록/속도/진행률 실시간 갱신 -- [ ] Magnet/Torrent 입력 파이프라인 +- [x] aria2 JSON-RPC client 계층 구현 (Rust command 래퍼) +- [x] 작업 추가 (URI/Torrent) +- [x] 작업 목록/속도/진행률 갱신 +- [x] 작업 제어(개별 pause/resume/remove, 전체 pause/resume) +- [~] Magnet/Torrent 입력 파이프라인 (입력/추가는 완료, 메타데이터 UX 개선 필요) +- [ ] 작업 상세 패널(파일/피어/트래커/활동) 구현 +- [ ] 선택 기반 배치 액션(다중 선택, 일괄 정지/재개/삭제) ### Phase 3: 설정/세션/마이그레이션 -- [ ] 설정 저장소 도입 (다운로드 폴더, 동시작업 수, 속도 제한 등) +- [~] 설정 저장소 도입 (다운로드 폴더, 동시작업 수, 속도 제한 등) + - [x] 기본 실행 설정(localStorage) 저장/복원 + - [ ] aria2 글로벌 옵션과 완전 동기화 - [ ] 세션 파일 관리(종료 시 저장, 시작 시 복구) - [ ] Motrix 설정 키 매핑표 작성 및 자동 마이그레이션 도구 @@ -55,13 +62,32 @@ - Electron API 차이: 기능별 대체표를 먼저 만들고 Tauri plugin으로 대응 - 설정 호환성: 기존 키를 그대로 유지하지 않고 매핑 테이블로 이관 -## 6. 현재 구현 상태 (2026-02-23) +## 6. 현재 구현 상태 (2026-02-24) - 완료: + - `scripts/version-bump.sh`: 빌드 전 patch 버전 자동 증가 훅 추가 + - `scripts/sync-aria2-from-motrix.sh`: Motrix extra 엔진 리소스 동기화 + - `src-tauri/resources/engine`: Motrix 기반 번들 aria2 바이너리 포함 + - `src-tauri/src/engine.rs`: RPC 포트 점유 시 기존 aria2 인스턴스 재사용 + - `@tauri-apps/plugin-dialog` 연동: 기본 저장 폴더 네이티브 선택 구현 + - `src/App.vue`: 엔진 제어를 수동 버튼 방식에서 자동 관리(Motrix 스타일)로 전환 + - `src/App.vue` + `src/style.css`: 메인 다운로드 화면의 엔진 하단 패널 제거(자동 엔진 관리 기반으로 UI 단순화) + - `src/App.vue`: 저장 폴더 선택 버튼 상호작용 안정화(label 중첩 제거) + - `src/style.css`: 설정 드롭다운(select) 플랫 스타일 적용 + - `src-tauri/capabilities/default.json`: dialog open 권한 추가 + - `src-tauri/tauri.conf.json`: 초기 창 크기 확대 및 `main` 라벨 명시 + - `src-tauri/src/engine.rs`: 삭제 동작 안정화(`aria2.remove` 실패 시 `aria2.removeDownloadResult` 폴백, 이미 삭제된 GID idempotent 처리) + - `src/App.vue` + `src/style.css`: 다운로드 리스트를 테이블에서 Motrix형 카드 + 아이콘 액션 버튼 UI로 재구성 + - `src/App.vue` + `src/style.css`: 다운로드 화면 레이아웃을 스크린샷 기준(라이트 톤, 좌측 작업 패널, 상단 아이콘 툴바)으로 재정렬 + - `src-tauri/src/engine.rs` + `src/lib/engineApi.ts`: 파일관리자에서 경로 열기 커맨드(`open_path_in_file_manager`) 추가 + - `src-tauri/src/engine.rs`: task summary에 `uri` 노출 추가(링크 복사용) + - `src/App.vue`: Motrix `TaskActions`/`TaskItemActions` 기능 매핑에 맞춘 상단/항목 아이콘 동작 연결 - `src-tauri/src/engine.rs`: aria2 프로세스 시작/중지/상태 조회 + - `src-tauri/src/engine.rs`: 바이너리 자동 탐지 + 에러 분류 + 작업 제어 RPC 커맨드 - `src-tauri/src/lib.rs`: Tauri invoke handler 연결 - - `src/lib/engineApi.ts`: 프런트 command 호출 래퍼 - - `src/App.vue`: 엔진 제어 UI + - `src/lib/engineApi.ts`: 프런트 command 호출 래퍼(엔진 + 작업 제어) + - `src/App.vue`: Motrix 스타일 사이드바/목록/추가 모달/액션 버튼 + - `src/style.css`: 작업 액션 UI 스타일 보강 - 다음 우선순위: - 1. aria2 바이너리 경로 자동 탐지 + 리소스 번들 구조 확정 - 2. JSON-RPC 기반 다운로드 목록 API 구현 - 3. 설정 저장소 도입 + 1. Motrix `Task Detail` 동등 기능(파일/피어/트래커/활동) 구현 + 2. 설정 저장소 도입(local persist + aria2 global option 적용) + 3. 선택 기반 배치 작업 액션 및 리스트 인터랙션 개선 diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..1020c8c --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,61 @@ +# gdown Porting TODO (Motrix 기준) + +마지막 업데이트: 2026-02-24 + +## 규칙 +- 모든 기능은 `/Users/A/Work/Motrix` 기준으로 비교/포팅 +- 작업 후 반드시 이 파일과 `PORTING_PLAN.md` 동시 업데이트 + +## 현재 진행률 +- 전체: 약 30% +- 엔진/RPC: 60%+ +- UI 동등성: 35~40% +- 고급 기능: 10~20% + +## In Progress +- [ ] 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] Motrix `TaskActions.vue` / `TaskItemActions.vue` 분석 기반 아이콘 기능 매핑 적용 +- [x] 항목 아이콘 기능 확장: 폴더 열기(네이티브 파일관리자), 링크 복사(URI 우선), 정보 표시 +- [x] 다운로드 화면을 스크린샷 기준으로 재정렬(라이트 톤, 좌측 작업 패널, 상단 아이콘 툴바) +- [x] 다운로드 리스트를 Motrix 톤의 카드형 레이아웃 + 아이콘 액션 버튼(재개/일시정지/삭제)으로 리디자인 +- [x] 삭제 시 aria2 상태 경합 보완: `remove` 실패 시 `removeDownloadResult` 폴백 + 이미 삭제된 GID는 성공 처리 +- [x] dialog 권한(`dialog:allow-open`) 추가로 저장 폴더 선택 버튼 무반응 이슈 보완 +- [x] Tauri 초기 창 크기 확대(1280x860, 최소 1080x720) +- [x] 저장 폴더 탐색 버튼 클릭 전달 구조 수정 (label 중첩 제거 + 클릭 이벤트 안정화) +- [x] 설정 페이지 드롭다운(select) 플랫 스타일 적용 +- [x] 메인 다운로드 하단 엔진 패널 제거 (자동 엔진 관리 UX에 맞게 단일 리스트 집중형 레이아웃 적용) +- [x] 기본 폴더 네이티브 선택 다이얼로그 구현 (`@tauri-apps/plugin-dialog`) +- [x] 설정 페이지 여백/레이아웃을 Motrix 스크린샷 톤으로 재조정 +- [x] 설정 페이지 UI를 Motrix 스타일의 컴팩트 밀도 중심으로 재조정 +- [x] RPC 포트 점유 시 기존 aria2 엔진 재사용 로직 추가 +- [x] Motrix extra 기반 aria2 번들 리소스 도입 (`src-tauri/resources/engine`) +- [x] Motrix -> gdown 엔진 리소스 동기화 스크립트 추가 (`npm run sync:aria2`) +- [x] 엔진 수동 버튼 제거, Motrix 스타일 자동 엔진 관리로 전환 +- [x] 빌드 시 패치 버전 자동 증가 훅 추가 (`scripts/version-bump.sh`, `tauri:build`) +- [x] 엔진 시작/중지/상태 조회 +- [x] URI/Torrent 추가 +- [x] 토렌트 드래그&드롭 +- [x] 작업 목록(Active/Waiting/Stopped) 표시 +- [x] 개별 작업 pause/resume/remove +- [x] 전체 pause/resume +- [x] aria2 바이너리 자동 탐지 + 시작 오류 분류 +- [x] 기본 실행 설정 localStorage 저장/복원 diff --git a/package-lock.json b/package-lock.json index 07a4c35..058628e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-dialog": "^2.4.2", "vue": "^3.5.25" }, "devDependencies": { @@ -19,6 +20,9 @@ "typescript": "~5.9.3", "vite": "^7.3.1", "vue-tsc": "^3.1.5" + }, + "engines": { + "node": ">=24 <25" } }, "node_modules/@babel/helper-string-parser": { @@ -1099,6 +1103,15 @@ "node": ">= 10" } }, + "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", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index 9993247..a439424 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,13 @@ "build": "vue-tsc -b && vite build", "preview": "vite preview", "tauri:dev": "tauri dev", - "tauri:build": "tauri build" + "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" }, "dependencies": { "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-dialog": "^2.6.0", "vue": "^3.5.25" }, "devDependencies": { diff --git a/scripts/sync-aria2-from-motrix.sh b/scripts/sync-aria2-from-motrix.sh new file mode 100755 index 0000000..c5b6818 --- /dev/null +++ b/scripts/sync-aria2-from-motrix.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +MOTRIX_DIR="${1:-/Users/A/Work/Motrix}" +SRC_BASE="$MOTRIX_DIR/extra" +DST_BASE="$ROOT_DIR/src-tauri/resources/engine" + +if [ ! -d "$SRC_BASE" ]; then + echo "[sync-aria2] Motrix extra directory not found: $SRC_BASE" + exit 1 +fi + +rm -rf "$DST_BASE" +mkdir -p "$DST_BASE" + +copy_engine_dir() { + local src="$1" + local dst="$2" + if [ -d "$src" ]; then + mkdir -p "$dst" + cp "$src"/aria2c* "$dst"/ 2>/dev/null || true + cp "$src"/aria2.conf "$dst"/ 2>/dev/null || true + echo "[sync-aria2] copied $src -> $dst" + fi +} + +# macOS +copy_engine_dir "$SRC_BASE/darwin/arm64/engine" "$DST_BASE/darwin/arm64" +copy_engine_dir "$SRC_BASE/darwin/x64/engine" "$DST_BASE/darwin/x64" + +# Linux aliases +copy_engine_dir "$SRC_BASE/linux/x64/engine" "$DST_BASE/linux/x64" +copy_engine_dir "$SRC_BASE/linux/arm64/engine" "$DST_BASE/linux/arm64" +copy_engine_dir "$SRC_BASE/linux/armv7l/engine" "$DST_BASE/linux/armv7l" + +# Windows +copy_engine_dir "$SRC_BASE/win32/x64/engine" "$DST_BASE/win32/x64" +copy_engine_dir "$SRC_BASE/win32/ia32/engine" "$DST_BASE/win32/ia32" + +echo "[sync-aria2] done" diff --git a/scripts/version-bump.sh b/scripts/version-bump.sh new file mode 100755 index 0000000..c5b6996 --- /dev/null +++ b/scripts/version-bump.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +TAURI_CONF="src-tauri/tauri.conf.json" +PKG_JSON="package.json" +CARGO_TOML="src-tauri/Cargo.toml" + +if [ ! -f "$TAURI_CONF" ] || [ ! -f "$PKG_JSON" ] || [ ! -f "$CARGO_TOML" ]; then + echo "[version-bump] required files are missing" + exit 1 +fi + +CURRENT_VERSION="$(node -e "const fs=require('fs');const j=JSON.parse(fs.readFileSync('$TAURI_CONF','utf8'));process.stdout.write(String(j.version||''));")" + +if ! echo "$CURRENT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "[version-bump] invalid version format in $TAURI_CONF: $CURRENT_VERSION" + exit 1 +fi + +IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" +NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + +echo "[version-bump] $CURRENT_VERSION -> $NEW_VERSION" + +node -e " +const fs = require('fs'); +const files = ['$PKG_JSON', '$TAURI_CONF']; +for (const file of files) { + const json = JSON.parse(fs.readFileSync(file, 'utf8')); + json.version = '$NEW_VERSION'; + fs.writeFileSync(file, JSON.stringify(json, null, 2) + '\n'); +} +" + +awk -v new_ver="$NEW_VERSION" ' +BEGIN { in_package=0; done=0 } +/^\[package\]/ { in_package=1; print; next } +/^\[/ { + if ($0 != "[package]") in_package=0 +} +{ + if (in_package && !done && $1 == "version") { + sub(/"[^"]+"/, "\"" new_ver "\"") + done=1 + } + print +} +' "$CARGO_TOML" > "$CARGO_TOML.tmp" +mv "$CARGO_TOML.tmp" "$CARGO_TOML" + +echo "[version-bump] updated package.json / tauri.conf.json / Cargo.toml" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0287a76..2ded603 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -86,6 +86,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-log", ] @@ -668,6 +669,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.11.0", + "block2", + "libc", "objc2", ] @@ -2955,6 +2958,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3760,6 +3787,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-log" version = "2.8.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 60bacbf..75329f5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,5 +23,6 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.10.0", features = [] } tauri-plugin-log = "2" +tauri-plugin-dialog = "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 c135d7f..5b5e8de 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "main" ], "permissions": [ - "core:default" + "core:default", + "dialog:allow-open" ] } diff --git a/src-tauri/resources/engine/darwin/arm64/aria2.conf b/src-tauri/resources/engine/darwin/arm64/aria2.conf new file mode 100644 index 0000000..932dd75 --- /dev/null +++ b/src-tauri/resources/engine/darwin/arm64/aria2.conf @@ -0,0 +1,91 @@ +############################### +# Motrix macOS Aria2 config file +# +# @see https://aria2.github.io/manual/en/html/aria2c.html +# +############################### + + +################ RPC ################ +# Enable JSON-RPC/XML-RPC server. +enable-rpc=true +# Add Access-Control-Allow-Origin header field with value * to the RPC response. +rpc-allow-origin-all=true +# Listen incoming JSON-RPC/XML-RPC requests on all network interfaces. +rpc-listen-all=true + + +################ File system ################ +# Save a control file(*.aria2) every SEC seconds. +auto-save-interval=10 +# Enable disk cache. +disk-cache=64M +# Specify file allocation method. +file-allocation=none +# No file allocation is made for files whose size is smaller than SIZE +no-file-allocation-limit=64M +# Save error/unfinished downloads to a file specified by --save-session option every SEC seconds. +save-session-interval=10 + + +################ Task ################ +# Exclude seed only downloads when counting concurrent active downloads +bt-detach-seed-only=true +# Verify the peer using certificates specified in --ca-certificate option. +check-certificate=false +# If aria2 receives "file not found" status from the remote HTTP/FTP servers NUM times +# without getting a single byte, then force the download to fail. +max-file-not-found=10 +# Set number of tries. +max-tries=0 +# Set the seconds to wait between retries. When SEC > 0, aria2 will retry downloads when the HTTP server returns a 503 response. +retry-wait=10 +# Set the connect timeout in seconds to establish connection to HTTP/FTP/proxy server. After the connection is established, this option makes no effect and --timeout option is used instead. +connect-timeout=10 +# Set timeout in seconds. +timeout=10 +# aria2 does not split less than 2*SIZE byte range. +min-split-size=1M +# Send Accept: deflate, gzip request header. +http-accept-gzip=true +# Retrieve timestamp of the remote file from the remote HTTP/FTP server and if it is available, apply it to the local file. +remote-time=true +# Set interval in seconds to output download progress summary. Setting 0 suppresses the output. +summary-interval=0 +# Handle quoted string in Content-Disposition header as UTF-8 instead of ISO-8859-1, for example, the filename parameter, but not the extended version filename*. +content-disposition-default-utf8=true + + +################ BT Task ################ +# Enable Local Peer Discovery. +bt-enable-lpd=true +# Requires BitTorrent message payload encryption with arc4. +# bt-force-encryption=true +# If true is given, after hash check using --check-integrity option and file is complete, continue to seed file. +bt-hash-check-seed=true +# Specify the maximum number of peers per torrent. +bt-max-peers=128 +# Try to download first and last pieces of each file first. This is useful for previewing files. +bt-prioritize-piece=head +# Removes the unselected files when download is completed in BitTorrent. +bt-remove-unselected-file=true +# Seed previously downloaded files without verifying piece hashes. +bt-seed-unverified=false +# Set the connect timeout in seconds to establish connection to tracker. After the connection is established, this option makes no effect and --bt-tracker-timeout option is used instead. +bt-tracker-connect-timeout=10 +# Set timeout in seconds. +bt-tracker-timeout=10 +# Set host and port as an entry point to IPv4 DHT network. +dht-entry-point=dht.transmissionbt.com:6881 +# Set host and port as an entry point to IPv6 DHT network. +dht-entry-point6=dht.transmissionbt.com:6881 +# Enable IPv4 DHT functionality. It also enables UDP tracker support. +enable-dht=true +# Enable IPv6 DHT functionality. +enable-dht6=true +# Enable Peer Exchange extension. +enable-peer-exchange=true +# Specify the string used during the bitorrent extended handshake for the peer's client version. +peer-agent=Transmission/3.00 +# Specify the prefix of peer ID. +peer-id-prefix=-TR3000- diff --git a/src-tauri/resources/engine/darwin/arm64/aria2c b/src-tauri/resources/engine/darwin/arm64/aria2c new file mode 100755 index 0000000..f4a2740 Binary files /dev/null and b/src-tauri/resources/engine/darwin/arm64/aria2c differ diff --git a/src-tauri/resources/engine/darwin/x64/aria2.conf b/src-tauri/resources/engine/darwin/x64/aria2.conf new file mode 100644 index 0000000..932dd75 --- /dev/null +++ b/src-tauri/resources/engine/darwin/x64/aria2.conf @@ -0,0 +1,91 @@ +############################### +# Motrix macOS Aria2 config file +# +# @see https://aria2.github.io/manual/en/html/aria2c.html +# +############################### + + +################ RPC ################ +# Enable JSON-RPC/XML-RPC server. +enable-rpc=true +# Add Access-Control-Allow-Origin header field with value * to the RPC response. +rpc-allow-origin-all=true +# Listen incoming JSON-RPC/XML-RPC requests on all network interfaces. +rpc-listen-all=true + + +################ File system ################ +# Save a control file(*.aria2) every SEC seconds. +auto-save-interval=10 +# Enable disk cache. +disk-cache=64M +# Specify file allocation method. +file-allocation=none +# No file allocation is made for files whose size is smaller than SIZE +no-file-allocation-limit=64M +# Save error/unfinished downloads to a file specified by --save-session option every SEC seconds. +save-session-interval=10 + + +################ Task ################ +# Exclude seed only downloads when counting concurrent active downloads +bt-detach-seed-only=true +# Verify the peer using certificates specified in --ca-certificate option. +check-certificate=false +# If aria2 receives "file not found" status from the remote HTTP/FTP servers NUM times +# without getting a single byte, then force the download to fail. +max-file-not-found=10 +# Set number of tries. +max-tries=0 +# Set the seconds to wait between retries. When SEC > 0, aria2 will retry downloads when the HTTP server returns a 503 response. +retry-wait=10 +# Set the connect timeout in seconds to establish connection to HTTP/FTP/proxy server. After the connection is established, this option makes no effect and --timeout option is used instead. +connect-timeout=10 +# Set timeout in seconds. +timeout=10 +# aria2 does not split less than 2*SIZE byte range. +min-split-size=1M +# Send Accept: deflate, gzip request header. +http-accept-gzip=true +# Retrieve timestamp of the remote file from the remote HTTP/FTP server and if it is available, apply it to the local file. +remote-time=true +# Set interval in seconds to output download progress summary. Setting 0 suppresses the output. +summary-interval=0 +# Handle quoted string in Content-Disposition header as UTF-8 instead of ISO-8859-1, for example, the filename parameter, but not the extended version filename*. +content-disposition-default-utf8=true + + +################ BT Task ################ +# Enable Local Peer Discovery. +bt-enable-lpd=true +# Requires BitTorrent message payload encryption with arc4. +# bt-force-encryption=true +# If true is given, after hash check using --check-integrity option and file is complete, continue to seed file. +bt-hash-check-seed=true +# Specify the maximum number of peers per torrent. +bt-max-peers=128 +# Try to download first and last pieces of each file first. This is useful for previewing files. +bt-prioritize-piece=head +# Removes the unselected files when download is completed in BitTorrent. +bt-remove-unselected-file=true +# Seed previously downloaded files without verifying piece hashes. +bt-seed-unverified=false +# Set the connect timeout in seconds to establish connection to tracker. After the connection is established, this option makes no effect and --bt-tracker-timeout option is used instead. +bt-tracker-connect-timeout=10 +# Set timeout in seconds. +bt-tracker-timeout=10 +# Set host and port as an entry point to IPv4 DHT network. +dht-entry-point=dht.transmissionbt.com:6881 +# Set host and port as an entry point to IPv6 DHT network. +dht-entry-point6=dht.transmissionbt.com:6881 +# Enable IPv4 DHT functionality. It also enables UDP tracker support. +enable-dht=true +# Enable IPv6 DHT functionality. +enable-dht6=true +# Enable Peer Exchange extension. +enable-peer-exchange=true +# Specify the string used during the bitorrent extended handshake for the peer's client version. +peer-agent=Transmission/3.00 +# Specify the prefix of peer ID. +peer-id-prefix=-TR3000- diff --git a/src-tauri/resources/engine/darwin/x64/aria2c b/src-tauri/resources/engine/darwin/x64/aria2c new file mode 100755 index 0000000..b0b1cab Binary files /dev/null and b/src-tauri/resources/engine/darwin/x64/aria2c differ diff --git a/src-tauri/resources/engine/linux/arm64/aria2.conf b/src-tauri/resources/engine/linux/arm64/aria2.conf new file mode 100644 index 0000000..2dd6af5 --- /dev/null +++ b/src-tauri/resources/engine/linux/arm64/aria2.conf @@ -0,0 +1,91 @@ +############################### +# Motrix Linux Aria2 config file +# +# @see https://aria2.github.io/manual/en/html/aria2c.html +# +############################### + + +################ RPC ################ +# Enable JSON-RPC/XML-RPC server. +enable-rpc=true +# Add Access-Control-Allow-Origin header field with value * to the RPC response. +rpc-allow-origin-all=true +# Listen incoming JSON-RPC/XML-RPC requests on all network interfaces. +rpc-listen-all=true + + +################ File system ################ +# Save a control file(*.aria2) every SEC seconds. +auto-save-interval=10 +# Enable disk cache. +disk-cache=64M +# Specify file allocation method. +file-allocation=trunc +# No file allocation is made for files whose size is smaller than SIZE +no-file-allocation-limit=64M +# Save error/unfinished downloads to a file specified by --save-session option every SEC seconds. +save-session-interval=10 + + +################ Task ################ +# Exclude seed only downloads when counting concurrent active downloads +bt-detach-seed-only=true +# Verify the peer using certificates specified in --ca-certificate option. +check-certificate=false +# If aria2 receives "file not found" status from the remote HTTP/FTP servers NUM times +# without getting a single byte, then force the download to fail. +max-file-not-found=10 +# Set number of tries. +max-tries=0 +# Set the seconds to wait between retries. When SEC > 0, aria2 will retry downloads when the HTTP server returns a 503 response. +retry-wait=10 +# Set the connect timeout in seconds to establish connection to HTTP/FTP/proxy server. After the connection is established, this option makes no effect and --timeout option is used instead. +connect-timeout=10 +# Set timeout in seconds. +timeout=10 +# aria2 does not split less than 2*SIZE byte range. +min-split-size=1M +# Send Accept: deflate, gzip request header. +http-accept-gzip=true +# Retrieve timestamp of the remote file from the remote HTTP/FTP server and if it is available, apply it to the local file. +remote-time=true +# Set interval in seconds to output download progress summary. Setting 0 suppresses the output. +summary-interval=0 +# Handle quoted string in Content-Disposition header as UTF-8 instead of ISO-8859-1, for example, the filename parameter, but not the extended version filename*. +content-disposition-default-utf8=true + + +################ BT Task ################ +# Enable Local Peer Discovery. +bt-enable-lpd=true +# Requires BitTorrent message payload encryption with arc4. +# bt-force-encryption=true +# If true is given, after hash check using --check-integrity option and file is complete, continue to seed file. +bt-hash-check-seed=true +# Specify the maximum number of peers per torrent. +bt-max-peers=128 +# Try to download first and last pieces of each file first. This is useful for previewing files. +bt-prioritize-piece=head +# Removes the unselected files when download is completed in BitTorrent. +bt-remove-unselected-file=true +# Seed previously downloaded files without verifying piece hashes. +bt-seed-unverified=false +# Set the connect timeout in seconds to establish connection to tracker. After the connection is established, this option makes no effect and --bt-tracker-timeout option is used instead. +bt-tracker-connect-timeout=10 +# Set timeout in seconds. +bt-tracker-timeout=10 +# Set host and port as an entry point to IPv4 DHT network. +dht-entry-point=dht.transmissionbt.com:6881 +# Set host and port as an entry point to IPv6 DHT network. +dht-entry-point6=dht.transmissionbt.com:6881 +# Enable IPv4 DHT functionality. It also enables UDP tracker support. +enable-dht=true +# Enable IPv6 DHT functionality. +enable-dht6=true +# Enable Peer Exchange extension. +enable-peer-exchange=true +# Specify the string used during the bitorrent extended handshake for the peer's client version. +peer-agent=Transmission/3.00 +# Specify the prefix of peer ID. +peer-id-prefix=-TR3000- diff --git a/src-tauri/resources/engine/linux/arm64/aria2c b/src-tauri/resources/engine/linux/arm64/aria2c new file mode 100755 index 0000000..208091e Binary files /dev/null and b/src-tauri/resources/engine/linux/arm64/aria2c differ diff --git a/src-tauri/resources/engine/linux/armv7l/aria2.conf b/src-tauri/resources/engine/linux/armv7l/aria2.conf new file mode 100644 index 0000000..2dd6af5 --- /dev/null +++ b/src-tauri/resources/engine/linux/armv7l/aria2.conf @@ -0,0 +1,91 @@ +############################### +# Motrix Linux Aria2 config file +# +# @see https://aria2.github.io/manual/en/html/aria2c.html +# +############################### + + +################ RPC ################ +# Enable JSON-RPC/XML-RPC server. +enable-rpc=true +# Add Access-Control-Allow-Origin header field with value * to the RPC response. +rpc-allow-origin-all=true +# Listen incoming JSON-RPC/XML-RPC requests on all network interfaces. +rpc-listen-all=true + + +################ File system ################ +# Save a control file(*.aria2) every SEC seconds. +auto-save-interval=10 +# Enable disk cache. +disk-cache=64M +# Specify file allocation method. +file-allocation=trunc +# No file allocation is made for files whose size is smaller than SIZE +no-file-allocation-limit=64M +# Save error/unfinished downloads to a file specified by --save-session option every SEC seconds. +save-session-interval=10 + + +################ Task ################ +# Exclude seed only downloads when counting concurrent active downloads +bt-detach-seed-only=true +# Verify the peer using certificates specified in --ca-certificate option. +check-certificate=false +# If aria2 receives "file not found" status from the remote HTTP/FTP servers NUM times +# without getting a single byte, then force the download to fail. +max-file-not-found=10 +# Set number of tries. +max-tries=0 +# Set the seconds to wait between retries. When SEC > 0, aria2 will retry downloads when the HTTP server returns a 503 response. +retry-wait=10 +# Set the connect timeout in seconds to establish connection to HTTP/FTP/proxy server. After the connection is established, this option makes no effect and --timeout option is used instead. +connect-timeout=10 +# Set timeout in seconds. +timeout=10 +# aria2 does not split less than 2*SIZE byte range. +min-split-size=1M +# Send Accept: deflate, gzip request header. +http-accept-gzip=true +# Retrieve timestamp of the remote file from the remote HTTP/FTP server and if it is available, apply it to the local file. +remote-time=true +# Set interval in seconds to output download progress summary. Setting 0 suppresses the output. +summary-interval=0 +# Handle quoted string in Content-Disposition header as UTF-8 instead of ISO-8859-1, for example, the filename parameter, but not the extended version filename*. +content-disposition-default-utf8=true + + +################ BT Task ################ +# Enable Local Peer Discovery. +bt-enable-lpd=true +# Requires BitTorrent message payload encryption with arc4. +# bt-force-encryption=true +# If true is given, after hash check using --check-integrity option and file is complete, continue to seed file. +bt-hash-check-seed=true +# Specify the maximum number of peers per torrent. +bt-max-peers=128 +# Try to download first and last pieces of each file first. This is useful for previewing files. +bt-prioritize-piece=head +# Removes the unselected files when download is completed in BitTorrent. +bt-remove-unselected-file=true +# Seed previously downloaded files without verifying piece hashes. +bt-seed-unverified=false +# Set the connect timeout in seconds to establish connection to tracker. After the connection is established, this option makes no effect and --bt-tracker-timeout option is used instead. +bt-tracker-connect-timeout=10 +# Set timeout in seconds. +bt-tracker-timeout=10 +# Set host and port as an entry point to IPv4 DHT network. +dht-entry-point=dht.transmissionbt.com:6881 +# Set host and port as an entry point to IPv6 DHT network. +dht-entry-point6=dht.transmissionbt.com:6881 +# Enable IPv4 DHT functionality. It also enables UDP tracker support. +enable-dht=true +# Enable IPv6 DHT functionality. +enable-dht6=true +# Enable Peer Exchange extension. +enable-peer-exchange=true +# Specify the string used during the bitorrent extended handshake for the peer's client version. +peer-agent=Transmission/3.00 +# Specify the prefix of peer ID. +peer-id-prefix=-TR3000- diff --git a/src-tauri/resources/engine/linux/armv7l/aria2c b/src-tauri/resources/engine/linux/armv7l/aria2c new file mode 100755 index 0000000..b08e1e1 Binary files /dev/null and b/src-tauri/resources/engine/linux/armv7l/aria2c differ diff --git a/src-tauri/resources/engine/linux/x64/aria2.conf b/src-tauri/resources/engine/linux/x64/aria2.conf new file mode 100644 index 0000000..2dd6af5 --- /dev/null +++ b/src-tauri/resources/engine/linux/x64/aria2.conf @@ -0,0 +1,91 @@ +############################### +# Motrix Linux Aria2 config file +# +# @see https://aria2.github.io/manual/en/html/aria2c.html +# +############################### + + +################ RPC ################ +# Enable JSON-RPC/XML-RPC server. +enable-rpc=true +# Add Access-Control-Allow-Origin header field with value * to the RPC response. +rpc-allow-origin-all=true +# Listen incoming JSON-RPC/XML-RPC requests on all network interfaces. +rpc-listen-all=true + + +################ File system ################ +# Save a control file(*.aria2) every SEC seconds. +auto-save-interval=10 +# Enable disk cache. +disk-cache=64M +# Specify file allocation method. +file-allocation=trunc +# No file allocation is made for files whose size is smaller than SIZE +no-file-allocation-limit=64M +# Save error/unfinished downloads to a file specified by --save-session option every SEC seconds. +save-session-interval=10 + + +################ Task ################ +# Exclude seed only downloads when counting concurrent active downloads +bt-detach-seed-only=true +# Verify the peer using certificates specified in --ca-certificate option. +check-certificate=false +# If aria2 receives "file not found" status from the remote HTTP/FTP servers NUM times +# without getting a single byte, then force the download to fail. +max-file-not-found=10 +# Set number of tries. +max-tries=0 +# Set the seconds to wait between retries. When SEC > 0, aria2 will retry downloads when the HTTP server returns a 503 response. +retry-wait=10 +# Set the connect timeout in seconds to establish connection to HTTP/FTP/proxy server. After the connection is established, this option makes no effect and --timeout option is used instead. +connect-timeout=10 +# Set timeout in seconds. +timeout=10 +# aria2 does not split less than 2*SIZE byte range. +min-split-size=1M +# Send Accept: deflate, gzip request header. +http-accept-gzip=true +# Retrieve timestamp of the remote file from the remote HTTP/FTP server and if it is available, apply it to the local file. +remote-time=true +# Set interval in seconds to output download progress summary. Setting 0 suppresses the output. +summary-interval=0 +# Handle quoted string in Content-Disposition header as UTF-8 instead of ISO-8859-1, for example, the filename parameter, but not the extended version filename*. +content-disposition-default-utf8=true + + +################ BT Task ################ +# Enable Local Peer Discovery. +bt-enable-lpd=true +# Requires BitTorrent message payload encryption with arc4. +# bt-force-encryption=true +# If true is given, after hash check using --check-integrity option and file is complete, continue to seed file. +bt-hash-check-seed=true +# Specify the maximum number of peers per torrent. +bt-max-peers=128 +# Try to download first and last pieces of each file first. This is useful for previewing files. +bt-prioritize-piece=head +# Removes the unselected files when download is completed in BitTorrent. +bt-remove-unselected-file=true +# Seed previously downloaded files without verifying piece hashes. +bt-seed-unverified=false +# Set the connect timeout in seconds to establish connection to tracker. After the connection is established, this option makes no effect and --bt-tracker-timeout option is used instead. +bt-tracker-connect-timeout=10 +# Set timeout in seconds. +bt-tracker-timeout=10 +# Set host and port as an entry point to IPv4 DHT network. +dht-entry-point=dht.transmissionbt.com:6881 +# Set host and port as an entry point to IPv6 DHT network. +dht-entry-point6=dht.transmissionbt.com:6881 +# Enable IPv4 DHT functionality. It also enables UDP tracker support. +enable-dht=true +# Enable IPv6 DHT functionality. +enable-dht6=true +# Enable Peer Exchange extension. +enable-peer-exchange=true +# Specify the string used during the bitorrent extended handshake for the peer's client version. +peer-agent=Transmission/3.00 +# Specify the prefix of peer ID. +peer-id-prefix=-TR3000- diff --git a/src-tauri/resources/engine/linux/x64/aria2c b/src-tauri/resources/engine/linux/x64/aria2c new file mode 100755 index 0000000..b1ae0b4 Binary files /dev/null and b/src-tauri/resources/engine/linux/x64/aria2c differ diff --git a/src-tauri/resources/engine/win32/ia32/aria2.conf b/src-tauri/resources/engine/win32/ia32/aria2.conf new file mode 100644 index 0000000..4353519 --- /dev/null +++ b/src-tauri/resources/engine/win32/ia32/aria2.conf @@ -0,0 +1,91 @@ +############################### +# Motrix Windows Aria2 config file +# +# @see https://aria2.github.io/manual/en/html/aria2c.html +# +############################### + + +################ RPC ################ +# Enable JSON-RPC/XML-RPC server. +enable-rpc=true +# Add Access-Control-Allow-Origin header field with value * to the RPC response. +rpc-allow-origin-all=true +# Listen incoming JSON-RPC/XML-RPC requests on all network interfaces. +rpc-listen-all=true + + +################ File system ################ +# Save a control file(*.aria2) every SEC seconds. +auto-save-interval=10 +# Enable disk cache. +disk-cache=64M +# Specify file allocation method. +file-allocation=none +# No file allocation is made for files whose size is smaller than SIZE +no-file-allocation-limit=64M +# Save error/unfinished downloads to a file specified by --save-session option every SEC seconds. +save-session-interval=10 + + +################ Task ################ +# Exclude seed only downloads when counting concurrent active downloads +bt-detach-seed-only=true +# Verify the peer using certificates specified in --ca-certificate option. +check-certificate=false +# If aria2 receives "file not found" status from the remote HTTP/FTP servers NUM times +# without getting a single byte, then force the download to fail. +max-file-not-found=10 +# Set number of tries. +max-tries=0 +# Set the seconds to wait between retries. When SEC > 0, aria2 will retry downloads when the HTTP server returns a 503 response. +retry-wait=10 +# Set the connect timeout in seconds to establish connection to HTTP/FTP/proxy server. After the connection is established, this option makes no effect and --timeout option is used instead. +connect-timeout=10 +# Set timeout in seconds. +timeout=10 +# aria2 does not split less than 2*SIZE byte range. +min-split-size=1M +# Send Accept: deflate, gzip request header. +http-accept-gzip=true +# Retrieve timestamp of the remote file from the remote HTTP/FTP server and if it is available, apply it to the local file. +remote-time=true +# Set interval in seconds to output download progress summary. Setting 0 suppresses the output. +summary-interval=0 +# Handle quoted string in Content-Disposition header as UTF-8 instead of ISO-8859-1, for example, the filename parameter, but not the extended version filename*. +content-disposition-default-utf8=true + + +################ BT Task ################ +# Enable Local Peer Discovery. +bt-enable-lpd=true +# Requires BitTorrent message payload encryption with arc4. +# bt-force-encryption=true +# If true is given, after hash check using --check-integrity option and file is complete, continue to seed file. +bt-hash-check-seed=true +# Specify the maximum number of peers per torrent. +bt-max-peers=128 +# Try to download first and last pieces of each file first. This is useful for previewing files. +bt-prioritize-piece=head +# Removes the unselected files when download is completed in BitTorrent. +bt-remove-unselected-file=true +# Seed previously downloaded files without verifying piece hashes. +bt-seed-unverified=false +# Set the connect timeout in seconds to establish connection to tracker. After the connection is established, this option makes no effect and --bt-tracker-timeout option is used instead. +bt-tracker-connect-timeout=10 +# Set timeout in seconds. +bt-tracker-timeout=10 +# Set host and port as an entry point to IPv4 DHT network. +dht-entry-point=dht.transmissionbt.com:6881 +# Set host and port as an entry point to IPv6 DHT network. +dht-entry-point6=dht.transmissionbt.com:6881 +# Enable IPv4 DHT functionality. It also enables UDP tracker support. +enable-dht=true +# Enable IPv6 DHT functionality. +enable-dht6=true +# Enable Peer Exchange extension. +enable-peer-exchange=true +# Specify the string used during the bitorrent extended handshake for the peer's client version. +peer-agent=Transmission/3.00 +# Specify the prefix of peer ID. +peer-id-prefix=-TR3000- diff --git a/src-tauri/resources/engine/win32/ia32/aria2c.exe b/src-tauri/resources/engine/win32/ia32/aria2c.exe new file mode 100755 index 0000000..0741331 Binary files /dev/null and b/src-tauri/resources/engine/win32/ia32/aria2c.exe differ diff --git a/src-tauri/resources/engine/win32/x64/aria2.conf b/src-tauri/resources/engine/win32/x64/aria2.conf new file mode 100644 index 0000000..52ccfd0 --- /dev/null +++ b/src-tauri/resources/engine/win32/x64/aria2.conf @@ -0,0 +1,91 @@ +############################### +# Motrix Windows Aria2 config file +# +# @see https://aria2.github.io/manual/en/html/aria2c.html +# +############################### + + +################ RPC ################ +# Enable JSON-RPC/XML-RPC server. +enable-rpc=true +# Add Access-Control-Allow-Origin header field with value * to the RPC response. +rpc-allow-origin-all=true +# Listen incoming JSON-RPC/XML-RPC requests on all network interfaces. +rpc-listen-all=true + + +################ File system ################ +# Save a control file(*.aria2) every SEC seconds. +auto-save-interval=10 +# Enable disk cache. +disk-cache=64M +# Specify file allocation method. +file-allocation=falloc +# No file allocation is made for files whose size is smaller than SIZE +no-file-allocation-limit=64M +# Save error/unfinished downloads to a file specified by --save-session option every SEC seconds. +save-session-interval=10 + + +################ Task ################ +# Exclude seed only downloads when counting concurrent active downloads +bt-detach-seed-only=true +# Verify the peer using certificates specified in --ca-certificate option. +check-certificate=false +# If aria2 receives "file not found" status from the remote HTTP/FTP servers NUM times +# without getting a single byte, then force the download to fail. +max-file-not-found=10 +# Set number of tries. +max-tries=0 +# Set the seconds to wait between retries. When SEC > 0, aria2 will retry downloads when the HTTP server returns a 503 response. +retry-wait=10 +# Set the connect timeout in seconds to establish connection to HTTP/FTP/proxy server. After the connection is established, this option makes no effect and --timeout option is used instead. +connect-timeout=10 +# Set timeout in seconds. +timeout=10 +# aria2 does not split less than 2*SIZE byte range. +min-split-size=1M +# Send Accept: deflate, gzip request header. +http-accept-gzip=true +# Retrieve timestamp of the remote file from the remote HTTP/FTP server and if it is available, apply it to the local file. +remote-time=true +# Set interval in seconds to output download progress summary. Setting 0 suppresses the output. +summary-interval=0 +# Handle quoted string in Content-Disposition header as UTF-8 instead of ISO-8859-1, for example, the filename parameter, but not the extended version filename*. +content-disposition-default-utf8=true + + +################ BT Task ################ +# Enable Local Peer Discovery. +bt-enable-lpd=true +# Requires BitTorrent message payload encryption with arc4. +# bt-force-encryption=true +# If true is given, after hash check using --check-integrity option and file is complete, continue to seed file. +bt-hash-check-seed=true +# Specify the maximum number of peers per torrent. +bt-max-peers=128 +# Try to download first and last pieces of each file first. This is useful for previewing files. +bt-prioritize-piece=head +# Removes the unselected files when download is completed in BitTorrent. +bt-remove-unselected-file=true +# Seed previously downloaded files without verifying piece hashes. +bt-seed-unverified=false +# Set the connect timeout in seconds to establish connection to tracker. After the connection is established, this option makes no effect and --bt-tracker-timeout option is used instead. +bt-tracker-connect-timeout=10 +# Set timeout in seconds. +bt-tracker-timeout=10 +# Set host and port as an entry point to IPv4 DHT network. +dht-entry-point=dht.transmissionbt.com:6881 +# Set host and port as an entry point to IPv6 DHT network. +dht-entry-point6=dht.transmissionbt.com:6881 +# Enable IPv4 DHT functionality. It also enables UDP tracker support. +enable-dht=true +# Enable IPv6 DHT functionality. +enable-dht6=true +# Enable Peer Exchange extension. +enable-peer-exchange=true +# Specify the string used during the bitorrent extended handshake for the peer's client version. +peer-agent=Transmission/3.00 +# Specify the prefix of peer ID. +peer-id-prefix=-TR3000- diff --git a/src-tauri/resources/engine/win32/x64/aria2c.exe b/src-tauri/resources/engine/win32/x64/aria2c.exe new file mode 100755 index 0000000..d950831 Binary files /dev/null and b/src-tauri/resources/engine/win32/x64/aria2c.exe differ diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index bc11e96..56e2939 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -2,11 +2,13 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::env; +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 tauri::State; +use tauri::{Manager, State}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -46,6 +48,13 @@ pub struct Aria2AddTorrentRequest { pub split: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Aria2TaskCommandRequest { + pub rpc: Aria2RpcConfig, + pub gid: String, +} + #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct EngineStatusResponse { @@ -66,6 +75,7 @@ pub struct Aria2TaskSummary { pub download_speed: String, pub dir: String, pub file_name: String, + pub uri: String, } #[derive(Debug, Serialize)] @@ -84,9 +94,20 @@ pub struct TorrentFilePayload { pub size: u64, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Aria2BinaryProbeResponse { + pub found: bool, + pub binary_path: Option, + pub source: Option, + pub candidates: Vec, +} + #[derive(Debug)] struct EngineRuntime { child: Option, + external_reuse: bool, + rpc_port: Option, binary_path: Option, args: Vec, started_at: Option, @@ -96,6 +117,8 @@ impl Default for EngineRuntime { fn default() -> Self { Self { child: None, + external_reuse: false, + rpc_port: None, binary_path: None, args: vec![], started_at: None, @@ -111,7 +134,7 @@ pub struct EngineState { impl EngineState { fn status(runtime: &EngineRuntime) -> EngineStatusResponse { EngineStatusResponse { - running: runtime.child.is_some(), + running: runtime.child.is_some() || runtime.external_reuse, pid: runtime.child.as_ref().map(std::process::Child::id), binary_path: runtime.binary_path.clone(), args: runtime.args.clone(), @@ -153,6 +176,133 @@ fn default_aria2_binary() -> String { } } +fn normalize_binary_hint(binary_hint: Option<&str>) -> String { + let value = binary_hint.unwrap_or("").trim(); + if value.is_empty() { + default_aria2_binary() + } else { + value.to_string() + } +} + +fn candidate_push(path: &Path, out: &mut Vec) { + if let Some(s) = path.to_str() { + let v = s.trim(); + if !v.is_empty() { + out.push(v.to_string()); + } + } +} + +fn platform_aliases() -> Vec<&'static str> { + match env::consts::OS { + "macos" => vec!["macos", "darwin"], + "windows" => vec!["windows", "win32"], + "linux" => vec!["linux"], + _ => vec![env::consts::OS], + } +} + +fn arch_aliases() -> Vec<&'static str> { + match env::consts::ARCH { + "aarch64" => vec!["aarch64", "arm64"], + "x86_64" => vec!["x86_64", "x64"], + "x86" => vec!["x86", "ia32"], + "arm" => vec!["arm", "armv7l"], + other => vec![other], + } +} + +fn collect_binary_candidates(app: &tauri::AppHandle, binary_hint: Option<&str>) -> Vec { + let binary_name = normalize_binary_hint(binary_hint); + let mut candidates: Vec = vec![]; + + if let Some(raw) = binary_hint.map(str::trim).filter(|v| !v.is_empty()) { + candidates.push(raw.to_string()); + } + + if let Ok(env_path) = env::var("ARIA2C_BIN") { + let trimmed = env_path.trim(); + if !trimmed.is_empty() { + candidates.push(trimmed.to_string()); + } + } + if let Ok(env_path) = env::var("ARIA2C_PATH") { + let trimmed = env_path.trim(); + if !trimmed.is_empty() { + candidates.push(trimmed.to_string()); + } + } + + if let Some(path_var) = env::var_os("PATH") { + for dir in env::split_paths(&path_var) { + candidate_push(&dir.join(&binary_name), &mut candidates); + #[cfg(target_os = "windows")] + { + let lower = binary_name.to_ascii_lowercase(); + if !lower.ends_with(".exe") { + candidate_push(&dir.join(format!("{binary_name}.exe")), &mut candidates); + } + } + } + } + + let source_engine_base = Path::new(env!("CARGO_MANIFEST_DIR")).join("resources").join("engine"); + let mut engine_bases = vec![source_engine_base]; + if let Ok(resource_dir) = app.path().resource_dir() { + engine_bases.push(resource_dir.join("engine")); + } + + let platforms = platform_aliases(); + let arches = arch_aliases(); + for base in engine_bases { + for platform in &platforms { + for arch in &arches { + candidate_push(&base.join(platform).join(arch).join(&binary_name), &mut candidates); + } + candidate_push(&base.join(platform).join(&binary_name), &mut candidates); + } + candidate_push(&base.join(&binary_name), &mut candidates); + } + + let mut deduped = Vec::with_capacity(candidates.len()); + for path in candidates { + if !deduped.contains(&path) { + deduped.push(path); + } + } + deduped +} + +fn resolve_binary_from_candidates(candidates: &[String]) -> Option<(String, String)> { + for (idx, candidate) in candidates.iter().enumerate() { + let path = Path::new(candidate); + if path.is_file() { + let source = if idx == 0 { "user_or_default" } else { "auto_detected" }; + return Some((candidate.clone(), source.to_string())); + } + } + None +} + +fn classify_engine_spawn_error(binary: &str, err: &std::io::Error) -> String { + use std::io::ErrorKind; + match err.kind() { + ErrorKind::NotFound => format!( + "aria2 binary not found: '{binary}'. Binary Path를 지정하거나 PATH/resources/engine 경로를 확인하세요." + ), + ErrorKind::PermissionDenied => { + format!("aria2 binary is not executable: '{binary}'. 실행 권한(chmod +x)을 확인하세요.") + } + _ => format!("failed to start aria2 engine with '{binary}': {err}"), + } +} + +fn is_local_port_open(port: u16) -> bool { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(220)).is_ok() +} + fn rpc_endpoint(config: &Aria2RpcConfig) -> String { format!( "http://127.0.0.1:{}/jsonrpc", @@ -249,6 +399,17 @@ fn map_task(task: &Value) -> Aria2TaskSummary { download_speed: value_to_string(task.get("downloadSpeed")), dir: value_to_string(task.get("dir")), file_name: pick_file_name(file_path), + uri: task + .get("files") + .and_then(Value::as_array) + .and_then(|files| files.first()) + .and_then(|file| file.get("uris")) + .and_then(Value::as_array) + .and_then(|uris| uris.first()) + .and_then(|uri| uri.get("uri")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), } } @@ -277,16 +438,33 @@ fn build_rpc_options(out: Option<&String>, dir: Option<&String>, split: Option bool { + let lower = message.to_ascii_lowercase(); + lower.contains("not found for gid#") +} + #[tauri::command] pub fn engine_start( + app: tauri::AppHandle, state: State<'_, EngineState>, request: EngineStartRequest, ) -> Result { + let rpc_port = request.rpc_listen_port.unwrap_or(6800); let mut runtime = state .runtime .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(_)) => { @@ -302,7 +480,31 @@ pub fn engine_start( } let args = build_engine_args(&request); - let binary = request.binary_path.unwrap_or_else(default_aria2_binary); + + // 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)); + } + + let candidates = collect_binary_candidates(&app, request.binary_path.as_deref()); + let (binary, _) = resolve_binary_from_candidates(&candidates).ok_or_else(|| { + let sample = candidates.into_iter().take(6).collect::>().join(", "); + format!( + "aria2 binary를 찾지 못했습니다. Binary Path를 직접 지정하세요. (검색 후보: {sample})" + ) + })?; let child = Command::new(&binary) .args(&args) @@ -310,9 +512,25 @@ pub fn engine_start( .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .map_err(|err| format!("failed to start aria2 engine with '{binary}': {err}"))?; + .map_err(|err| classify_engine_spawn_error(&binary, &err))?; + + let mut child = child; + std::thread::sleep(std::time::Duration::from_millis(220)); + match child.try_wait() { + Ok(Some(status)) => { + return Err(format!( + "aria2 engine exited immediately (status={status}). 포트 충돌 또는 잘못된 옵션일 수 있습니다." + )); + } + Ok(None) => {} + Err(err) => { + return Err(format!("failed to inspect aria2 engine process: {err}")); + } + } runtime.child = Some(child); + runtime.external_reuse = false; + runtime.rpc_port = Some(rpc_port); runtime.binary_path = Some(binary); runtime.args = args; runtime.started_at = Some( @@ -325,6 +543,21 @@ pub fn engine_start( Ok(EngineState::status(&runtime)) } +#[tauri::command] +pub fn detect_aria2_binary( + app: tauri::AppHandle, + binary_path: Option, +) -> Aria2BinaryProbeResponse { + let candidates = collect_binary_candidates(&app, binary_path.as_deref()); + let resolved = resolve_binary_from_candidates(&candidates); + Aria2BinaryProbeResponse { + found: resolved.is_some(), + binary_path: resolved.as_ref().map(|v| v.0.clone()), + source: resolved.map(|v| v.1), + candidates, + } +} + #[tauri::command] pub fn engine_stop(state: State<'_, EngineState>) -> Result { let mut runtime = state @@ -339,6 +572,8 @@ pub fn engine_stop(state: State<'_, EngineState>) -> 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 result = call_aria2_rpc(&client, &request.rpc, "aria2.pause", vec![json!(gid)]).await?; + result + .as_str() + .map(|value| value.to_string()) + .ok_or_else(|| format!("aria2.pause returned unexpected result: {result}")) +} + +#[tauri::command] +pub async fn aria2_resume_task(request: Aria2TaskCommandRequest) -> Result { + let gid = request.gid.trim(); + if gid.is_empty() { + return Err("gid is required".to_string()); + } + let client = Client::new(); + let result = call_aria2_rpc(&client, &request.rpc, "aria2.unpause", vec![json!(gid)]).await?; + result + .as_str() + .map(|value| value.to_string()) + .ok_or_else(|| format!("aria2.unpause returned unexpected result: {result}")) +} + +#[tauri::command] +pub async fn aria2_remove_task(request: Aria2TaskCommandRequest) -> Result { + let gid = request.gid.trim(); + if gid.is_empty() { + return Err("gid is required".to_string()); + } + let client = Client::new(); + match call_aria2_rpc(&client, &request.rpc, "aria2.remove", 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), + } +} + +#[tauri::command] +pub async fn aria2_remove_task_record(request: Aria2TaskCommandRequest) -> Result { + let gid = request.gid.trim(); + if gid.is_empty() { + return Err("gid is required".to_string()); + } + let client = Client::new(); + 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(err) if is_gid_not_found_error(&err) => Ok(gid.to_string()), + Err(err) => Err(err), + } +} + +#[tauri::command] +pub async fn aria2_pause_all(config: Aria2RpcConfig) -> Result { + let client = Client::new(); + let result = call_aria2_rpc(&client, &config, "aria2.pauseAll", vec![]).await?; + result + .as_str() + .map(|value| value.to_string()) + .ok_or_else(|| format!("aria2.pauseAll returned unexpected result: {result}")) +} + +#[tauri::command] +pub async fn aria2_resume_all(config: Aria2RpcConfig) -> Result { + let client = Client::new(); + let result = call_aria2_rpc(&client, &config, "aria2.unpauseAll", vec![]).await?; + result + .as_str() + .map(|value| value.to_string()) + .ok_or_else(|| format!("aria2.unpauseAll returned unexpected result: {result}")) +} + #[tauri::command] pub fn load_torrent_file(path: String) -> Result { if !path.to_ascii_lowercase().ends_with(".torrent") { @@ -445,3 +788,34 @@ pub fn load_torrent_file(path: String) -> Result { size: bytes.len() as u64, }) } + +#[tauri::command] +pub fn open_path_in_file_manager(path: String) -> Result<(), String> { + let target = path.trim(); + if target.is_empty() { + return Err("path is required".to_string()); + } + + let mut command = if cfg!(target_os = "macos") { + let mut cmd = Command::new("open"); + cmd.arg(target); + cmd + } else if cfg!(target_os = "windows") { + let mut cmd = Command::new("explorer"); + cmd.arg(target); + cmd + } else { + let mut cmd = Command::new("xdg-open"); + cmd.arg(target); + cmd + }; + + let status = command + .status() + .map_err(|err| format!("failed to open path in file manager: {err}"))?; + + if !status.success() { + return Err(format!("file manager command exited with status: {status}")); + } + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2022c55..bc72f4e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,14 +1,17 @@ mod engine; use engine::{ - aria2_add_torrent, aria2_add_uri, aria2_list_tasks, engine_start, engine_status, engine_stop, - load_torrent_file, EngineState, + 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, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .manage(EngineState::default()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -23,10 +26,18 @@ pub fn run() { engine_start, engine_stop, engine_status, + detect_aria2_binary, aria2_add_torrent, aria2_add_uri, aria2_list_tasks, - load_torrent_file + aria2_pause_task, + aria2_resume_task, + aria2_remove_task, + aria2_remove_task_record, + aria2_pause_all, + aria2_resume_all, + load_torrent_file, + open_path_in_file_manager ]) .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 04855b2..df6e727 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -12,9 +12,12 @@ "app": { "windows": [ { + "label": "main", "title": "gdown", - "width": 800, - "height": 600, + "width": 1280, + "height": 860, + "minWidth": 1080, + "minHeight": 720, "resizable": true, "fullscreen": false } @@ -26,6 +29,9 @@ "bundle": { "active": true, "targets": "all", + "resources": [ + "resources/engine/**/*" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/App.vue b/src/App.vue index 134cb73..04a38a6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,14 +2,22 @@ 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 { addAria2Torrent, addAria2Uri, + detectAria2Binary, getEngineStatus, listAria2Tasks, loadTorrentFile, + pauseAllAria2, + pauseAria2Task, + openPathInFileManager, + removeAria2Task, + removeAria2TaskRecord, + resumeAllAria2, + resumeAria2Task, startEngine, - stopEngine, type Aria2Task, type Aria2TaskSnapshot, type EngineStatus, @@ -19,6 +27,17 @@ type TaskFilter = 'all' | 'active' | 'waiting' | 'stopped' type AddTab = 'url' | 'torrent' type AppPage = 'downloads' | 'settings' type SettingsTab = 'basic' | 'advanced' | 'lab' +type PersistedSettings = { + binaryPath?: string + rpcPort?: number + rpcSecret?: string + downloadDir?: string + split?: number + maxConcurrentDownloads?: number + autoRefresh?: boolean +} + +const SETTINGS_STORAGE_KEY = 'gdown.settings.v1' const binaryPath = ref('aria2c') const rpcPort = ref(6800) @@ -30,10 +49,11 @@ const maxConcurrentDownloads = ref(5) const loadingEngine = ref(false) const loadingTasks = ref(false) const loadingAddTask = ref(false) +const loadingTaskAction = ref(false) const errorMessage = ref('') const successMessage = ref('') const autoRefresh = ref(true) -const filter = ref('all') +const filter = ref('active') const page = ref('downloads') const settingsTab = ref('basic') @@ -83,16 +103,45 @@ const tasks = ref({ let refreshTimer: number | null = null let unlistenDragDrop: UnlistenFn | null = null -const totalCount = computed(() => tasks.value.active.length + tasks.value.waiting.length + tasks.value.stopped.length) - 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] }) +const filteredTaskCount = computed(() => filteredTasks.value.length) -const runtimeLabel = computed(() => (status.value.running ? 'Running' : 'Stopped')) +function loadSettingsFromStorage() { + try { + const raw = localStorage.getItem(SETTINGS_STORAGE_KEY) + if (!raw) return + const parsed = JSON.parse(raw) as PersistedSettings + if (typeof parsed.binaryPath === 'string') binaryPath.value = parsed.binaryPath + if (typeof parsed.rpcPort === 'number' && Number.isFinite(parsed.rpcPort)) rpcPort.value = parsed.rpcPort + if (typeof parsed.rpcSecret === 'string') rpcSecret.value = parsed.rpcSecret + if (typeof parsed.downloadDir === 'string') downloadDir.value = parsed.downloadDir + if (typeof parsed.split === 'number' && Number.isFinite(parsed.split)) split.value = parsed.split + if (typeof parsed.maxConcurrentDownloads === 'number' && Number.isFinite(parsed.maxConcurrentDownloads)) { + maxConcurrentDownloads.value = parsed.maxConcurrentDownloads + } + if (typeof parsed.autoRefresh === 'boolean') autoRefresh.value = parsed.autoRefresh + } catch { + // ignore malformed settings + } +} + +function saveSettingsToStorage() { + const payload: PersistedSettings = { + binaryPath: binaryPath.value, + rpcPort: rpcPort.value, + rpcSecret: rpcSecret.value, + downloadDir: downloadDir.value, + split: split.value, + maxConcurrentDownloads: maxConcurrentDownloads.value, + autoRefresh: autoRefresh.value, + } + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(payload)) +} function pushError(message: string) { successMessage.value = '' @@ -111,11 +160,6 @@ function rpcConfig() { } } -function formatDateTime(epochSec: number | null): string { - if (!epochSec) return '-' - return new Date(epochSec * 1000).toLocaleString() -} - function formatBytes(value: string): string { const n = Number(value) if (!Number.isFinite(n)) return '-' @@ -187,39 +231,164 @@ function updateRefreshTimer() { }, 3000) } -async function onStartEngine() { +async function autoStartEngine(silent = true) { loadingEngine.value = true try { + let resolvedBinaryPath = binaryPath.value.trim() || undefined + const probe = await detectAria2Binary(resolvedBinaryPath).catch(() => null) + if (probe?.found && probe.binaryPath) { + binaryPath.value = probe.binaryPath + resolvedBinaryPath = probe.binaryPath + } + status.value = await startEngine({ - binaryPath: binaryPath.value.trim() || undefined, + binaryPath: resolvedBinaryPath, rpcListenPort: rpcPort.value, rpcSecret: rpcSecret.value.trim() || undefined, downloadDir: downloadDir.value.trim() || undefined, maxConcurrentDownloads: maxConcurrentDownloads.value, split: split.value, }) - pushSuccess('aria2 engine started.') + saveSettingsToStorage() + if (!silent) pushSuccess('aria2 engine started.') await refreshTasks() + return true } catch (error) { pushError(String(error)) + return false } finally { loadingEngine.value = false } } -async function onStopEngine() { - loadingEngine.value = true +async function ensureEngineRunning() { + if (status.value.running) return true + await refreshEngineStatus() + if (status.value.running) return true + return autoStartEngine(false) +} + +async function onPauseAllTasks() { + const ready = await ensureEngineRunning() + if (!ready) return + loadingTaskAction.value = true try { - status.value = await stopEngine() - tasks.value = { active: [], waiting: [], stopped: [] } - pushSuccess('aria2 engine stopped.') + await pauseAllAria2(rpcConfig()) + pushSuccess('모든 작업을 일시정지했습니다.') + await refreshTasks() } catch (error) { pushError(String(error)) } finally { - loadingEngine.value = false + loadingTaskAction.value = false } } +async function onResumeAllTasks() { + const ready = await ensureEngineRunning() + if (!ready) return + loadingTaskAction.value = true + try { + await resumeAllAria2(rpcConfig()) + pushSuccess('모든 작업을 재개했습니다.') + await refreshTasks() + } catch (error) { + pushError(String(error)) + } finally { + loadingTaskAction.value = false + } +} + +async function onTaskAction(task: Aria2Task, action: 'pause' | 'resume' | 'remove' | 'purge') { + const ready = await ensureEngineRunning() + if (!ready) return + loadingTaskAction.value = true + try { + const payload = { rpc: rpcConfig(), gid: task.gid } + if (action === 'pause') { + await pauseAria2Task(payload) + } else if (action === 'resume') { + await resumeAria2Task(payload) + } else if (action === 'remove') { + await removeAria2Task(payload) + } else { + await removeAria2TaskRecord(payload) + } + await refreshTasks() + } catch (error) { + pushError(String(error)) + } finally { + loadingTaskAction.value = false + } +} + +async function onRemoveFilteredTasks() { + const ready = await ensureEngineRunning() + if (!ready) return + const targetTasks = [...filteredTasks.value] + if (targetTasks.length === 0) return + + loadingTaskAction.value = true + try { + for (const task of targetTasks) { + const payload = { rpc: rpcConfig(), gid: task.gid } + if (task.status === 'stopped') { + await removeAria2TaskRecord(payload) + } else { + await removeAria2Task(payload) + } + } + if (filter.value === 'stopped') { + pushSuccess(`${targetTasks.length}개 기록을 정리했습니다.`) + } else { + pushSuccess(`${targetTasks.length}개 작업을 삭제했습니다.`) + } + await refreshTasks() + } catch (error) { + pushError(String(error)) + } finally { + loadingTaskAction.value = false + } +} + +function taskPrimaryAction(task: Aria2Task): 'pause' | 'resume' { + return task.status === 'active' ? 'pause' : 'resume' +} + +function taskPrimaryIcon(task: Aria2Task): string { + return task.status === 'active' ? '□' : '▶' +} + +function taskPrimaryTitle(task: Aria2Task): string { + return task.status === 'active' ? '정지' : '재개' +} + +async function onOpenTaskFolder(task: Aria2Task) { + const dir = task.dir?.trim() + if (!dir) { + pushError('작업 폴더 경로가 없습니다.') + return + } + try { + await openPathInFileManager(dir) + } catch (error) { + pushError(String(error)) + } +} + +async function onCopyTaskLink(task: Aria2Task) { + const text = task.uri?.trim() || task.gid + try { + await navigator.clipboard.writeText(text) + pushSuccess('작업 링크를 클립보드에 복사했습니다.') + } catch (error) { + pushError(`클립보드 복사 실패: ${error}`) + } +} + +function onShowTaskInfo(task: Aria2Task) { + pushSuccess(`gid=${task.gid} / status=${task.status} / dir=${task.dir || '-'}`) +} + function openAddModal() { showAddModal.value = true addTab.value = 'url' @@ -234,9 +403,28 @@ function openSettingsPage() { } function saveSettings() { + saveSettingsToStorage() pushSuccess('설정이 저장되었습니다.') } +async function pickDownloadFolder() { + try { + const selected = await openDialog({ + directory: true, + multiple: false, + defaultPath: downloadDir.value || undefined, + title: '기본 다운로드 폴더 선택', + }) + if (typeof selected === 'string' && selected.trim()) { + downloadDir.value = selected + saveSettingsToStorage() + pushSuccess('기본 폴더를 변경했습니다.') + } + } catch (error) { + pushError(String(error)) + } +} + function closeAddModal() { showAddModal.value = false modalDropActive.value = false @@ -388,26 +576,32 @@ async function onWindowDrop(event: DragEvent) { } async function onSubmitAddTask() { - if (!status.value.running) { - pushError('먼저 엔진을 시작하세요.') - return - } + const ready = await ensureEngineRunning() + if (!ready) return loadingAddTask.value = true try { if (addTab.value === 'url') { - if (!addUrl.value.trim()) { + const uris = addUrl.value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + if (uris.length === 0) { pushError('URL을 입력하세요.') return } - const gid = await addAria2Uri({ - rpc: rpcConfig(), - uri: addUrl.value.trim(), - out: addOut.value.trim() || undefined, - dir: downloadDir.value.trim() || undefined, - split: addSplit.value, - }) - pushSuccess(`작업이 추가되었습니다. gid=${gid}`) + const gids: string[] = [] + for (const uri of uris) { + const gid = await addAria2Uri({ + rpc: rpcConfig(), + uri, + out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined, + dir: downloadDir.value.trim() || undefined, + split: addSplit.value, + }) + gids.push(gid) + } + pushSuccess(`${gids.length}개 작업이 추가되었습니다.`) } else { if (!torrentBase64.value.trim()) { pushError('.torrent 파일을 선택하세요.') @@ -439,7 +633,11 @@ async function onSubmitAddTask() { } onMounted(async () => { + loadSettingsFromStorage() await refreshEngineStatus() + if (!status.value.running) { + await autoStartEngine(true) + } await refreshTasks() updateRefreshTimer() @@ -510,116 +708,85 @@ onUnmounted(() => { -
-
-
-

다운로드 중

- {{ totalCount }} tasks -
-
- - -
-
+
+ -
{{ errorMessage }}
-
{{ successMessage }}
+
+
+
+

{{ filter === 'active' ? '다운로드 중' : filter === 'waiting' ? '대기 중' : '중단됨' }}

+
+
+ + + + +
+
+ +
{{ errorMessage }}
+
{{ successMessage }}
-
-
- - - - +
+
+
+
+ {{ task.fileName || '-' }} +
+
+ + + + + +
+
+ +
+
+ +
+
+ +
+ {{ formatBytes(task.completedLength) }} / {{ formatBytes(task.totalLength) }} + + {{ formatSpeed(task.downloadSpeed) }} + +
+
+ +
현재 다운로드 없음
- -
- - - - - - - - - - - - - - - - - - - - - - -
작업상태진행률속도전체 크기
-
- {{ task.fileName || '-' }} - {{ task.gid }} -
-
- {{ task.status }} - -
-
- -
- {{ progress(task).toFixed(1) }}% -
-
{{ formatSpeed(task.downloadSpeed) }}{{ formatBytes(task.totalLength) }}
현재 다운로드 없음
-
-
- -
-

Engine

-

{{ runtimeLabel }}

-

PID: {{ status.pid ?? '-' }}

-

Started: {{ formatDateTime(status.startedAt) }}

- -
- - -
- -

연결 설정

- - - - - -
@@ -627,18 +794,32 @@ onUnmounted(() => {
-

기본

+
+

환경 설정

+

Motrix 스타일에 맞춘 엔진/다운로드 환경 구성

+
-
- +
+
UI & 표시
+
@@ -646,13 +827,15 @@ onUnmounted(() => {
-
+
+
시작/표시 동작
-
+
+
기본 파라미터
-
- +
+
다운로드 디렉터리
+ +
+ + +
-
+
+
대역폭 제한
@@ -685,8 +875,8 @@ onUnmounted(() => {
-
-

BitTorrent

+
+
BitTorrent
@@ -702,8 +892,9 @@ onUnmounted(() => {
-
-

{{ settingsTab === 'advanced' ? '고급 설정 화면(포팅 진행 중)' : '실험실 설정 화면(포팅 진행 중)' }}

+
+

{{ settingsTab === 'advanced' ? '고급 설정' : '실험실 설정' }}

+

{{ settingsTab === 'advanced' ? 'RPC, 프록시, 트래커 등 고급 항목을 Motrix 구조에 맞춰 포팅 중입니다.' : '실험 기능과 실험적 네트워크 옵션을 단계적으로 이식합니다.' }}

diff --git a/src/lib/engineApi.ts b/src/lib/engineApi.ts index 89f49ad..2debecb 100644 --- a/src/lib/engineApi.ts +++ b/src/lib/engineApi.ts @@ -8,6 +8,13 @@ export interface EngineStatus { startedAt: number | null } +export interface BinaryProbeResult { + found: boolean + binaryPath: string | null + source: string | null + candidates: string[] +} + export interface EngineStartPayload { binaryPath?: string rpcListenPort?: number @@ -30,6 +37,7 @@ export interface Aria2Task { downloadSpeed: string dir: string fileName: string + uri: string } export interface Aria2TaskSnapshot { @@ -54,6 +62,11 @@ export interface AddTorrentPayload { split?: number } +export interface TaskCommandPayload { + rpc: Aria2RpcConfig + gid: string +} + export interface TorrentFilePayload { name: string base64: string @@ -72,6 +85,10 @@ export async function stopEngine(): Promise { return invoke('engine_stop') } +export async function detectAria2Binary(binaryPath?: string): Promise { + return invoke('detect_aria2_binary', { binaryPath }) +} + export async function listAria2Tasks(config: Aria2RpcConfig): Promise { return invoke('aria2_list_tasks', { config }) } @@ -87,3 +104,31 @@ export async function addAria2Torrent(payload: AddTorrentPayload): Promise { return invoke('load_torrent_file', { path }) } + +export async function pauseAria2Task(payload: TaskCommandPayload): Promise { + return invoke('aria2_pause_task', { request: payload }) +} + +export async function resumeAria2Task(payload: TaskCommandPayload): Promise { + return invoke('aria2_resume_task', { request: payload }) +} + +export async function removeAria2Task(payload: TaskCommandPayload): Promise { + return invoke('aria2_remove_task', { request: payload }) +} + +export async function removeAria2TaskRecord(payload: TaskCommandPayload): Promise { + return invoke('aria2_remove_task_record', { request: payload }) +} + +export async function pauseAllAria2(config: Aria2RpcConfig): Promise { + return invoke('aria2_pause_all', { config }) +} + +export async function resumeAllAria2(config: Aria2RpcConfig): Promise { + return invoke('aria2_resume_all', { config }) +} + +export async function openPathInFileManager(path: string): Promise { + return invoke('open_path_in_file_manager', { path }) +} diff --git a/src/style.css b/src/style.css index 9cf04c9..9135ebf 100644 --- a/src/style.css +++ b/src/style.css @@ -120,6 +120,121 @@ body { overflow: auto; } +.downloads-view { + background: #f3f3f5; + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 22px; + padding: 18px 20px; +} + +.downloads-view .card { + background: #f3f3f5; + border: 1px solid #dfdfe4; + border-radius: 10px; +} + +.downloads-filter { + padding: 16px; + align-self: start; + min-height: calc(100vh - 40px); +} + +.downloads-filter h2 { + margin: 2px 0 14px; + color: #2a2a2e; + font-size: 1.05rem; +} + +.downloads-filter button { + width: 100%; + text-align: left; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: #3f424a; + font-size: 0.98rem; + padding: 10px 12px; + margin-bottom: 8px; +} + +.downloads-filter button.active { + background: #e9e9ee; + color: #5b5fe9; +} + +.downloads-main .toolbar { + border-bottom: 1px solid #d8d8de; + padding-bottom: 12px; + margin-bottom: 14px; +} + +.downloads-main h1 { + color: #2f3136; + font-size: 2.1rem; + font-weight: 600; +} + +.downloads-main .icon-tool.ghost { + background: transparent; + color: #5f626b; + border-color: transparent; +} + +.downloads-main .icon-tool.ghost:hover { + background: #ebecf1; + color: #383b42; +} + +.downloads-main .task-pane { + background: transparent; + border: none; + padding: 0; +} + +.downloads-main .task-card { + border: 1px solid #ceced6; + border-radius: 10px; + background: #f9f9fb; + padding: 14px 16px; +} + +.downloads-main .file-cell strong { + color: #37393f; + font-size: 1.08rem; + font-weight: 500; +} + +.downloads-main .task-actions-pill { + background: #f5f5f8; + border: 1px solid #dfdfe6; +} + +.downloads-main .icon-pill { + color: #727782; + border-color: transparent; + background: transparent; +} + +.downloads-main .icon-pill:hover { + background: #e7e8ee; + border-color: transparent; +} + +.downloads-main .bar { + height: 8px; + background: #e0e1ea; +} + +.downloads-main .bar span { + background: #6268ee; +} + +.downloads-main .task-meta-row { + color: #757b87; + font-size: 1rem; +} + .toolbar { display: flex; align-items: center; @@ -135,6 +250,17 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; } .toolbar-right { display: flex; align-items: center; gap: 8px; } +.icon-tool { + width: 34px; + height: 34px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + font-size: 0.96rem; +} + .switcher { display: inline-flex; align-items: center; @@ -170,7 +296,7 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; } .main-grid { display: grid; - grid-template-columns: minmax(0, 1fr) 280px; + grid-template-columns: minmax(0, 1fr); gap: 10px; } @@ -193,27 +319,24 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; } color: #e0e3ff; } -.task-table-wrap { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } - -.task-table { - width: 100%; - border-collapse: collapse; - font-size: 0.84rem; +.task-card-list { + display: grid; + gap: 10px; } -.task-table th, -.task-table td { - text-align: left; - padding: 9px; - border-bottom: 1px solid #2b2f37; +.task-card { + border: 1px solid #363b45; + border-radius: 10px; + background: #20242c; + padding: 12px 14px; } -.task-table th { - font-size: 0.72rem; - color: #8f9ab0; - background: #1a1c22; - text-transform: uppercase; - letter-spacing: 0.04em; +.task-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; } .file-cell { display: flex; flex-direction: column; gap: 2px; } @@ -240,8 +363,8 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; } .progress-row { display: grid; - grid-template-columns: 1fr auto; - gap: 8px; + grid-template-columns: 1fr; + gap: 6px; align-items: center; } @@ -259,35 +382,45 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; } background: linear-gradient(90deg, #5b86ff, #5860f0); } -.progress-row small { color: #a8b2c4; font-size: 0.72rem; } - .empty { text-align: center; color: var(--text-sub); } -.right-pane { - padding: 10px; +.task-actions-pill { display: flex; - flex-direction: column; - gap: 7px; + gap: 6px; + justify-content: flex-start; + background: #262b33; + border: 1px solid #3a404c; + border-radius: 999px; + padding: 4px; } -.right-pane h2 { margin: 0; font-size: 0.98rem; } -.right-pane h3 { margin: 6px 0 2px; font-size: 0.86rem; } - -.runtime { margin: 0; font-weight: 700; } -.runtime.ok { color: var(--success); } -.runtime.off { color: #949db0; } - -.right-pane p { margin: 0; color: #9aa3b8; font-size: 0.8rem; } - -.right-pane label { - display: flex; - flex-direction: column; - gap: 3px; - font-size: 0.75rem; - color: #a3acbe; +.icon-pill { + width: 28px; + height: 28px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + font-size: 0.82rem; +} + +.task-meta-row { + margin-top: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: #a5afc1; + font-size: 0.78rem; +} + +.task-meta-right { + display: inline-flex; + align-items: center; + gap: 8px; } -.right-pane input, .add-modal input, .add-modal textarea { height: 33px; @@ -305,7 +438,6 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; } min-height: 92px; } -.right-pane input:focus, .add-modal input:focus, .add-modal textarea:focus { outline: none; @@ -338,11 +470,19 @@ button.ghost:hover { } button.small { padding: 4px 8px; font-size: 0.74rem; } +button.tiny { padding: 3px 7px; font-size: 0.72rem; } +button.ghost.danger { + color: #ffbec8; + border-color: #66414a; + background: #4b2e35; +} +button.ghost.danger:hover { + background: #5a3640; + border-color: #7a4a55; +} button:disabled { opacity: 0.55; cursor: not-allowed; } -.engine-actions { display: flex; gap: 7px; margin: 4px 0 6px; } - .modal-backdrop { position: fixed; inset: 0; @@ -487,11 +627,278 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } gap: 8px; } +.settings-content { + padding: 16px 18px; +} + +.settings-layout { + display: grid; + grid-template-columns: 208px minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.settings-nav { + padding: 12px; + position: sticky; + top: 8px; +} + +.settings-nav h2 { + margin: 0; + font-size: 1.05rem; + letter-spacing: 0.01em; +} + +.settings-nav-sub { + margin: 8px 0 14px; + color: #97a2b9; + font-size: 0.75rem; + line-height: 1.45; +} + +.settings-nav button { + width: 100%; + text-align: left; + border: 1px solid transparent; + border-radius: 9px; + margin-bottom: 8px; + padding: 9px 10px; + background: #23262d; + color: #c9d1e3; +} + +.settings-nav button span { + display: block; + font-size: 0.82rem; + font-weight: 700; +} + +.settings-nav button small { + display: block; + margin-top: 1px; + color: #919db4; + font-size: 0.68rem; + font-weight: 500; +} + +.settings-nav button.active { + border-color: #575ced; + background: #2f345e; + color: #eef1ff; +} + +.settings-nav button.active small { + color: #c9d2ff; +} + +.settings-panel { + padding: 18px 20px; + min-height: 640px; +} + +.settings-header { + margin-bottom: 16px; +} + +.settings-header h1 { + margin: 0; + font-size: 1.2rem; +} + +.settings-header p { + margin: 6px 0 0; + color: #98a2b8; + font-size: 0.81rem; +} + +.settings-form { + display: grid; + gap: 14px; + max-width: 760px; + margin: 0 auto; +} + +.settings-group { + display: grid; + gap: 8px; +} + +.settings-card-section { + border: 1px solid #3a404c; + border-radius: 10px; + padding: 14px; + background: #252a33; +} + +.group-title { + color: #e5e9f7; + font-size: 0.84rem; + font-weight: 700; + letter-spacing: 0.01em; + margin-bottom: 6px; +} + +.group-title.full-row { + grid-column: 1 / -1; +} + +.field-label { + color: #9aa6bf; + font-size: 0.76rem; +} + +.theme-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.theme-tile { + background: #2d313a; + border: 1px solid #464d5b; + color: #cdd4e6; + min-height: 36px; +} + +.theme-tile.active { + background: #3a3f73; + border-color: #646dff; + color: #f0f3ff; +} + +.settings-group label { + display: flex; + flex-direction: column; + gap: 3px; + color: #b1b9cb; + font-size: 0.79rem; +} + +.settings-group input, +.settings-group select { + height: 33px; + border: 1px solid #434955; + border-radius: 4px; + padding: 7px 10px; + font-size: 0.81rem; + color: var(--text-main); + background: #1b1f27; +} + +.settings-group input:focus, +.settings-group select:focus { + outline: none; + border-color: #656cf5; + box-shadow: none; +} + +.settings-group select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, #8e97aa 50%), + linear-gradient(135deg, #8e97aa 50%, transparent 50%); + background-position: + calc(100% - 14px) calc(50% - 2px), + calc(100% - 9px) calc(50% - 2px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + padding-right: 28px; +} + +.settings-group.two-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.check-row { + display: flex !important; + flex-direction: row !important; + align-items: center; + gap: 6px !important; + color: #c1c8d9 !important; +} + +.check-row input { + width: 15px; + height: 15px; + margin: 0; +} + +.settings-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 1px solid #373d49; +} + +.folder-picker-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: center; +} + +.folder-browse-btn { + white-space: nowrap; + padding: 7px 12px; + min-height: 33px; +} + +.settings-placeholder { + padding: 20px; + background: #242831; + border: 1px solid #3a404c; + border-radius: 12px; +} + +.settings-placeholder h3 { + margin: 0 0 8px; + font-size: 0.96rem; +} + +.settings-placeholder p { + margin: 0; + color: #9aa6bf; + font-size: 0.82rem; + line-height: 1.6; +} + @media (max-width: 1180px) { .main-grid { grid-template-columns: 1fr; } + .settings-layout { grid-template-columns: 1fr; } + .settings-nav { + position: static; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + } + .settings-nav h2, + .settings-nav-sub { + grid-column: 1 / -1; + } + .settings-nav button { + margin-bottom: 0; + } } @media (max-width: 900px) { + .downloads-view { + grid-template-columns: 1fr; + min-height: auto; + } + .downloads-filter { + min-height: auto; + } .app-shell { grid-template-columns: 1fr; } .sidebar { display: none; } + .settings-group.two-col { + grid-template-columns: 1fr; + } + .theme-row { + grid-template-columns: 1fr; + } }