feat: align motrix-style download UI/actions and stabilize aria2 ops

This commit is contained in:
tongki078
2026-02-24 12:00:30 +09:00
parent 845d5ca65c
commit 552f27c002
29 changed files with 2164 additions and 226 deletions

View File

@@ -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. 선택 기반 배치 작업 액션 및 리스트 인터랙션 개선

61
docs/TODO.md Normal file
View File

@@ -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 저장/복원

13
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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"

54
scripts/version-bump.sh Executable file
View File

@@ -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"

67
src-tauri/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -6,6 +6,7 @@
"main"
],
"permissions": [
"core:default"
"core:default",
"dialog:allow-open"
]
}

View File

@@ -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-

Binary file not shown.

View File

@@ -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-

Binary file not shown.

View File

@@ -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-

Binary file not shown.

View File

@@ -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-

Binary file not shown.

View File

@@ -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-

Binary file not shown.

View File

@@ -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-

Binary file not shown.

View File

@@ -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-

Binary file not shown.

View File

@@ -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<u16>,
}
#[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<String>,
pub source: Option<String>,
pub candidates: Vec<String>,
}
#[derive(Debug)]
struct EngineRuntime {
child: Option<Child>,
external_reuse: bool,
rpc_port: Option<u16>,
binary_path: Option<String>,
args: Vec<String>,
started_at: Option<u64>,
@@ -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<String>) {
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<String> {
let binary_name = normalize_binary_hint(binary_hint);
let mut candidates: Vec<String> = 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<u
Value::Object(options)
}
fn is_gid_not_found_error(message: &str) -> 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<EngineStatusResponse, String> {
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::<Vec<String>>().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<String>,
) -> 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<EngineStatusResponse, String> {
let mut runtime = state
@@ -339,6 +572,8 @@ pub fn engine_stop(state: State<'_, EngineState>) -> Result<EngineStatusResponse
let _ = child.wait();
}
runtime.external_reuse = false;
runtime.rpc_port = None;
runtime.started_at = None;
Ok(EngineState::status(&runtime))
}
@@ -363,6 +598,15 @@ pub fn engine_status(state: State<'_, EngineState>) -> Result<EngineStatusRespon
}
}
if runtime.external_reuse {
let alive = runtime.rpc_port.map(is_local_port_open).unwrap_or(false);
if !alive {
runtime.external_reuse = false;
runtime.rpc_port = None;
runtime.started_at = None;
}
}
Ok(EngineState::status(&runtime))
}
@@ -426,6 +670,105 @@ pub async fn aria2_list_tasks(config: Aria2RpcConfig) -> Result<Aria2TaskSnapsho
})
}
#[tauri::command]
pub async fn aria2_pause_task(request: Aria2TaskCommandRequest) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<TorrentFilePayload, String> {
if !path.to_ascii_lowercase().ends_with(".torrent") {
@@ -445,3 +788,34 @@ pub fn load_torrent_file(path: String) -> Result<TorrentFilePayload, String> {
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(())
}

View File

@@ -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");

View File

@@ -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",

View File

@@ -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<TaskFilter>('all')
const filter = ref<TaskFilter>('active')
const page = ref<AppPage>('downloads')
const settingsTab = ref<SettingsTab>('basic')
@@ -83,16 +103,45 @@ const tasks = ref<Aria2TaskSnapshot>({
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(() => {
</div>
</aside>
<section v-if="page === 'downloads'" class="content">
<header class="toolbar">
<div class="toolbar-left">
<h1>다운로드 </h1>
<span class="count">{{ totalCount }} tasks</span>
</div>
<div class="toolbar-right">
<label class="switcher">
<input v-model="autoRefresh" type="checkbox" @change="updateRefreshTimer" />
<span>Auto refresh</span>
</label>
<button class="ghost" :disabled="loadingTasks" @click="refreshTasks()">새로고침</button>
</div>
</header>
<section v-if="page === 'downloads'" class="content downloads-view">
<aside class="downloads-filter card">
<h2>작업</h2>
<button :class="{ active: filter === 'active' }" @click="filter = 'active'"> 다운로드 </button>
<button :class="{ active: filter === 'waiting' }" @click="filter = 'waiting'"> 대기 </button>
<button :class="{ active: filter === 'stopped' }" @click="filter = 'stopped'"> 중단됨</button>
</aside>
<section v-if="errorMessage" class="notice error">{{ errorMessage }}</section>
<section v-if="successMessage" class="notice success">{{ successMessage }}</section>
<section class="downloads-main">
<header class="toolbar">
<div class="toolbar-left">
<h1>{{ filter === 'active' ? '다운로드 중' : filter === 'waiting' ? '대기 중' : '중단됨' }}</h1>
</div>
<div class="toolbar-right">
<button
class="icon-tool ghost"
:disabled="loadingTaskAction || filteredTaskCount === 0"
title="목록 삭제"
@click="onRemoveFilteredTasks"
>
</button>
<button class="icon-tool ghost" :disabled="loadingTasks" title="새로고침" @click="refreshTasks()"></button>
<button class="icon-tool ghost" :disabled="loadingTaskAction || !status.running" title="전체 재개" @click="onResumeAllTasks"></button>
<button class="icon-tool ghost" :disabled="loadingTaskAction || !status.running" title="전체 일시정지" @click="onPauseAllTasks"></button>
</div>
</header>
<section v-if="errorMessage" class="notice error">{{ errorMessage }}</section>
<section v-if="successMessage" class="notice success">{{ successMessage }}</section>
<section class="main-grid">
<article class="task-pane card">
<div class="tabs">
<button :class="{ active: filter === 'all' }" @click="filter = 'all'">전체</button>
<button :class="{ active: filter === 'active' }" @click="filter = 'active'">
다운로드 ({{ tasks.active.length }})
</button>
<button :class="{ active: filter === 'waiting' }" @click="filter = 'waiting'">
대기 ({{ tasks.waiting.length }})
</button>
<button :class="{ active: filter === 'stopped' }" @click="filter = 'stopped'">
중단됨 ({{ tasks.stopped.length }})
</button>
<div class="task-card-list">
<article v-for="task in filteredTasks" :key="task.gid" class="task-card">
<div class="task-card-head">
<div class="file-cell">
<strong>{{ task.fileName || '-' }}</strong>
</div>
<div class="task-actions-pill">
<button
class="icon-pill ghost"
:disabled="loadingTaskAction || task.status === 'stopped'"
:title="taskPrimaryTitle(task)"
@click="onTaskAction(task, taskPrimaryAction(task))"
>
{{ taskPrimaryIcon(task) }}
</button>
<button
class="icon-pill ghost"
:disabled="loadingTaskAction"
title="삭제"
@click="onTaskAction(task, task.status === 'stopped' ? 'purge' : 'remove')"
>
</button>
<button class="icon-pill ghost" title="폴더 열기" @click="onOpenTaskFolder(task)">📁</button>
<button class="icon-pill ghost" :title="task.uri ? '링크 복사' : 'GID 복사'" @click="onCopyTaskLink(task)">
🔗
</button>
<button class="icon-pill ghost" title="정보" @click="onShowTaskInfo(task)"></button>
</div>
</div>
<div class="progress-row">
<div class="bar">
<span :style="{ width: `${progress(task).toFixed(1)}%` }" />
</div>
</div>
<div class="task-meta-row">
<span>{{ formatBytes(task.completedLength) }} / {{ formatBytes(task.totalLength) }}</span>
<span class="task-meta-right">
<span>{{ formatSpeed(task.downloadSpeed) }}</span>
</span>
</div>
</article>
<div v-if="filteredTasks.length === 0" class="empty">현재 다운로드 없음</div>
</div>
<div class="task-table-wrap">
<table class="task-table">
<thead>
<tr>
<th>작업</th>
<th>상태</th>
<th>진행률</th>
<th>속도</th>
<th>전체 크기</th>
</tr>
</thead>
<tbody>
<tr v-for="task in filteredTasks" :key="task.gid">
<td>
<div class="file-cell">
<strong>{{ task.fileName || '-' }}</strong>
<small>{{ task.gid }}</small>
</div>
</td>
<td>
<span class="status-tag" :class="task.status">{{ task.status }}</span>
</td>
<td>
<div class="progress-row">
<div class="bar">
<span :style="{ width: `${progress(task).toFixed(1)}%` }" />
</div>
<small>{{ progress(task).toFixed(1) }}%</small>
</div>
</td>
<td>{{ formatSpeed(task.downloadSpeed) }}</td>
<td>{{ formatBytes(task.totalLength) }}</td>
</tr>
<tr v-if="filteredTasks.length === 0">
<td colspan="5" class="empty">현재 다운로드 없음</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="right-pane card">
<h2>Engine</h2>
<p class="runtime" :class="status.running ? 'ok' : 'off'">{{ runtimeLabel }}</p>
<p><strong>PID:</strong> {{ status.pid ?? '-' }}</p>
<p><strong>Started:</strong> {{ formatDateTime(status.startedAt) }}</p>
<div class="engine-actions">
<button :disabled="loadingEngine || status.running" @click="onStartEngine">Start</button>
<button class="ghost" :disabled="loadingEngine || !status.running" @click="onStopEngine">Stop</button>
</div>
<h3>연결 설정</h3>
<label>
<span>Binary Path</span>
<input v-model="binaryPath" type="text" placeholder="aria2c" />
</label>
<label>
<span>RPC Port</span>
<input v-model.number="rpcPort" type="number" min="1" max="65535" />
</label>
<label>
<span>RPC Secret</span>
<input v-model="rpcSecret" type="text" placeholder="optional" />
</label>
<label>
<span>폴더</span>
<input v-model="downloadDir" type="text" placeholder="/path/to/downloads" />
</label>
<label>
<span>Split</span>
<input v-model.number="split" type="number" min="1" max="64" />
</label>
<label>
<span>Max Concurrent</span>
<input v-model.number="maxConcurrentDownloads" type="number" min="1" max="20" />
</label>
</article>
</section>
</section>
@@ -627,18 +794,32 @@ onUnmounted(() => {
<section v-else class="content settings-content">
<section class="settings-layout">
<aside class="settings-nav card">
<h2>설정</h2>
<button :class="{ active: settingsTab === 'basic' }" @click="settingsTab = 'basic'">기본</button>
<button :class="{ active: settingsTab === 'advanced' }" @click="settingsTab = 'advanced'">고급</button>
<button :class="{ active: settingsTab === 'lab' }" @click="settingsTab = 'lab'">실험실</button>
<h2>Settings</h2>
<p class="settings-nav-sub">다운로드 엔진과 동작을 조정합니다.</p>
<button :class="{ active: settingsTab === 'basic' }" @click="settingsTab = 'basic'">
<span>기본</span>
<small>일반 사용 옵션</small>
</button>
<button :class="{ active: settingsTab === 'advanced' }" @click="settingsTab = 'advanced'">
<span>고급</span>
<small>네트워크/RPC</small>
</button>
<button :class="{ active: settingsTab === 'lab' }" @click="settingsTab = 'lab'">
<span>실험실</span>
<small>미리보기 기능</small>
</button>
</aside>
<article class="settings-panel card">
<h1>기본</h1>
<header class="settings-header">
<h1>환경 설정</h1>
<p>Motrix 스타일에 맞춘 엔진/다운로드 환경 구성</p>
</header>
<div v-if="settingsTab === 'basic'" class="settings-form">
<section class="settings-group">
<label>테마</label>
<section class="settings-group settings-card-section">
<div class="group-title">UI & 표시</div>
<label class="field-label">테마</label>
<div class="theme-row">
<button class="theme-tile" :class="{ active: settingTheme === 'auto' }" @click="settingTheme = 'auto'">자동</button>
<button class="theme-tile" :class="{ active: settingTheme === 'light' }" @click="settingTheme = 'light'">밝게</button>
@@ -646,13 +827,15 @@ onUnmounted(() => {
</div>
</section>
<section class="settings-group">
<section class="settings-group settings-card-section">
<div class="group-title">시작/표시 동작</div>
<label class="check-row"><input v-model="settingHideWindowOnStartup" type="checkbox" /> 자동으로 숨기기</label>
<label class="check-row"><input v-model="settingTraySpeed" type="checkbox" /> 메뉴 막대 트레이에 실시간 속도 표시</label>
<label class="check-row"><input v-model="settingShowDockProgress" type="checkbox" /> 다운로드 진행률 막대 보기</label>
</section>
<section class="settings-group two-col">
<section class="settings-group settings-card-section two-col">
<div class="group-title full-row">기본 파라미터</div>
<label>언어
<select v-model="settingLanguage">
<option>한국어</option>
@@ -664,19 +847,26 @@ onUnmounted(() => {
</label>
</section>
<section class="settings-group">
<section class="settings-group settings-card-section">
<div class="group-title">세션</div>
<label class="check-row"><input v-model="settingRunOnLogin" type="checkbox" /> 로그인 실행</label>
<label class="check-row"><input v-model="settingRememberWindow" type="checkbox" /> 크기 위치 기억</label>
<label class="check-row"><input v-model="settingAutoResume" type="checkbox" /> 완료되지 않은 작업 자동 재개</label>
</section>
<section class="settings-group">
<label>기본 폴더
<input v-model="downloadDir" type="text" />
</label>
<section class="settings-group settings-card-section">
<div class="group-title">다운로드 디렉터리</div>
<label class="field-label">기본 폴더</label>
<div class="folder-picker-row">
<input v-model="downloadDir" type="text" placeholder="/Users/.../Downloads" />
<button type="button" class="ghost folder-browse-btn" @click.stop.prevent="pickDownloadFolder">
폴더 선택
</button>
</div>
</section>
<section class="settings-group two-col">
<section class="settings-group settings-card-section two-col">
<div class="group-title full-row">대역폭 제한</div>
<label>업로드 제한 (KB/s)
<input v-model.number="settingUploadLimit" type="number" min="0" />
</label>
@@ -685,8 +875,8 @@ onUnmounted(() => {
</label>
</section>
<section class="settings-group">
<h3>BitTorrent</h3>
<section class="settings-group settings-card-section">
<div class="group-title">BitTorrent</div>
<label class="check-row"><input v-model="settingMagnetAsTorrent" type="checkbox" /> 마그넷 링크를 토렌트 파일로 저장</label>
<label class="check-row"><input v-model="settingAutoDownloadTorrentMeta" type="checkbox" /> 마그넷 토렌트 내용 자동 다운로드</label>
<label class="check-row"><input v-model="settingBtForceEncryption" type="checkbox" /> BT 강제 암호화</label>
@@ -702,8 +892,9 @@ onUnmounted(() => {
</div>
</div>
<div v-else class="settings-placeholder">
<p>{{ settingsTab === 'advanced' ? '고급 설정 화면(포팅 진행 중)' : '실험실 설정 화면(포팅 진행 중)' }}</p>
<div v-else class="settings-placeholder card">
<h3>{{ settingsTab === 'advanced' ? '고급 설정' : '실험실 설정' }}</h3>
<p>{{ settingsTab === 'advanced' ? 'RPC, 프록시, 트래커 등 고급 항목을 Motrix 구조에 맞춰 포팅 중입니다.' : '실험 기능과 실험적 네트워크 옵션을 단계적으로 이식합니다.' }}</p>
</div>
</article>
</section>

View File

@@ -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<EngineStatus> {
return invoke<EngineStatus>('engine_stop')
}
export async function detectAria2Binary(binaryPath?: string): Promise<BinaryProbeResult> {
return invoke<BinaryProbeResult>('detect_aria2_binary', { binaryPath })
}
export async function listAria2Tasks(config: Aria2RpcConfig): Promise<Aria2TaskSnapshot> {
return invoke<Aria2TaskSnapshot>('aria2_list_tasks', { config })
}
@@ -87,3 +104,31 @@ export async function addAria2Torrent(payload: AddTorrentPayload): Promise<strin
export async function loadTorrentFile(path: string): Promise<TorrentFilePayload> {
return invoke<TorrentFilePayload>('load_torrent_file', { path })
}
export async function pauseAria2Task(payload: TaskCommandPayload): Promise<string> {
return invoke<string>('aria2_pause_task', { request: payload })
}
export async function resumeAria2Task(payload: TaskCommandPayload): Promise<string> {
return invoke<string>('aria2_resume_task', { request: payload })
}
export async function removeAria2Task(payload: TaskCommandPayload): Promise<string> {
return invoke<string>('aria2_remove_task', { request: payload })
}
export async function removeAria2TaskRecord(payload: TaskCommandPayload): Promise<string> {
return invoke<string>('aria2_remove_task_record', { request: payload })
}
export async function pauseAllAria2(config: Aria2RpcConfig): Promise<string> {
return invoke<string>('aria2_pause_all', { config })
}
export async function resumeAllAria2(config: Aria2RpcConfig): Promise<string> {
return invoke<string>('aria2_resume_all', { config })
}
export async function openPathInFileManager(path: string): Promise<void> {
return invoke<void>('open_path_in_file_manager', { path })
}

View File

@@ -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;
}
}