Compare commits
3 Commits
34f63acf49
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4e0243ac2 | ||
|
|
5d8fb9db55 | ||
|
|
d85fdc1101 |
@@ -65,6 +65,13 @@
|
|||||||
- Step 4: 링크 자동 후킹을 Native Host 경로로 이관
|
- Step 4: 링크 자동 후킹을 Native Host 경로로 이관
|
||||||
- Step 5: 오류 복구/로깅/설정 UX 정리
|
- Step 5: 오류 복구/로깅/설정 UX 정리
|
||||||
|
|
||||||
|
### Phase 7 (신규): 범주(Category) 기능
|
||||||
|
- Step 1: 범주 기본 데이터 + 설정 토글 + Add 모달 범주 선택/경로 반영
|
||||||
|
- Step 2: 범주 CRUD UI(이름/아이콘/확장자 룰)
|
||||||
|
- Step 3: 좌측 범주 패널(카운트/필터/접힘 상태) 연동
|
||||||
|
- Step 4: 확장/외부 요청 자동 분류 규칙 정교화
|
||||||
|
- Step 5: 성능/UX/오류 피드백 상용 수준 마감
|
||||||
|
|
||||||
## 5. 리스크 및 대응
|
## 5. 리스크 및 대응
|
||||||
- aria2 바이너리 번들/서명: 플랫폼별 바이너리 동봉 규칙 문서화 + CI 검증
|
- aria2 바이너리 번들/서명: 플랫폼별 바이너리 동봉 규칙 문서화 + CI 검증
|
||||||
- Electron API 차이: 기능별 대체표를 먼저 만들고 Tauri plugin으로 대응
|
- Electron API 차이: 기능별 대체표를 먼저 만들고 Tauri plugin으로 대응
|
||||||
@@ -89,6 +96,8 @@
|
|||||||
- `src-tauri/src/engine.rs` + `src/lib/engineApi.ts`: 파일관리자에서 경로 열기 커맨드(`open_path_in_file_manager`) 추가
|
- `src-tauri/src/engine.rs` + `src/lib/engineApi.ts`: 파일관리자에서 경로 열기 커맨드(`open_path_in_file_manager`) 추가
|
||||||
- `src-tauri/src/engine.rs`: task summary에 `uri` 노출 추가(링크 복사용)
|
- `src-tauri/src/engine.rs`: task summary에 `uri` 노출 추가(링크 복사용)
|
||||||
- `src/App.vue`: Motrix `TaskActions`/`TaskItemActions` 기능 매핑에 맞춘 상단/항목 아이콘 동작 연결
|
- `src/App.vue`: Motrix `TaskActions`/`TaskItemActions` 기능 매핑에 맞춘 상단/항목 아이콘 동작 연결
|
||||||
|
- `src-tauri/src/lib.rs`: macOS 앱 시작 시 Native Host manifest/runner 자동 설치(`org.gdown.nativehost`)
|
||||||
|
- `src/App.vue` + `src/style.css`: 범주 기능 Step 1(기본 토글/선택/적용 폴더 프리뷰 + Add 시 경로 자동 반영)
|
||||||
- `src-tauri/src/engine.rs`: aria2 프로세스 시작/중지/상태 조회
|
- `src-tauri/src/engine.rs`: aria2 프로세스 시작/중지/상태 조회
|
||||||
- `src-tauri/src/engine.rs`: 바이너리 자동 탐지 + 에러 분류 + 작업 제어 RPC 커맨드
|
- `src-tauri/src/engine.rs`: 바이너리 자동 탐지 + 에러 분류 + 작업 제어 RPC 커맨드
|
||||||
- `src-tauri/src/lib.rs`: Tauri invoke handler 연결
|
- `src-tauri/src/lib.rs`: Tauri invoke handler 연결
|
||||||
|
|||||||
14
docs/TODO.md
14
docs/TODO.md
@@ -13,6 +13,16 @@
|
|||||||
- 고급 기능: 40~50%
|
- 고급 기능: 40~50%
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
- [~] yt-dlp 번들 내장/탐지
|
||||||
|
- [x] 런타임 탐지에 번들 resources 경로 추가 (`src-tauri/resources/engine/**/yt-dlp`)
|
||||||
|
- [x] 번들 동기화 스크립트 추가 (`npm run sync:ytdlp`)
|
||||||
|
- [ ] Linux/Windows용 yt-dlp 바이너리 동기화 확장
|
||||||
|
- [~] 범주(Category) 기능 단계 구현
|
||||||
|
- [x] Step 1: 범주 기본 데이터/설정 토글/추가 모달 범주 선택 + 저장 경로 자동 반영
|
||||||
|
- [ ] Step 2: 범주 관리 UI(추가/수정/삭제, 아이콘/확장자 규칙 편집)
|
||||||
|
- [ ] Step 3: 좌측 범주 네비게이션/카운트/필터 연동
|
||||||
|
- [ ] Step 4: 외부 연동/자동 캡처 시 범주 자동 매핑 고도화
|
||||||
|
- [ ] Step 5: 범주별 통계/정렬/검색 UX 마무리
|
||||||
- [~] Native Messaging 기반 브라우저 연동 전환 (Step-by-step)
|
- [~] Native Messaging 기반 브라우저 연동 전환 (Step-by-step)
|
||||||
- [x] Step 1: Native Host 스캐폴드(프로토콜/설치 스크립트/템플릿 manifest)
|
- [x] Step 1: Native Host 스캐폴드(프로토콜/설치 스크립트/템플릿 manifest)
|
||||||
- [x] Step 2: 확장에서 Native Host 1차 연결(우클릭 + 자동 경로 공통 addUri)
|
- [x] Step 2: 확장에서 Native Host 1차 연결(우클릭 + 자동 경로 공통 addUri)
|
||||||
@@ -56,6 +66,10 @@
|
|||||||
- [ ] 탭별 빈 상태/오류 상태 문구 정리
|
- [ ] 탭별 빈 상태/오류 상태 문구 정리
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
- [x] Native Host `addUri` 처리에서 앱 포커스/전환 동작 제거(큐 적재 전용 무중단 모드)
|
||||||
|
- [x] Native Host 포커스 로직에서 `osascript/System Events` 제거, `open` 기반 포커스만 사용(자동화 승인 팝업 회피)
|
||||||
|
- [x] macOS 앱 시작 시 Native Host(`org.gdown.nativehost`) 자동 설치/갱신 로직 추가
|
||||||
|
- [x] 완료 작업 분류 보정: `active/waiting/stopped` 전 구간에서 완료 조건(`done>=total`)을 `다운로드 완료`로 집계
|
||||||
- [x] 외부 링크 캡처 시 즉시 시작 대신 `Add 모달 확인 후 시작` 흐름으로 전환
|
- [x] 외부 링크 캡처 시 즉시 시작 대신 `Add 모달 확인 후 시작` 흐름으로 전환
|
||||||
- [x] `gdown://` 스킴 미등록 환경 대응: Native Host -> 로컬 큐(`~/.gdown/external_add_queue.jsonl`) -> 앱 폴링 처리
|
- [x] `gdown://` 스킴 미등록 환경 대응: Native Host -> 로컬 큐(`~/.gdown/external_add_queue.jsonl`) -> 앱 폴링 처리
|
||||||
- [x] Native Host 설치/삭제/스모크 스크립트 추가 (`tools/native-host/install-macos.sh`, `uninstall-macos.sh`, `smoke.mjs`)
|
- [x] Native Host 설치/삭제/스모크 스크립트 추가 (`tools/native-host/install-macos.sh`, `uninstall-macos.sh`, `smoke.mjs`)
|
||||||
|
|||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gdown",
|
"name": "gdown",
|
||||||
"version": "0.0.0",
|
"version": "0.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gdown",
|
"name": "gdown",
|
||||||
"version": "0.0.0",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.4.3",
|
"@tauri-apps/plugin-deep-link": "^2.4.3",
|
||||||
@@ -1135,7 +1135,6 @@
|
|||||||
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
|
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -1480,7 +1479,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1593,7 +1591,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -1615,7 +1612,6 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -1697,7 +1693,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
|
||||||
"integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==",
|
"integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.28",
|
"@vue/compiler-dom": "3.5.28",
|
||||||
"@vue/compiler-sfc": "3.5.28",
|
"@vue/compiler-sfc": "3.5.28",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gdown",
|
"name": "gdown",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24 <25"
|
"node": ">=24 <25"
|
||||||
@@ -16,8 +16,9 @@
|
|||||||
"tauri:build": "bash scripts/version-bump.sh && tauri build",
|
"tauri:build": "bash scripts/version-bump.sh && tauri build",
|
||||||
"version:bump": "bash scripts/version-bump.sh",
|
"version:bump": "bash scripts/version-bump.sh",
|
||||||
"sync:aria2": "bash scripts/sync-aria2-from-motrix.sh",
|
"sync:aria2": "bash scripts/sync-aria2-from-motrix.sh",
|
||||||
|
"sync:ytdlp": "bash scripts/sync-ytdlp-bundle.sh",
|
||||||
"native-host:smoke": "cd tools/native-host && npm run smoke",
|
"native-host:smoke": "cd tools/native-host && npm run smoke",
|
||||||
"native-host:install": "bash tools/native-host/install-macos.sh alaohbbicffclloghmknhlmfdbobcigc",
|
"native-host:install": "bash tools/native-host/install-macos.sh makoclohjdpempbndoaljeadpngefhcf",
|
||||||
"native-host:uninstall": "bash tools/native-host/uninstall-macos.sh"
|
"native-host:uninstall": "bash tools/native-host/uninstall-macos.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
28
scripts/sync-ytdlp-bundle.sh
Executable file
28
scripts/sync-ytdlp-bundle.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
DST_BASE="$ROOT_DIR/src-tauri/resources/engine"
|
||||||
|
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$DST_BASE/darwin/arm64" "$DST_BASE/darwin/x64"
|
||||||
|
|
||||||
|
YTDLP_URL="${YTDLP_URL:-https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos}"
|
||||||
|
TARGET_FILE="$TMP_DIR/yt-dlp"
|
||||||
|
|
||||||
|
echo "[sync-ytdlp] downloading: $YTDLP_URL"
|
||||||
|
curl -fL "$YTDLP_URL" -o "$TARGET_FILE"
|
||||||
|
chmod +x "$TARGET_FILE"
|
||||||
|
|
||||||
|
cp "$TARGET_FILE" "$DST_BASE/darwin/arm64/yt-dlp"
|
||||||
|
cp "$TARGET_FILE" "$DST_BASE/darwin/x64/yt-dlp"
|
||||||
|
chmod +x "$DST_BASE/darwin/arm64/yt-dlp" "$DST_BASE/darwin/x64/yt-dlp"
|
||||||
|
|
||||||
|
echo "[sync-ytdlp] copied to:"
|
||||||
|
echo " - $DST_BASE/darwin/arm64/yt-dlp"
|
||||||
|
echo " - $DST_BASE/darwin/x64/yt-dlp"
|
||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -77,7 +77,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
license = ""
|
license = ""
|
||||||
|
|||||||
BIN
src-tauri/resources/engine/darwin/arm64/yt-dlp
Executable file
BIN
src-tauri/resources/engine/darwin/arm64/yt-dlp
Executable file
Binary file not shown.
BIN
src-tauri/resources/engine/darwin/x64/yt-dlp
Executable file
BIN
src-tauri/resources/engine/darwin/x64/yt-dlp
Executable file
Binary file not shown.
@@ -42,6 +42,21 @@ pub struct Aria2AddUriRequest {
|
|||||||
pub position: Option<u32>,
|
pub position: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct YtDlpAddUriRequest {
|
||||||
|
pub rpc: Aria2RpcConfig,
|
||||||
|
pub url: String,
|
||||||
|
pub out: Option<String>,
|
||||||
|
pub dir: Option<String>,
|
||||||
|
pub split: Option<u16>,
|
||||||
|
pub format: Option<String>,
|
||||||
|
pub referer: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
pub options: Option<BTreeMap<String, Value>>,
|
||||||
|
pub position: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Aria2AddTorrentRequest {
|
pub struct Aria2AddTorrentRequest {
|
||||||
@@ -799,6 +814,335 @@ fn is_gid_not_found_error(message: &str) -> bool {
|
|||||||
lower.contains("not found for gid#")
|
lower.contains("not found for gid#")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_ytdlp_binary() -> String {
|
||||||
|
if cfg!(target_os = "windows") {
|
||||||
|
"yt-dlp.exe".to_string()
|
||||||
|
} else {
|
||||||
|
"yt-dlp".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_ytdlp_binary(app: &tauri::AppHandle) -> Option<String> {
|
||||||
|
let binary_name = default_ytdlp_binary();
|
||||||
|
let mut candidates: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for key in ["YTDLP_BIN", "YTDLP_PATH"] {
|
||||||
|
if let Ok(raw) = env::var(key) {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
candidates.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(path_var) = env::var("PATH") {
|
||||||
|
for dir in env::split_paths(&path_var) {
|
||||||
|
candidate_push(&dir.join(&binary_name), &mut candidates);
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
if !binary_name.to_ascii_lowercase().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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
for candidate in ["/opt/homebrew/bin/yt-dlp", "/usr/local/bin/yt-dlp", "/usr/bin/yt-dlp"] {
|
||||||
|
candidates.push(candidate.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deduped: Vec<String> = Vec::with_capacity(candidates.len());
|
||||||
|
for candidate in candidates {
|
||||||
|
if !deduped.contains(&candidate) {
|
||||||
|
deduped.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deduped.into_iter().find(|path| Path::new(path).is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ytdlp_get_url(
|
||||||
|
binary: &str,
|
||||||
|
page_url: &str,
|
||||||
|
format: Option<&str>,
|
||||||
|
referer: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
extra_headers: &[String],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut cmd = Command::new(binary);
|
||||||
|
cmd.arg("--no-playlist");
|
||||||
|
cmd.arg("--get-url");
|
||||||
|
if let Some(fmt) = format {
|
||||||
|
let trimmed = fmt.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("-f");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = referer {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("--referer");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = user_agent {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("--user-agent");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for header in extra_headers {
|
||||||
|
let trimmed = header.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("--add-header");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.arg(page_url.trim());
|
||||||
|
cmd.stdout(Stdio::piped());
|
||||||
|
cmd.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("yt-dlp 실행 실패: {err}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
return Err(format!(
|
||||||
|
"yt-dlp 처리 실패(status={}): {}",
|
||||||
|
output.status,
|
||||||
|
if stderr.is_empty() { "unknown error" } else { &stderr }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let direct = stdout
|
||||||
|
.lines()
|
||||||
|
.map(|line| line.trim())
|
||||||
|
.find(|line| !line.is_empty() && (line.starts_with("http://") || line.starts_with("https://")))
|
||||||
|
.map(|line| line.to_string())
|
||||||
|
.ok_or_else(|| "yt-dlp 결과에서 다운로드 URL을 찾지 못했습니다.".to_string())?;
|
||||||
|
|
||||||
|
Ok(direct)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_output_filename(raw: &str) -> String {
|
||||||
|
let mut value = raw.trim().replace(['\r', '\n', '\t'], " ");
|
||||||
|
value = value
|
||||||
|
.chars()
|
||||||
|
.map(|ch| match ch {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
_ => ch,
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
while value.contains(" ") {
|
||||||
|
value = value.replace(" ", " ");
|
||||||
|
}
|
||||||
|
value.trim_matches('.').trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ytdlp_get_filename(
|
||||||
|
binary: &str,
|
||||||
|
page_url: &str,
|
||||||
|
format: Option<&str>,
|
||||||
|
referer: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
extra_headers: &[String],
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let mut cmd = Command::new(binary);
|
||||||
|
cmd.arg("--no-playlist");
|
||||||
|
cmd.arg("--get-filename");
|
||||||
|
cmd.arg("-o");
|
||||||
|
cmd.arg("%(title).200B.%(ext)s");
|
||||||
|
if let Some(fmt) = format {
|
||||||
|
let trimmed = fmt.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("-f");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = referer {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("--referer");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = user_agent {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("--user-agent");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for header in extra_headers {
|
||||||
|
let trimmed = header.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("--add-header");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.arg(page_url.trim());
|
||||||
|
cmd.stdout(Stdio::piped());
|
||||||
|
cmd.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("yt-dlp 파일명 추출 실패: {err}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let raw = stdout
|
||||||
|
.lines()
|
||||||
|
.map(|line| line.trim())
|
||||||
|
.find(|line| !line.is_empty())
|
||||||
|
.unwrap_or("");
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitized = sanitize_output_filename(raw);
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(sanitized))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_hls_url(raw: &str) -> bool {
|
||||||
|
let lower = raw.to_ascii_lowercase();
|
||||||
|
lower.contains(".m3u8") || lower.contains(".m3u") || lower.contains("m3u8")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ytdlp_direct_download(
|
||||||
|
binary: &str,
|
||||||
|
page_url: &str,
|
||||||
|
out: Option<&str>,
|
||||||
|
dir: Option<&str>,
|
||||||
|
format: Option<&str>,
|
||||||
|
referer: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
extra_headers: &[String],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut cmd = Command::new(binary);
|
||||||
|
cmd.arg("--no-playlist");
|
||||||
|
cmd.arg("--merge-output-format");
|
||||||
|
cmd.arg("mp4");
|
||||||
|
cmd.arg("--remux-video");
|
||||||
|
cmd.arg("mp4");
|
||||||
|
cmd.arg("--restrict-filenames");
|
||||||
|
|
||||||
|
let fmt = format
|
||||||
|
.map(|v| v.trim())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.unwrap_or("best");
|
||||||
|
cmd.arg("-f");
|
||||||
|
cmd.arg(fmt);
|
||||||
|
|
||||||
|
if let Some(value) = referer {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("--referer");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = user_agent {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("--user-agent");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for header in extra_headers {
|
||||||
|
let trimmed = header.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("--add-header");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = dir {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("--paths");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = out {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
cmd.arg("-o");
|
||||||
|
cmd.arg(trimmed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cmd.arg("-o");
|
||||||
|
cmd.arg("%(title).180B.%(ext)s");
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.arg(page_url.trim());
|
||||||
|
cmd.stdin(Stdio::null());
|
||||||
|
cmd.stdout(Stdio::null());
|
||||||
|
cmd.stderr(Stdio::null());
|
||||||
|
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("yt-dlp 다운로드 시작 실패: {err}"))?;
|
||||||
|
let pid = child.id();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(format!("ytdlp:{pid}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ytdlp_headers_from_options(options: Option<&BTreeMap<String, Value>>) -> Vec<String> {
|
||||||
|
let Some(options) = options else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(raw) = options.get("header") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
match raw {
|
||||||
|
Value::String(v) => {
|
||||||
|
let trimmed = v.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![trimmed.to_string()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(arr) => arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.as_str().map(|v| v.trim().to_string()))
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn engine_start(
|
pub fn engine_start(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
@@ -968,6 +1312,67 @@ pub async fn aria2_add_uri(request: Aria2AddUriRequest) -> Result<String, String
|
|||||||
.ok_or_else(|| format!("aria2.addUri returned unexpected result: {result}"))
|
.ok_or_else(|| format!("aria2.addUri returned unexpected result: {result}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn yt_dlp_add_uri(app: tauri::AppHandle, request: YtDlpAddUriRequest) -> Result<String, String> {
|
||||||
|
let page_url = request.url.trim();
|
||||||
|
if page_url.is_empty() {
|
||||||
|
return Err("url is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let binary = resolve_ytdlp_binary(&app).ok_or_else(|| {
|
||||||
|
"yt-dlp 실행 파일을 찾을 수 없습니다. 번들(resources/engine) 또는 시스템 경로를 확인하세요."
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
let extra_headers = ytdlp_headers_from_options(request.options.as_ref());
|
||||||
|
|
||||||
|
if looks_like_hls_url(page_url) {
|
||||||
|
return run_ytdlp_direct_download(
|
||||||
|
&binary,
|
||||||
|
page_url,
|
||||||
|
request.out.as_deref(),
|
||||||
|
request.dir.as_deref(),
|
||||||
|
request.format.as_deref(),
|
||||||
|
request.referer.as_deref(),
|
||||||
|
request.user_agent.as_deref(),
|
||||||
|
&extra_headers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let direct_url = run_ytdlp_get_url(
|
||||||
|
&binary,
|
||||||
|
page_url,
|
||||||
|
request.format.as_deref(),
|
||||||
|
request.referer.as_deref(),
|
||||||
|
request.user_agent.as_deref(),
|
||||||
|
&extra_headers,
|
||||||
|
)?;
|
||||||
|
let suggested_out = run_ytdlp_get_filename(
|
||||||
|
&binary,
|
||||||
|
page_url,
|
||||||
|
request.format.as_deref(),
|
||||||
|
request.referer.as_deref(),
|
||||||
|
request.user_agent.as_deref(),
|
||||||
|
&extra_headers,
|
||||||
|
)?;
|
||||||
|
let final_out = if request.out.as_ref().map(|v| v.trim().is_empty()).unwrap_or(true) {
|
||||||
|
suggested_out
|
||||||
|
} else {
|
||||||
|
request.out.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let aria_request = Aria2AddUriRequest {
|
||||||
|
rpc: request.rpc,
|
||||||
|
uri: direct_url,
|
||||||
|
out: final_out,
|
||||||
|
dir: request.dir,
|
||||||
|
split: request.split,
|
||||||
|
options: request.options,
|
||||||
|
position: request.position,
|
||||||
|
};
|
||||||
|
|
||||||
|
aria2_add_uri(aria_request).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn aria2_add_torrent(request: Aria2AddTorrentRequest) -> Result<String, String> {
|
pub async fn aria2_add_torrent(request: Aria2AddTorrentRequest) -> Result<String, String> {
|
||||||
if request.torrent_base64.trim().is_empty() {
|
if request.torrent_base64.trim().is_empty() {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
mod engine;
|
mod engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
@@ -11,7 +13,7 @@ use engine::{
|
|||||||
aria2_remove_task_record, aria2_resume_all, aria2_resume_task, detect_aria2_binary,
|
aria2_remove_task_record, aria2_resume_all, aria2_resume_task, detect_aria2_binary,
|
||||||
engine_start, engine_status, engine_stop,
|
engine_start, engine_status, engine_stop,
|
||||||
load_torrent_file,
|
load_torrent_file,
|
||||||
open_path_in_file_manager, stop_engine_for_exit, EngineState,
|
open_path_in_file_manager, stop_engine_for_exit, yt_dlp_add_uri, EngineState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -44,6 +46,147 @@ struct ExternalAddRequest {
|
|||||||
cookie: Option<String>,
|
cookie: Option<String>,
|
||||||
proxy: Option<String>,
|
proxy: Option<String>,
|
||||||
split: Option<u32>,
|
split: Option<u32>,
|
||||||
|
extractor: Option<String>,
|
||||||
|
format: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_extension_ids() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"alaohbbicffclloghmknhlmfdbobcigc".to_string(),
|
||||||
|
"makoclohjdpempbndoaljeadpngefhcf".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_extension_ids() -> Vec<String> {
|
||||||
|
let mut ids = default_extension_ids();
|
||||||
|
for key in ["GDOWN_EXTENSION_ID", "GDOWN_EXTENSION_IDS"] {
|
||||||
|
if let Ok(raw) = env::var(key) {
|
||||||
|
for id in raw.split([',', ' ', '\n', '\t']) {
|
||||||
|
let trimmed = id.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !ids.iter().any(|v| v == trimmed) {
|
||||||
|
ids.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn merge_existing_allowed_origins(manifest_path: &Path, ids: &mut Vec<String>) {
|
||||||
|
let content = match fs::read_to_string(manifest_path) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let Some(origins) = parsed.get("allowed_origins").and_then(|v| v.as_array()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for origin in origins {
|
||||||
|
let Some(value) = origin.as_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let prefix = "chrome-extension://";
|
||||||
|
if !value.starts_with(prefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = value.trim_start_matches(prefix).trim_end_matches('/');
|
||||||
|
if id.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !ids.iter().any(|v| v == id) {
|
||||||
|
ids.push(id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_node_path() -> Option<PathBuf> {
|
||||||
|
if let Ok(path_var) = env::var("PATH") {
|
||||||
|
for dir in env::split_paths(&path_var) {
|
||||||
|
let node = dir.join("node");
|
||||||
|
if node.is_file() {
|
||||||
|
return Some(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidate in ["/usr/local/bin/node", "/opt/homebrew/bin/node", "/usr/bin/node"] {
|
||||||
|
let path = PathBuf::from(candidate);
|
||||||
|
if path.is_file() {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn ensure_native_host_installed() -> Result<PathBuf, String> {
|
||||||
|
let host_name = "org.gdown.nativehost";
|
||||||
|
let home = env::var("HOME").map_err(|err| format!("HOME 경로 확인 실패: {err}"))?;
|
||||||
|
let tools_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("tools")
|
||||||
|
.join("native-host");
|
||||||
|
let host_script = tools_dir.join("host.mjs");
|
||||||
|
if !host_script.is_file() {
|
||||||
|
return Err(format!("native host script not found: {}", host_script.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let node_path = resolve_node_path().ok_or_else(|| "node 경로를 찾을 수 없습니다.".to_string())?;
|
||||||
|
let runtime_dir = tools_dir.join(".runtime");
|
||||||
|
fs::create_dir_all(&runtime_dir).map_err(|err| format!("runtime 디렉터리 생성 실패: {err}"))?;
|
||||||
|
|
||||||
|
let runner_path = runtime_dir.join("run-host-macos.sh");
|
||||||
|
let runner_content = format!(
|
||||||
|
"#!/usr/bin/env bash\nset -euo pipefail\nexec \"{}\" \"{}\"\n",
|
||||||
|
node_path.display(),
|
||||||
|
host_script.display()
|
||||||
|
);
|
||||||
|
fs::write(&runner_path, runner_content).map_err(|err| format!("runner 생성 실패: {err}"))?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let perm = fs::Permissions::from_mode(0o755);
|
||||||
|
fs::set_permissions(&runner_path, perm).map_err(|err| format!("runner 권한 설정 실패: {err}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chrome_hosts_dir = Path::new(&home)
|
||||||
|
.join("Library")
|
||||||
|
.join("Application Support")
|
||||||
|
.join("Google")
|
||||||
|
.join("Chrome")
|
||||||
|
.join("NativeMessagingHosts");
|
||||||
|
fs::create_dir_all(&chrome_hosts_dir).map_err(|err| format!("native host 디렉터리 생성 실패: {err}"))?;
|
||||||
|
let manifest_path = chrome_hosts_dir.join(format!("{host_name}.json"));
|
||||||
|
|
||||||
|
let mut extension_ids = collect_extension_ids();
|
||||||
|
merge_existing_allowed_origins(&manifest_path, &mut extension_ids);
|
||||||
|
let allowed_origins = extension_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| format!("chrome-extension://{id}/"))
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
|
let manifest = serde_json::json!({
|
||||||
|
"name": host_name,
|
||||||
|
"description": "gdown Native Messaging Host",
|
||||||
|
"path": runner_path,
|
||||||
|
"type": "stdio",
|
||||||
|
"allowed_origins": allowed_origins,
|
||||||
|
});
|
||||||
|
|
||||||
|
let manifest_text = serde_json::to_string_pretty(&manifest)
|
||||||
|
.map_err(|err| format!("manifest 직렬화 실패: {err}"))?;
|
||||||
|
fs::write(&manifest_path, manifest_text).map_err(|err| format!("manifest 쓰기 실패: {err}"))?;
|
||||||
|
|
||||||
|
Ok(manifest_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn external_add_queue_path() -> Result<PathBuf, String> {
|
fn external_add_queue_path() -> Result<PathBuf, String> {
|
||||||
@@ -102,6 +245,13 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Err(err) = ensure_native_host_installed() {
|
||||||
|
eprintln!("[gdown] native host auto-install skipped: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
tauri_plugin_log::Builder::default()
|
tauri_plugin_log::Builder::default()
|
||||||
@@ -118,6 +268,7 @@ pub fn run() {
|
|||||||
detect_aria2_binary,
|
detect_aria2_binary,
|
||||||
aria2_add_torrent,
|
aria2_add_torrent,
|
||||||
aria2_add_uri,
|
aria2_add_uri,
|
||||||
|
yt_dlp_add_uri,
|
||||||
aria2_change_global_option,
|
aria2_change_global_option,
|
||||||
aria2_get_task_detail,
|
aria2_get_task_detail,
|
||||||
aria2_list_tasks,
|
aria2_list_tasks,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "gdown",
|
"productName": "gdown",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"identifier": "com.tauri.dev",
|
"identifier": "com.tauri.dev",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "gdown",
|
"title": "gdown",
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 860,
|
"height": 660,
|
||||||
"minWidth": 1080,
|
"minWidth": 1080,
|
||||||
"minHeight": 720,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false
|
"fullscreen": false
|
||||||
}
|
}
|
||||||
|
|||||||
296
src/App.vue
296
src/App.vue
@@ -24,6 +24,7 @@ import {
|
|||||||
resumeAria2Task,
|
resumeAria2Task,
|
||||||
startEngine,
|
startEngine,
|
||||||
stopEngine,
|
stopEngine,
|
||||||
|
ytDlpAddUri,
|
||||||
type Aria2Task,
|
type Aria2Task,
|
||||||
type Aria2TaskDetail,
|
type Aria2TaskDetail,
|
||||||
type Aria2TaskSnapshot,
|
type Aria2TaskSnapshot,
|
||||||
@@ -36,6 +37,12 @@ type AddTab = 'url' | 'torrent'
|
|||||||
type AppPage = 'downloads' | 'settings'
|
type AppPage = 'downloads' | 'settings'
|
||||||
type SettingsTab = 'basic' | 'advanced' | 'lab'
|
type SettingsTab = 'basic' | 'advanced' | 'lab'
|
||||||
type TaskInfoTab = 'general' | 'activity' | 'trackers' | 'peers' | 'files'
|
type TaskInfoTab = 'general' | 'activity' | 'trackers' | 'peers' | 'files'
|
||||||
|
type CategoryItem = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
extensions: string[]
|
||||||
|
}
|
||||||
type PersistedSettings = {
|
type PersistedSettings = {
|
||||||
binaryPath?: string
|
binaryPath?: string
|
||||||
rpcPort?: number
|
rpcPort?: number
|
||||||
@@ -75,7 +82,24 @@ type PersistedSettings = {
|
|||||||
protocolThunder?: boolean
|
protocolThunder?: boolean
|
||||||
userAgent?: string
|
userAgent?: string
|
||||||
skipTlsVerify?: boolean
|
skipTlsVerify?: boolean
|
||||||
|
useCategoriesByDefault?: boolean
|
||||||
}
|
}
|
||||||
|
type PendingTask = {
|
||||||
|
gid: string
|
||||||
|
fileName: string
|
||||||
|
uri: string
|
||||||
|
dir: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CATEGORIES: CategoryItem[] = [
|
||||||
|
{ id: 'compressed', name: 'Compressed', icon: '📄', extensions: ['zip', '7z', 'rar', 'tar', 'gz', 'bz2', 'xz'] },
|
||||||
|
{ id: 'programs', name: 'Programs', icon: '🧩', extensions: ['exe', 'msi', 'dmg', 'pkg', 'apk', 'ipa', 'deb', 'rpm'] },
|
||||||
|
{ id: 'videos', name: 'Videos', icon: '🎬', extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'webm'] },
|
||||||
|
{ id: 'music', name: 'Music', icon: '🎵', extensions: ['mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg'] },
|
||||||
|
{ id: 'pictures', name: 'Pictures', icon: '🖼', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'] },
|
||||||
|
{ id: 'documents', name: 'Documents', icon: '📑', extensions: ['pdf', 'doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'txt'] },
|
||||||
|
]
|
||||||
|
|
||||||
const SETTINGS_STORAGE_KEY = 'gdown.settings.v1'
|
const SETTINGS_STORAGE_KEY = 'gdown.settings.v1'
|
||||||
|
|
||||||
@@ -137,6 +161,7 @@ const settingProtocolMagnet = ref(true)
|
|||||||
const settingProtocolThunder = ref(false)
|
const settingProtocolThunder = ref(false)
|
||||||
const settingUserAgent = ref('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122 Safari/537.36')
|
const settingUserAgent = ref('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122 Safari/537.36')
|
||||||
const settingSkipTlsVerify = ref(true)
|
const settingSkipTlsVerify = ref(true)
|
||||||
|
const settingUseCategoriesByDefault = ref(true)
|
||||||
const trackerSyncing = ref(false)
|
const trackerSyncing = ref(false)
|
||||||
const rpcTestLoading = ref(false)
|
const rpcTestLoading = ref(false)
|
||||||
const rpcTestStatus = ref<'idle' | 'ok' | 'fail'>('idle')
|
const rpcTestStatus = ref<'idle' | 'ok' | 'fail'>('idle')
|
||||||
@@ -144,6 +169,8 @@ const rpcTestMessage = ref('')
|
|||||||
|
|
||||||
const showAddModal = ref(false)
|
const showAddModal = ref(false)
|
||||||
const addTab = ref<AddTab>('url')
|
const addTab = ref<AddTab>('url')
|
||||||
|
const addExtractor = ref<'aria2' | 'yt-dlp'>('aria2')
|
||||||
|
const addYtdlpFormat = ref('bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best')
|
||||||
const addUrl = ref('')
|
const addUrl = ref('')
|
||||||
const addOut = ref('')
|
const addOut = ref('')
|
||||||
const addSplit = ref(64)
|
const addSplit = ref(64)
|
||||||
@@ -154,6 +181,8 @@ const addReferer = ref('')
|
|||||||
const addCookie = ref('')
|
const addCookie = ref('')
|
||||||
const addProxy = ref('')
|
const addProxy = ref('')
|
||||||
const addNavigateToDownloading = ref(true)
|
const addNavigateToDownloading = ref(true)
|
||||||
|
const addUseCategory = ref(true)
|
||||||
|
const addCategoryId = ref(DEFAULT_CATEGORIES[0]?.id ?? 'compressed')
|
||||||
const torrentBase64 = ref('')
|
const torrentBase64 = ref('')
|
||||||
const torrentFileName = ref('')
|
const torrentFileName = ref('')
|
||||||
const torrentFileExt = ref('')
|
const torrentFileExt = ref('')
|
||||||
@@ -180,6 +209,7 @@ const tasks = ref<Aria2TaskSnapshot>({
|
|||||||
waiting: [],
|
waiting: [],
|
||||||
stopped: [],
|
stopped: [],
|
||||||
})
|
})
|
||||||
|
const pendingTasksByGid = ref<Record<string, PendingTask>>({})
|
||||||
|
|
||||||
let refreshTimer: number | null = null
|
let refreshTimer: number | null = null
|
||||||
let unlistenDragDrop: UnlistenFn | null = null
|
let unlistenDragDrop: UnlistenFn | null = null
|
||||||
@@ -234,6 +264,7 @@ function parseExternalAddDeepLink(rawUrl: string): ExternalAddPayload | null {
|
|||||||
function applyExternalAddPayload(payload: ExternalAddPayload) {
|
function applyExternalAddPayload(payload: ExternalAddPayload) {
|
||||||
showAddModal.value = true
|
showAddModal.value = true
|
||||||
addTab.value = 'url'
|
addTab.value = 'url'
|
||||||
|
addUseCategory.value = settingUseCategoriesByDefault.value
|
||||||
const current = addUrl.value.trim()
|
const current = addUrl.value.trim()
|
||||||
if (!current) {
|
if (!current) {
|
||||||
addUrl.value = payload.url
|
addUrl.value = payload.url
|
||||||
@@ -249,6 +280,17 @@ function applyExternalAddPayload(payload: ExternalAddPayload) {
|
|||||||
if (payload.cookie) addCookie.value = payload.cookie
|
if (payload.cookie) addCookie.value = payload.cookie
|
||||||
if (payload.proxy) addProxy.value = payload.proxy
|
if (payload.proxy) addProxy.value = payload.proxy
|
||||||
if (payload.split) addSplit.value = payload.split
|
if (payload.split) addSplit.value = payload.split
|
||||||
|
if (payload.extractor === 'yt-dlp') {
|
||||||
|
addExtractor.value = 'yt-dlp'
|
||||||
|
}
|
||||||
|
if (payload.format) {
|
||||||
|
addYtdlpFormat.value = payload.format
|
||||||
|
}
|
||||||
|
if (payload.out) {
|
||||||
|
applySuggestedCategory(payload.out)
|
||||||
|
} else {
|
||||||
|
applySuggestedCategory(payload.url)
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.referer || payload.userAgent || payload.authorization || payload.cookie || payload.proxy) {
|
if (payload.referer || payload.userAgent || payload.authorization || payload.cookie || payload.proxy) {
|
||||||
addShowAdvanced.value = true
|
addShowAdvanced.value = true
|
||||||
@@ -303,15 +345,18 @@ function isCompletedTask(task: Aria2Task): boolean {
|
|||||||
return Number.isFinite(total) && Number.isFinite(done) && total > 0 && done >= total
|
return Number.isFinite(total) && Number.isFinite(done) && total > 0 && done >= total
|
||||||
}
|
}
|
||||||
|
|
||||||
const completedTasks = computed(() => tasks.value.stopped.filter((task) => isCompletedTask(task)))
|
const completedTasks = computed(() =>
|
||||||
|
[...tasks.value.active, ...tasks.value.waiting, ...tasks.value.stopped].filter((task) => isCompletedTask(task))
|
||||||
|
)
|
||||||
const stoppedTasks = computed(() => tasks.value.stopped.filter((task) => !isCompletedTask(task)))
|
const stoppedTasks = computed(() => tasks.value.stopped.filter((task) => !isCompletedTask(task)))
|
||||||
|
|
||||||
const filteredTasks = computed(() => {
|
const filteredTasks = computed(() => {
|
||||||
if (filter.value === 'active') return tasks.value.active
|
const activeTasks = tasks.value.active.filter((task) => !isCompletedTask(task))
|
||||||
|
if (filter.value === 'active') return [...activeTasks, ...tasks.value.waiting]
|
||||||
if (filter.value === 'waiting') return tasks.value.waiting
|
if (filter.value === 'waiting') return tasks.value.waiting
|
||||||
if (filter.value === 'stopped') return stoppedTasks.value
|
if (filter.value === 'stopped') return stoppedTasks.value
|
||||||
if (filter.value === 'completed') return completedTasks.value
|
if (filter.value === 'completed') return completedTasks.value
|
||||||
return [...tasks.value.active, ...tasks.value.waiting, ...stoppedTasks.value, ...completedTasks.value]
|
return [...activeTasks, ...tasks.value.waiting, ...stoppedTasks.value, ...completedTasks.value]
|
||||||
})
|
})
|
||||||
const filteredTaskCount = computed(() => filteredTasks.value.length)
|
const filteredTaskCount = computed(() => filteredTasks.value.length)
|
||||||
const taskInfoLiveTask = computed(() => {
|
const taskInfoLiveTask = computed(() => {
|
||||||
@@ -338,6 +383,14 @@ const taskInfoSelectedFilesText = computed(() => {
|
|||||||
if (selectedCount === 0) return '파일 0개 선택됨'
|
if (selectedCount === 0) return '파일 0개 선택됨'
|
||||||
return `파일 ${selectedCount}개 선택됨, 총 ${formatBytesNumber(size)}`
|
return `파일 ${selectedCount}개 선택됨, 총 ${formatBytesNumber(size)}`
|
||||||
})
|
})
|
||||||
|
const addCategoryItems = computed(() => DEFAULT_CATEGORIES)
|
||||||
|
const addSelectedCategory = computed(() => addCategoryItems.value.find((item) => item.id === addCategoryId.value) ?? null)
|
||||||
|
const addResolvedDownloadDir = computed(() => {
|
||||||
|
const base = downloadDir.value.trim()
|
||||||
|
if (!base) return ''
|
||||||
|
if (!addUseCategory.value || !addSelectedCategory.value) return base
|
||||||
|
return `${base.replace(/[\\/]+$/, '')}/${addSelectedCategory.value.name}`
|
||||||
|
})
|
||||||
const rpcEndpointText = computed(() => `http://127.0.0.1:${sanitizePort(rpcPort.value, 6800)}/jsonrpc`)
|
const rpcEndpointText = computed(() => `http://127.0.0.1:${sanitizePort(rpcPort.value, 6800)}/jsonrpc`)
|
||||||
const rpcTokenText = computed(() => {
|
const rpcTokenText = computed(() => {
|
||||||
const secret = rpcSecret.value.trim()
|
const secret = rpcSecret.value.trim()
|
||||||
@@ -418,6 +471,9 @@ function loadSettingsFromStorage() {
|
|||||||
if (typeof parsed.protocolThunder === 'boolean') settingProtocolThunder.value = parsed.protocolThunder
|
if (typeof parsed.protocolThunder === 'boolean') settingProtocolThunder.value = parsed.protocolThunder
|
||||||
if (typeof parsed.userAgent === 'string') settingUserAgent.value = parsed.userAgent
|
if (typeof parsed.userAgent === 'string') settingUserAgent.value = parsed.userAgent
|
||||||
if (typeof parsed.skipTlsVerify === 'boolean') settingSkipTlsVerify.value = parsed.skipTlsVerify
|
if (typeof parsed.skipTlsVerify === 'boolean') settingSkipTlsVerify.value = parsed.skipTlsVerify
|
||||||
|
if (typeof parsed.useCategoriesByDefault === 'boolean') {
|
||||||
|
settingUseCategoriesByDefault.value = parsed.useCategoriesByDefault
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed settings
|
// ignore malformed settings
|
||||||
}
|
}
|
||||||
@@ -463,6 +519,7 @@ function saveSettingsToStorage() {
|
|||||||
protocolThunder: settingProtocolThunder.value,
|
protocolThunder: settingProtocolThunder.value,
|
||||||
userAgent: settingUserAgent.value,
|
userAgent: settingUserAgent.value,
|
||||||
skipTlsVerify: settingSkipTlsVerify.value,
|
skipTlsVerify: settingSkipTlsVerify.value,
|
||||||
|
useCategoriesByDefault: settingUseCategoriesByDefault.value,
|
||||||
}
|
}
|
||||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(payload))
|
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(payload))
|
||||||
}
|
}
|
||||||
@@ -545,6 +602,32 @@ function progress(task: Aria2Task): number {
|
|||||||
return Math.min(100, Math.max(0, (done / total) * 100))
|
return Math.min(100, Math.max(0, (done / total) * 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extensionFromName(value: string): string {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
const qIndex = trimmed.indexOf('?')
|
||||||
|
const hashIndex = trimmed.indexOf('#')
|
||||||
|
let endIndex = trimmed.length
|
||||||
|
if (qIndex >= 0) endIndex = Math.min(endIndex, qIndex)
|
||||||
|
if (hashIndex >= 0) endIndex = Math.min(endIndex, hashIndex)
|
||||||
|
const clean = trimmed.slice(0, endIndex)
|
||||||
|
const dot = clean.lastIndexOf('.')
|
||||||
|
if (dot < 0 || dot === clean.length - 1) return ''
|
||||||
|
return clean.slice(dot + 1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectCategoryId(input: string): string | null {
|
||||||
|
const ext = extensionFromName(input)
|
||||||
|
if (!ext) return null
|
||||||
|
const found = DEFAULT_CATEGORIES.find((item) => item.extensions.includes(ext))
|
||||||
|
return found?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySuggestedCategory(input: string) {
|
||||||
|
const detected = detectCategoryId(input)
|
||||||
|
if (detected) addCategoryId.value = detected
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshEngineStatus() {
|
async function refreshEngineStatus() {
|
||||||
try {
|
try {
|
||||||
status.value = await getEngineStatus()
|
status.value = await getEngineStatus()
|
||||||
@@ -556,12 +639,14 @@ async function refreshEngineStatus() {
|
|||||||
async function refreshTasks(silent = false) {
|
async function refreshTasks(silent = false) {
|
||||||
if (!status.value.running) {
|
if (!status.value.running) {
|
||||||
tasks.value = { active: [], waiting: [], stopped: [] }
|
tasks.value = { active: [], waiting: [], stopped: [] }
|
||||||
|
pendingTasksByGid.value = {}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!silent) loadingTasks.value = true
|
if (!silent) loadingTasks.value = true
|
||||||
try {
|
try {
|
||||||
tasks.value = await listAria2Tasks(rpcConfig())
|
const snapshot = await listAria2Tasks(rpcConfig())
|
||||||
|
tasks.value = mergePendingTasks(snapshot)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushError(String(error))
|
pushError(String(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -569,6 +654,74 @@ async function refreshTasks(silent = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskNameMissing(task: Aria2Task): boolean {
|
||||||
|
const name = task.fileName?.trim() ?? ''
|
||||||
|
return !name || name === '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingToTask(pending: PendingTask): Aria2Task {
|
||||||
|
return {
|
||||||
|
gid: pending.gid,
|
||||||
|
status: 'waiting',
|
||||||
|
totalLength: '0',
|
||||||
|
completedLength: '0',
|
||||||
|
downloadSpeed: '0',
|
||||||
|
dir: pending.dir || '-',
|
||||||
|
fileName: pending.fileName || '-',
|
||||||
|
uri: pending.uri,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePendingTasks(snapshot: Aria2TaskSnapshot): Aria2TaskSnapshot {
|
||||||
|
const now = Date.now()
|
||||||
|
const pending = { ...pendingTasksByGid.value }
|
||||||
|
const seen = new Map<string, Aria2Task>()
|
||||||
|
|
||||||
|
const hydrate = (task: Aria2Task): Aria2Task => {
|
||||||
|
const pendingTask = pending[task.gid]
|
||||||
|
if (!pendingTask) {
|
||||||
|
seen.set(task.gid, task)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
const merged: Aria2Task = {
|
||||||
|
...task,
|
||||||
|
fileName: isTaskNameMissing(task) ? pendingTask.fileName : task.fileName,
|
||||||
|
dir: task.dir?.trim() && task.dir !== '-' ? task.dir : pendingTask.dir,
|
||||||
|
uri: task.uri?.trim() ? task.uri : pendingTask.uri,
|
||||||
|
}
|
||||||
|
seen.set(task.gid, merged)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: Aria2TaskSnapshot = {
|
||||||
|
active: snapshot.active.map(hydrate),
|
||||||
|
waiting: snapshot.waiting.map(hydrate),
|
||||||
|
stopped: snapshot.stopped.map(hydrate),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [gid, pendingTask] of Object.entries(pending)) {
|
||||||
|
const current = seen.get(gid)
|
||||||
|
if (current) {
|
||||||
|
const knownSize = Number(current.totalLength) > 0
|
||||||
|
const hasName = !isTaskNameMissing(current)
|
||||||
|
const isFinal = current.status === 'complete' || current.status === 'removed' || current.status === 'error'
|
||||||
|
if ((hasName && knownSize) || isFinal || now - pendingTask.createdAt > 60_000) {
|
||||||
|
delete pending[gid]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - pendingTask.createdAt > 30_000) {
|
||||||
|
delete pending[gid]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.waiting.unshift(pendingToTask(pendingTask))
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingTasksByGid.value = pending
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshTaskInfo(silent = false) {
|
async function refreshTaskInfo(silent = false) {
|
||||||
if (!taskInfoVisible.value || !taskInfoTask.value) return
|
if (!taskInfoVisible.value || !taskInfoTask.value) return
|
||||||
if (!status.value.running) return
|
if (!status.value.running) return
|
||||||
@@ -736,6 +889,11 @@ function taskPrimaryTitle(task: Aria2Task): string {
|
|||||||
return task.status === 'active' ? '정지' : '재개'
|
return task.status === 'active' ? '정지' : '재개'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskToggleable(task: Aria2Task): boolean {
|
||||||
|
if (isCompletedTask(task)) return false
|
||||||
|
return task.status === 'active' || task.status === 'waiting' || task.status === 'paused'
|
||||||
|
}
|
||||||
|
|
||||||
async function onOpenTaskFolder(task: Aria2Task) {
|
async function onOpenTaskFolder(task: Aria2Task) {
|
||||||
const dir = task.dir?.trim()
|
const dir = task.dir?.trim()
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
@@ -823,6 +981,19 @@ function statusLabel(status: string | undefined): string {
|
|||||||
return upper
|
return upper
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function taskListStateText(task: Aria2Task): string {
|
||||||
|
if (isCompletedTask(task)) return '완료'
|
||||||
|
if (task.status === 'active') return '다운로드 중'
|
||||||
|
if (task.status === 'paused') return '일시정지'
|
||||||
|
if (task.status === 'waiting') {
|
||||||
|
const total = Number(task.totalLength)
|
||||||
|
if (!Number.isFinite(total) || total <= 0) return '대기열에 추가 중'
|
||||||
|
return '대기 중'
|
||||||
|
}
|
||||||
|
if (task.status === 'error') return '오류'
|
||||||
|
return statusLabel(task.status)
|
||||||
|
}
|
||||||
|
|
||||||
function parsePeerClient(peerId: string): string {
|
function parsePeerClient(peerId: string): string {
|
||||||
const value = peerId.trim()
|
const value = peerId.trim()
|
||||||
if (!value) return '-'
|
if (!value) return '-'
|
||||||
@@ -930,6 +1101,7 @@ async function inspectAddedTask(gid: string) {
|
|||||||
function openAddModal() {
|
function openAddModal() {
|
||||||
showAddModal.value = true
|
showAddModal.value = true
|
||||||
addTab.value = 'url'
|
addTab.value = 'url'
|
||||||
|
addUseCategory.value = settingUseCategoriesByDefault.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDownloadsPage() {
|
function openDownloadsPage() {
|
||||||
@@ -1133,11 +1305,13 @@ function closeAddModal() {
|
|||||||
showAddModal.value = false
|
showAddModal.value = false
|
||||||
modalDropActive.value = false
|
modalDropActive.value = false
|
||||||
addShowAdvanced.value = false
|
addShowAdvanced.value = false
|
||||||
|
addUseCategory.value = settingUseCategoriesByDefault.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddModalForTorrent() {
|
function openAddModalForTorrent() {
|
||||||
showAddModal.value = true
|
showAddModal.value = true
|
||||||
addTab.value = 'torrent'
|
addTab.value = 'torrent'
|
||||||
|
addUseCategory.value = settingUseCategoriesByDefault.value
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toBase64(file: File): Promise<string> {
|
async function toBase64(file: File): Promise<string> {
|
||||||
@@ -1177,6 +1351,7 @@ async function applyTorrentFile(file: File) {
|
|||||||
if (!addOut.value) {
|
if (!addOut.value) {
|
||||||
addOut.value = file.name.replace(/\.torrent$/i, '')
|
addOut.value = file.name.replace(/\.torrent$/i, '')
|
||||||
}
|
}
|
||||||
|
applySuggestedCategory(file.name)
|
||||||
|
|
||||||
openAddModalForTorrent()
|
openAddModalForTorrent()
|
||||||
}
|
}
|
||||||
@@ -1190,6 +1365,7 @@ async function applyTorrentPath(path: string) {
|
|||||||
if (!addOut.value) {
|
if (!addOut.value) {
|
||||||
addOut.value = payload.name.replace(/\.torrent$/i, '')
|
addOut.value = payload.name.replace(/\.torrent$/i, '')
|
||||||
}
|
}
|
||||||
|
applySuggestedCategory(payload.name)
|
||||||
openAddModalForTorrent()
|
openAddModalForTorrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1280,19 +1456,25 @@ function guessFileNameFromUri(uri: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isYtDlpDetachedGid(gid: string): boolean {
|
||||||
|
return gid.startsWith('ytdlp:')
|
||||||
|
}
|
||||||
|
|
||||||
function prependPendingTask(gid: string, fileName: string, uri: string) {
|
function prependPendingTask(gid: string, fileName: string, uri: string) {
|
||||||
const exists = [...tasks.value.active, ...tasks.value.waiting, ...tasks.value.stopped].some((task) => task.gid === gid)
|
const exists = [...tasks.value.active, ...tasks.value.waiting, ...tasks.value.stopped].some((task) => task.gid === gid)
|
||||||
if (exists) return
|
if (exists) return
|
||||||
tasks.value.active.unshift({
|
const pending: PendingTask = {
|
||||||
gid,
|
gid,
|
||||||
status: 'active',
|
|
||||||
totalLength: '0',
|
|
||||||
completedLength: '0',
|
|
||||||
downloadSpeed: '0',
|
|
||||||
dir: downloadDir.value.trim() || '-',
|
|
||||||
fileName: fileName.trim() || '-',
|
fileName: fileName.trim() || '-',
|
||||||
uri: uri.trim(),
|
uri: uri.trim(),
|
||||||
})
|
dir: addResolvedDownloadDir.value || downloadDir.value.trim() || '-',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
pendingTasksByGid.value = {
|
||||||
|
...pendingTasksByGid.value,
|
||||||
|
[gid]: pending,
|
||||||
|
}
|
||||||
|
tasks.value.waiting.unshift(pendingToTask(pending))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWindowDragEnter(event: DragEvent) {
|
function onWindowDragEnter(event: DragEvent) {
|
||||||
@@ -1349,6 +1531,8 @@ async function onSubmitAddTask() {
|
|||||||
addOptions.header = headers
|
addOptions.header = headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetDir = addResolvedDownloadDir.value.trim() || undefined
|
||||||
|
|
||||||
if (addTab.value === 'url') {
|
if (addTab.value === 'url') {
|
||||||
const uris = addUrl.value
|
const uris = addUrl.value
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -1360,19 +1544,38 @@ async function onSubmitAddTask() {
|
|||||||
}
|
}
|
||||||
const gids: string[] = []
|
const gids: string[] = []
|
||||||
for (const uri of uris) {
|
for (const uri of uris) {
|
||||||
const gid = await addAria2Uri({
|
const gid =
|
||||||
rpc: rpcConfig(),
|
addExtractor.value === 'yt-dlp'
|
||||||
uri,
|
? await ytDlpAddUri({
|
||||||
out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined,
|
rpc: rpcConfig(),
|
||||||
dir: downloadDir.value.trim() || undefined,
|
url: uri,
|
||||||
split: addSplit.value,
|
out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined,
|
||||||
options: Object.keys(addOptions).length > 0 ? addOptions : undefined,
|
dir: targetDir,
|
||||||
})
|
split: addSplit.value,
|
||||||
|
format: addYtdlpFormat.value.trim() || undefined,
|
||||||
|
referer: addReferer.value.trim() || undefined,
|
||||||
|
userAgent: addUserAgent.value.trim() || undefined,
|
||||||
|
options: Object.keys(addOptions).length > 0 ? addOptions : undefined,
|
||||||
|
})
|
||||||
|
: await addAria2Uri({
|
||||||
|
rpc: rpcConfig(),
|
||||||
|
uri,
|
||||||
|
out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined,
|
||||||
|
dir: targetDir,
|
||||||
|
split: addSplit.value,
|
||||||
|
options: Object.keys(addOptions).length > 0 ? addOptions : undefined,
|
||||||
|
})
|
||||||
gids.push(gid)
|
gids.push(gid)
|
||||||
void inspectAddedTask(gid)
|
if (!isYtDlpDetachedGid(gid)) {
|
||||||
prependPendingTask(gid, uris.length === 1 ? addOut.value || guessFileNameFromUri(uri) : guessFileNameFromUri(uri), uri)
|
void inspectAddedTask(gid)
|
||||||
|
prependPendingTask(gid, uris.length === 1 ? addOut.value || guessFileNameFromUri(uri) : guessFileNameFromUri(uri), uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gids.some((gid) => isYtDlpDetachedGid(gid))) {
|
||||||
|
pushSuccess(`${gids.length}개 작업이 시작되었습니다. (m3u8/hls는 yt-dlp 직접 다운로드)`)
|
||||||
|
} else {
|
||||||
|
pushSuccess(`${gids.length}개 작업이 추가되었습니다.`)
|
||||||
}
|
}
|
||||||
pushSuccess(`${gids.length}개 작업이 추가되었습니다.`)
|
|
||||||
} else {
|
} else {
|
||||||
if (!torrentBase64.value.trim()) {
|
if (!torrentBase64.value.trim()) {
|
||||||
pushError('.torrent 파일을 선택하세요.')
|
pushError('.torrent 파일을 선택하세요.')
|
||||||
@@ -1382,7 +1585,7 @@ async function onSubmitAddTask() {
|
|||||||
rpc: rpcConfig(),
|
rpc: rpcConfig(),
|
||||||
torrentBase64: torrentBase64.value,
|
torrentBase64: torrentBase64.value,
|
||||||
out: addOut.value.trim() || undefined,
|
out: addOut.value.trim() || undefined,
|
||||||
dir: downloadDir.value.trim() || undefined,
|
dir: targetDir,
|
||||||
split: addSplit.value,
|
split: addSplit.value,
|
||||||
options: Object.keys(addOptions).length > 0 ? addOptions : undefined,
|
options: Object.keys(addOptions).length > 0 ? addOptions : undefined,
|
||||||
})
|
})
|
||||||
@@ -1397,6 +1600,8 @@ async function onSubmitAddTask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addUrl.value = ''
|
addUrl.value = ''
|
||||||
|
addExtractor.value = 'aria2'
|
||||||
|
addYtdlpFormat.value = 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best'
|
||||||
addOut.value = ''
|
addOut.value = ''
|
||||||
addShowAdvanced.value = false
|
addShowAdvanced.value = false
|
||||||
addUserAgent.value = ''
|
addUserAgent.value = ''
|
||||||
@@ -1405,6 +1610,7 @@ async function onSubmitAddTask() {
|
|||||||
addCookie.value = ''
|
addCookie.value = ''
|
||||||
addProxy.value = ''
|
addProxy.value = ''
|
||||||
addNavigateToDownloading.value = true
|
addNavigateToDownloading.value = true
|
||||||
|
addUseCategory.value = settingUseCategoriesByDefault.value
|
||||||
torrentBase64.value = ''
|
torrentBase64.value = ''
|
||||||
torrentFileName.value = ''
|
torrentFileName.value = ''
|
||||||
torrentFileExt.value = ''
|
torrentFileExt.value = ''
|
||||||
@@ -1519,7 +1725,7 @@ onUnmounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<main class="app-shell" :class="{ 'app-drop-active': appDropActive }">
|
<main class="app-shell" :class="{ 'app-drop-active': appDropActive }">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">m</div>
|
<div class="brand">G</div>
|
||||||
<div class="side-icons">
|
<div class="side-icons">
|
||||||
<button class="side-icon" :class="{ active: page === 'downloads' }" title="목록" @click="openDownloadsPage">
|
<button class="side-icon" :class="{ active: page === 'downloads' }" title="목록" @click="openDownloadsPage">
|
||||||
☰
|
☰
|
||||||
@@ -1588,7 +1794,7 @@ onUnmounted(() => {
|
|||||||
<div class="task-actions-pill">
|
<div class="task-actions-pill">
|
||||||
<button
|
<button
|
||||||
class="icon-pill ghost"
|
class="icon-pill ghost"
|
||||||
:disabled="loadingTaskAction || task.status === 'stopped'"
|
:disabled="loadingTaskAction || !isTaskToggleable(task)"
|
||||||
:title="taskPrimaryTitle(task)"
|
:title="taskPrimaryTitle(task)"
|
||||||
@click="onTaskAction(task, taskPrimaryAction(task))"
|
@click="onTaskAction(task, taskPrimaryAction(task))"
|
||||||
>
|
>
|
||||||
@@ -1619,6 +1825,7 @@ onUnmounted(() => {
|
|||||||
<div class="task-meta-row">
|
<div class="task-meta-row">
|
||||||
<span>{{ formatBytes(task.completedLength) }} / {{ formatBytes(task.totalLength) }}</span>
|
<span>{{ formatBytes(task.completedLength) }} / {{ formatBytes(task.totalLength) }}</span>
|
||||||
<span class="task-meta-right">
|
<span class="task-meta-right">
|
||||||
|
<span>{{ taskListStateText(task) }}</span>
|
||||||
<span>{{ formatSpeed(task.downloadSpeed) }}</span>
|
<span>{{ formatSpeed(task.downloadSpeed) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1880,6 +2087,7 @@ onUnmounted(() => {
|
|||||||
폴더 선택
|
폴더 선택
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="check-row"><input v-model="settingUseCategoriesByDefault" type="checkbox" /> 기본값으로 범주 사용</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-group settings-card-section two-col">
|
<section class="settings-group settings-card-section two-col">
|
||||||
@@ -2055,6 +2263,19 @@ onUnmounted(() => {
|
|||||||
<span>URL</span>
|
<span>URL</span>
|
||||||
<textarea v-model="addUrl" placeholder="한 줄에 작업 URL 하나 (HTTP / FTP / Magnet)" rows="4" />
|
<textarea v-model="addUrl" placeholder="한 줄에 작업 URL 하나 (HTTP / FTP / Magnet)" rows="4" />
|
||||||
</label>
|
</label>
|
||||||
|
<div class="modal-row">
|
||||||
|
<label>
|
||||||
|
<span>처리 엔진</span>
|
||||||
|
<select v-model="addExtractor">
|
||||||
|
<option value="aria2">aria2 (직접 URL)</option>
|
||||||
|
<option value="yt-dlp">yt-dlp (페이지 URL 추출)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="addExtractor === 'yt-dlp'">
|
||||||
|
<span>yt-dlp 포맷</span>
|
||||||
|
<input v-model="addYtdlpFormat" type="text" placeholder="bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -2115,6 +2336,31 @@ onUnmounted(() => {
|
|||||||
<input v-model="downloadDir" type="text" placeholder="/Users/.../Downloads" />
|
<input v-model="downloadDir" type="text" placeholder="/Users/.../Downloads" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<section class="category-panel">
|
||||||
|
<div class="category-row">
|
||||||
|
<label class="check-row"><input v-model="addUseCategory" type="checkbox" /> 범주 사용</label>
|
||||||
|
<label class="category-select-label">
|
||||||
|
<select v-model="addCategoryId" :disabled="!addUseCategory">
|
||||||
|
<option v-for="item in addCategoryItems" :key="item.id" :value="item.id">
|
||||||
|
{{ item.icon }} {{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ghost tiny"
|
||||||
|
:disabled="!addUseCategory"
|
||||||
|
@click="pushSuccess('범주 추가 UI는 다음 단계에서 구현합니다.')"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>적용 폴더</span>
|
||||||
|
<input :value="addResolvedDownloadDir || downloadDir" type="text" readonly />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
<transition name="expand-advanced">
|
<transition name="expand-advanced">
|
||||||
<div v-if="addShowAdvanced" class="modal-advanced">
|
<div v-if="addShowAdvanced" class="modal-advanced">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -105,6 +105,19 @@ export interface AddUriPayload {
|
|||||||
position?: number
|
position?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface YtDlpAddUriPayload {
|
||||||
|
rpc: Aria2RpcConfig
|
||||||
|
url: string
|
||||||
|
out?: string
|
||||||
|
dir?: string
|
||||||
|
split?: number
|
||||||
|
format?: string
|
||||||
|
referer?: string
|
||||||
|
userAgent?: string
|
||||||
|
options?: Record<string, string | string[]>
|
||||||
|
position?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AddTorrentPayload {
|
export interface AddTorrentPayload {
|
||||||
rpc: Aria2RpcConfig
|
rpc: Aria2RpcConfig
|
||||||
torrentBase64: string
|
torrentBase64: string
|
||||||
@@ -141,6 +154,8 @@ export interface ExternalAddRequest {
|
|||||||
cookie?: string
|
cookie?: string
|
||||||
proxy?: string
|
proxy?: string
|
||||||
split?: number
|
split?: number
|
||||||
|
extractor?: string
|
||||||
|
format?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEngineStatus(): Promise<EngineStatus> {
|
export async function getEngineStatus(): Promise<EngineStatus> {
|
||||||
@@ -171,6 +186,10 @@ export async function addAria2Uri(payload: AddUriPayload): Promise<string> {
|
|||||||
return invoke<string>('aria2_add_uri', { request: payload })
|
return invoke<string>('aria2_add_uri', { request: payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ytDlpAddUri(payload: YtDlpAddUriPayload): Promise<string> {
|
||||||
|
return invoke<string>('yt_dlp_add_uri', { request: payload })
|
||||||
|
}
|
||||||
|
|
||||||
export async function addAria2Torrent(payload: AddTorrentPayload): Promise<string> {
|
export async function addAria2Torrent(payload: AddTorrentPayload): Promise<string> {
|
||||||
return invoke<string>('aria2_add_torrent', { request: payload })
|
return invoke<string>('aria2_add_torrent', { request: payload })
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/style.css
107
src/style.css
@@ -1,7 +1,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-app: #121317;
|
--bg-app: #121317;
|
||||||
--bg-sidebar: #0a0b0d;
|
--bg-sidebar: #2b1f57;
|
||||||
--bg-sidebar-active: #202227;
|
--bg-sidebar-active: #473385;
|
||||||
--bg-main: #15171c;
|
--bg-main: #15171c;
|
||||||
--bg-card: #1f2127;
|
--bg-card: #1f2127;
|
||||||
--bg-card-soft: #2a2d34;
|
--bg-card-soft: #2a2d34;
|
||||||
@@ -57,7 +57,7 @@ body {
|
|||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background: var(--bg-sidebar);
|
background: var(--bg-sidebar);
|
||||||
border-right: 1px solid #14161b;
|
border-right: 1px solid #3f2f74;
|
||||||
padding: 12px 8px;
|
padding: 12px 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -91,7 +91,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-icon:hover,
|
.side-icon:hover,
|
||||||
.side-icon.add { background: #15181e; color: #d8def0; }
|
.side-icon.add { background: #3d2f72; color: #ede8ff; }
|
||||||
|
|
||||||
.sidebar nav {
|
.sidebar nav {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
@@ -102,7 +102,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar nav a {
|
.sidebar nav a {
|
||||||
color: #98a3b8;
|
color: #d2caf3;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -111,7 +111,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar nav a.active,
|
.sidebar nav a.active,
|
||||||
.sidebar nav a:hover { background: var(--bg-sidebar-active); color: #ecf0fa; }
|
.sidebar nav a:hover { background: var(--bg-sidebar-active); color: #f2edff; }
|
||||||
|
|
||||||
.sidebar-foot { margin-top: auto; }
|
.sidebar-foot { margin-top: auto; }
|
||||||
|
|
||||||
@@ -648,14 +648,16 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-modal input,
|
.add-modal input,
|
||||||
|
.add-modal select,
|
||||||
.add-modal textarea {
|
.add-modal textarea {
|
||||||
height: 33px;
|
height: 33px;
|
||||||
border: 1px solid #434955;
|
border: 1px solid #434955;
|
||||||
border-radius: 7px;
|
border-radius: 4px;
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
background: #242830;
|
background: #20242c;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-modal textarea {
|
.add-modal textarea {
|
||||||
@@ -665,10 +667,11 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-modal input:focus,
|
.add-modal input:focus,
|
||||||
.add-modal textarea:focus {
|
.add-modal textarea:focus,
|
||||||
|
.add-modal select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #656cf5;
|
border-color: #656cf5;
|
||||||
box-shadow: 0 0 0 2px rgba(94, 98, 243, 0.18);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -714,18 +717,22 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.45);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 90px;
|
padding: 18px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-modal {
|
.add-modal {
|
||||||
width: min(680px, calc(100vw - 32px));
|
width: min(680px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 36px);
|
||||||
background: var(--bg-card-soft);
|
background: var(--bg-card-soft);
|
||||||
border: 1px solid #434955;
|
border: 1px solid #434955;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
box-shadow: 0 22px 56px rgba(0, 0, 0, 0.44);
|
box-shadow: 0 22px 56px rgba(0, 0, 0, 0.44);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -767,6 +774,9 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body label {
|
.modal-body label {
|
||||||
@@ -779,6 +789,65 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
|
|
||||||
.modal-body small { color: #919cb2; font-size: 0.74rem; }
|
.modal-body small { color: #919cb2; font-size: 0.74rem; }
|
||||||
|
|
||||||
|
.category-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #3a404c;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #20242c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-select-label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-select-label select {
|
||||||
|
width: 100%;
|
||||||
|
height: 33px;
|
||||||
|
border: 1px solid #3f4654;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
background: #20242c;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-select-label select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #666ef5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-select-label select option {
|
||||||
|
color: #dce2f0;
|
||||||
|
background: #1d2128;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-row button.ghost.tiny {
|
||||||
|
min-width: 34px;
|
||||||
|
height: 33px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1d2128;
|
||||||
|
border-color: #3f4654;
|
||||||
|
color: #aeb8cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-row button.ghost.tiny:hover:not(:disabled) {
|
||||||
|
background: #242933;
|
||||||
|
border-color: #4a5263;
|
||||||
|
color: #d0d7e7;
|
||||||
|
}
|
||||||
|
|
||||||
.torrent-drop-zone {
|
.torrent-drop-zone {
|
||||||
border: 1px dashed #5a606f;
|
border: 1px dashed #5a606f;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -852,6 +921,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer-actions {
|
.modal-footer-actions {
|
||||||
@@ -896,6 +966,19 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
height: 15px;
|
height: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-height: 760px) {
|
||||||
|
.modal-backdrop {
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-modal {
|
||||||
|
width: min(680px, calc(100vw - 20px));
|
||||||
|
max-height: calc(100vh - 20px);
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.expand-advanced-enter-active,
|
.expand-advanced-enter-active,
|
||||||
.expand-advanced-leave-active {
|
.expand-advanced-leave-active {
|
||||||
transition: opacity 140ms ease, transform 140ms ease;
|
transition: opacity 140ms ease, transform 140ms ease;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { execFile } from 'node:child_process'
|
|
||||||
import { appendFile, mkdir } from 'node:fs/promises'
|
import { appendFile, mkdir } from 'node:fs/promises'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
@@ -45,40 +44,7 @@ async function enqueueExternalAdd(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function focusGdownApp() {
|
function focusGdownApp() {
|
||||||
return new Promise((resolve) => {
|
return Promise.resolve({ ok: true, note: 'focus disabled by design' })
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
resolve({ ok: true, note: 'focus noop on non-macos in step1 host' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const attempts = [
|
|
||||||
['osascript', ['-e', 'tell application id "com.tauri.dev" to activate']],
|
|
||||||
['osascript', ['-e', 'tell application "gdown" to activate']],
|
|
||||||
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "gdown" to true']],
|
|
||||||
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "Gdown" to true']],
|
|
||||||
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "app" to true']],
|
|
||||||
['open', ['-b', 'com.tauri.dev']],
|
|
||||||
['open', ['-a', 'gdown']],
|
|
||||||
]
|
|
||||||
|
|
||||||
const run = (index) => {
|
|
||||||
if (index >= attempts.length) {
|
|
||||||
resolve({ ok: false, message: 'all focus strategies failed' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const [bin, args] = attempts[index]
|
|
||||||
execFile(bin, args, (error) => {
|
|
||||||
if (!error) {
|
|
||||||
resolve({ ok: true, strategy: `${bin} ${args.join(' ')}` })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run(index + 1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
run(0)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRequest(message) {
|
async function handleRequest(message) {
|
||||||
@@ -89,7 +55,7 @@ async function handleRequest(message) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
host: 'org.gdown.nativehost',
|
host: 'org.gdown.nativehost',
|
||||||
capabilities: ['ping', 'addUri', 'focus'],
|
capabilities: ['ping', 'addUri', 'focus', 'extractor:yt-dlp'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +70,8 @@ async function handleRequest(message) {
|
|||||||
const authorization = String(message?.authorization || '').trim()
|
const authorization = String(message?.authorization || '').trim()
|
||||||
const proxy = String(message?.proxy || '').trim()
|
const proxy = String(message?.proxy || '').trim()
|
||||||
const split = Number(message?.split || 0)
|
const split = Number(message?.split || 0)
|
||||||
|
const extractor = String(message?.extractor || '').trim()
|
||||||
|
const format = String(message?.format || '').trim()
|
||||||
|
|
||||||
const parsedCookie = parseCookieHeader(Array.isArray(message?.headers) ? message.headers : [])
|
const parsedCookie = parseCookieHeader(Array.isArray(message?.headers) ? message.headers : [])
|
||||||
const cookieValue = cookie || parsedCookie
|
const cookieValue = cookie || parsedCookie
|
||||||
@@ -118,8 +86,9 @@ async function handleRequest(message) {
|
|||||||
authorization: authorization || undefined,
|
authorization: authorization || undefined,
|
||||||
proxy: proxy || undefined,
|
proxy: proxy || undefined,
|
||||||
split: Number.isFinite(split) && split > 0 ? Math.round(split) : undefined,
|
split: Number.isFinite(split) && split > 0 ? Math.round(split) : undefined,
|
||||||
|
extractor: extractor || undefined,
|
||||||
|
format: format || undefined,
|
||||||
})
|
})
|
||||||
await focusGdownApp()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
EXTENSION_ID="${1:-alaohbbicffclloghmknhlmfdbobcigc}"
|
EXTENSION_ID="${1:-makoclohjdpempbndoaljeadpngefhcf}"
|
||||||
HOST_NAME="org.gdown.nativehost"
|
HOST_NAME="org.gdown.nativehost"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
RUNNER_PATH="$SCRIPT_DIR/.runtime/run-host-macos.sh"
|
RUNNER_PATH="$SCRIPT_DIR/.runtime/run-host-macos.sh"
|
||||||
|
|||||||
Reference in New Issue
Block a user