diff --git a/docs/PORTING_PLAN.md b/docs/PORTING_PLAN.md index 1230c8b..5bf105c 100644 --- a/docs/PORTING_PLAN.md +++ b/docs/PORTING_PLAN.md @@ -65,6 +65,13 @@ - Step 4: 링크 자동 후킹을 Native Host 경로로 이관 - Step 5: 오류 복구/로깅/설정 UX 정리 +### Phase 7 (신규): 범주(Category) 기능 +- Step 1: 범주 기본 데이터 + 설정 토글 + Add 모달 범주 선택/경로 반영 +- Step 2: 범주 CRUD UI(이름/아이콘/확장자 룰) +- Step 3: 좌측 범주 패널(카운트/필터/접힘 상태) 연동 +- Step 4: 확장/외부 요청 자동 분류 규칙 정교화 +- Step 5: 성능/UX/오류 피드백 상용 수준 마감 + ## 5. 리스크 및 대응 - aria2 바이너리 번들/서명: 플랫폼별 바이너리 동봉 규칙 문서화 + CI 검증 - 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`: task summary에 `uri` 노출 추가(링크 복사용) - `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`: 바이너리 자동 탐지 + 에러 분류 + 작업 제어 RPC 커맨드 - `src-tauri/src/lib.rs`: Tauri invoke handler 연결 diff --git a/docs/TODO.md b/docs/TODO.md index 0c49865..23ff2d5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -13,6 +13,12 @@ - 고급 기능: 40~50% ## In Progress +- [~] 범주(Category) 기능 단계 구현 + - [x] Step 1: 범주 기본 데이터/설정 토글/추가 모달 범주 선택 + 저장 경로 자동 반영 + - [ ] Step 2: 범주 관리 UI(추가/수정/삭제, 아이콘/확장자 규칙 편집) + - [ ] Step 3: 좌측 범주 네비게이션/카운트/필터 연동 + - [ ] Step 4: 외부 연동/자동 캡처 시 범주 자동 매핑 고도화 + - [ ] Step 5: 범주별 통계/정렬/검색 UX 마무리 - [~] Native Messaging 기반 브라우저 연동 전환 (Step-by-step) - [x] Step 1: Native Host 스캐폴드(프로토콜/설치 스크립트/템플릿 manifest) - [x] Step 2: 확장에서 Native Host 1차 연결(우클릭 + 자동 경로 공통 addUri) @@ -56,6 +62,10 @@ - [ ] 탭별 빈 상태/오류 상태 문구 정리 ## 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] `gdown://` 스킴 미등록 환경 대응: Native Host -> 로컬 큐(`~/.gdown/external_add_queue.jsonl`) -> 앱 폴링 처리 - [x] Native Host 설치/삭제/스모크 스크립트 추가 (`tools/native-host/install-macos.sh`, `uninstall-macos.sh`, `smoke.mjs`) diff --git a/package-lock.json b/package-lock.json index ee382c2..36cde40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gdown", - "version": "0.0.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gdown", - "version": "0.0.0", + "version": "0.1.2", "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-deep-link": "^2.4.3", @@ -1135,7 +1135,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1480,7 +1479,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1593,7 +1591,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1615,7 +1612,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -1697,7 +1693,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", diff --git a/package.json b/package.json index 2373abc..ef1cc2a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gdown", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "engines": { "node": ">=24 <25" @@ -17,7 +17,7 @@ "version:bump": "bash scripts/version-bump.sh", "sync:aria2": "bash scripts/sync-aria2-from-motrix.sh", "native-host:smoke": "cd tools/native-host && npm run smoke", - "native-host:install": "bash tools/native-host/install-macos.sh alaohbbicffclloghmknhlmfdbobcigc", + "native-host:install": "bash tools/native-host/install-macos.sh makoclohjdpempbndoaljeadpngefhcf", "native-host:uninstall": "bash tools/native-host/uninstall-macos.sh" }, "dependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6836301..78d35a9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app" -version = "0.1.0" +version = "0.1.2" dependencies = [ "base64 0.22.1", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e3fafbc..3095e46 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.1" +version = "0.1.2" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1202517..3d60c39 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,9 @@ mod engine; use serde::{Deserialize, Serialize}; +use std::env; use std::fs; use std::io::Write; +use std::path::Path; use std::path::PathBuf; use tauri::Manager; @@ -46,6 +48,145 @@ struct ExternalAddRequest { split: Option, } +fn default_extension_ids() -> Vec { + vec![ + "alaohbbicffclloghmknhlmfdbobcigc".to_string(), + "makoclohjdpempbndoaljeadpngefhcf".to_string(), + ] +} + +fn collect_extension_ids() -> Vec { + 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) { + 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 { + 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 { + 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::>(); + + 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 { let home = std::env::var("HOME").map_err(|err| format!("HOME 경로 확인 실패: {err}"))?; Ok(PathBuf::from(home).join(".gdown").join("external_add_queue.jsonl")) @@ -102,6 +243,13 @@ pub fn run() { } }) .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) { app.handle().plugin( tauri_plugin_log::Builder::default() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f7b1051..dc94c71 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "gdown", - "version": "0.1.1", + "version": "0.1.2", "identifier": "com.tauri.dev", "build": { "frontendDist": "../dist", @@ -15,9 +15,9 @@ "label": "main", "title": "gdown", "width": 1280, - "height": 860, + "height": 660, "minWidth": 1080, - "minHeight": 720, + "minHeight": 600, "resizable": true, "fullscreen": false } diff --git a/src/App.vue b/src/App.vue index c52efd7..005df77 100644 --- a/src/App.vue +++ b/src/App.vue @@ -36,6 +36,12 @@ type AddTab = 'url' | 'torrent' type AppPage = 'downloads' | 'settings' type SettingsTab = 'basic' | 'advanced' | 'lab' type TaskInfoTab = 'general' | 'activity' | 'trackers' | 'peers' | 'files' +type CategoryItem = { + id: string + name: string + icon: string + extensions: string[] +} type PersistedSettings = { binaryPath?: string rpcPort?: number @@ -75,7 +81,24 @@ type PersistedSettings = { protocolThunder?: boolean userAgent?: string 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' @@ -137,6 +160,7 @@ const settingProtocolMagnet = ref(true) const settingProtocolThunder = ref(false) const settingUserAgent = ref('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122 Safari/537.36') const settingSkipTlsVerify = ref(true) +const settingUseCategoriesByDefault = ref(true) const trackerSyncing = ref(false) const rpcTestLoading = ref(false) const rpcTestStatus = ref<'idle' | 'ok' | 'fail'>('idle') @@ -154,6 +178,8 @@ const addReferer = ref('') const addCookie = ref('') const addProxy = ref('') const addNavigateToDownloading = ref(true) +const addUseCategory = ref(true) +const addCategoryId = ref(DEFAULT_CATEGORIES[0]?.id ?? 'compressed') const torrentBase64 = ref('') const torrentFileName = ref('') const torrentFileExt = ref('') @@ -180,6 +206,7 @@ const tasks = ref({ waiting: [], stopped: [], }) +const pendingTasksByGid = ref>({}) let refreshTimer: number | null = null let unlistenDragDrop: UnlistenFn | null = null @@ -234,6 +261,7 @@ function parseExternalAddDeepLink(rawUrl: string): ExternalAddPayload | null { function applyExternalAddPayload(payload: ExternalAddPayload) { showAddModal.value = true addTab.value = 'url' + addUseCategory.value = settingUseCategoriesByDefault.value const current = addUrl.value.trim() if (!current) { addUrl.value = payload.url @@ -249,6 +277,11 @@ function applyExternalAddPayload(payload: ExternalAddPayload) { if (payload.cookie) addCookie.value = payload.cookie if (payload.proxy) addProxy.value = payload.proxy if (payload.split) addSplit.value = payload.split + if (payload.out) { + applySuggestedCategory(payload.out) + } else { + applySuggestedCategory(payload.url) + } if (payload.referer || payload.userAgent || payload.authorization || payload.cookie || payload.proxy) { addShowAdvanced.value = true @@ -303,15 +336,18 @@ function isCompletedTask(task: Aria2Task): boolean { 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 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 === 'stopped') return stoppedTasks.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 taskInfoLiveTask = computed(() => { @@ -338,6 +374,14 @@ const taskInfoSelectedFilesText = computed(() => { if (selectedCount === 0) return '파일 0개 선택됨' 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 rpcTokenText = computed(() => { const secret = rpcSecret.value.trim() @@ -418,6 +462,9 @@ function loadSettingsFromStorage() { if (typeof parsed.protocolThunder === 'boolean') settingProtocolThunder.value = parsed.protocolThunder if (typeof parsed.userAgent === 'string') settingUserAgent.value = parsed.userAgent if (typeof parsed.skipTlsVerify === 'boolean') settingSkipTlsVerify.value = parsed.skipTlsVerify + if (typeof parsed.useCategoriesByDefault === 'boolean') { + settingUseCategoriesByDefault.value = parsed.useCategoriesByDefault + } } catch { // ignore malformed settings } @@ -463,6 +510,7 @@ function saveSettingsToStorage() { protocolThunder: settingProtocolThunder.value, userAgent: settingUserAgent.value, skipTlsVerify: settingSkipTlsVerify.value, + useCategoriesByDefault: settingUseCategoriesByDefault.value, } localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(payload)) } @@ -545,6 +593,32 @@ function progress(task: Aria2Task): number { 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() { try { status.value = await getEngineStatus() @@ -556,12 +630,14 @@ async function refreshEngineStatus() { async function refreshTasks(silent = false) { if (!status.value.running) { tasks.value = { active: [], waiting: [], stopped: [] } + pendingTasksByGid.value = {} return } if (!silent) loadingTasks.value = true try { - tasks.value = await listAria2Tasks(rpcConfig()) + const snapshot = await listAria2Tasks(rpcConfig()) + tasks.value = mergePendingTasks(snapshot) } catch (error) { pushError(String(error)) } finally { @@ -569,6 +645,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() + + 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) { if (!taskInfoVisible.value || !taskInfoTask.value) return if (!status.value.running) return @@ -736,6 +880,11 @@ function taskPrimaryTitle(task: Aria2Task): string { 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) { const dir = task.dir?.trim() if (!dir) { @@ -823,6 +972,19 @@ function statusLabel(status: string | undefined): string { 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 { const value = peerId.trim() if (!value) return '-' @@ -930,6 +1092,7 @@ async function inspectAddedTask(gid: string) { function openAddModal() { showAddModal.value = true addTab.value = 'url' + addUseCategory.value = settingUseCategoriesByDefault.value } function openDownloadsPage() { @@ -1133,11 +1296,13 @@ function closeAddModal() { showAddModal.value = false modalDropActive.value = false addShowAdvanced.value = false + addUseCategory.value = settingUseCategoriesByDefault.value } function openAddModalForTorrent() { showAddModal.value = true addTab.value = 'torrent' + addUseCategory.value = settingUseCategoriesByDefault.value } async function toBase64(file: File): Promise { @@ -1177,6 +1342,7 @@ async function applyTorrentFile(file: File) { if (!addOut.value) { addOut.value = file.name.replace(/\.torrent$/i, '') } + applySuggestedCategory(file.name) openAddModalForTorrent() } @@ -1190,6 +1356,7 @@ async function applyTorrentPath(path: string) { if (!addOut.value) { addOut.value = payload.name.replace(/\.torrent$/i, '') } + applySuggestedCategory(payload.name) openAddModalForTorrent() } @@ -1283,16 +1450,18 @@ function guessFileNameFromUri(uri: string): 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) if (exists) return - tasks.value.active.unshift({ + const pending: PendingTask = { gid, - status: 'active', - totalLength: '0', - completedLength: '0', - downloadSpeed: '0', - dir: downloadDir.value.trim() || '-', fileName: fileName.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) { @@ -1349,6 +1518,8 @@ async function onSubmitAddTask() { addOptions.header = headers } + const targetDir = addResolvedDownloadDir.value.trim() || undefined + if (addTab.value === 'url') { const uris = addUrl.value .split('\n') @@ -1364,7 +1535,7 @@ async function onSubmitAddTask() { rpc: rpcConfig(), uri, out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined, - dir: downloadDir.value.trim() || undefined, + dir: targetDir, split: addSplit.value, options: Object.keys(addOptions).length > 0 ? addOptions : undefined, }) @@ -1382,7 +1553,7 @@ async function onSubmitAddTask() { rpc: rpcConfig(), torrentBase64: torrentBase64.value, out: addOut.value.trim() || undefined, - dir: downloadDir.value.trim() || undefined, + dir: targetDir, split: addSplit.value, options: Object.keys(addOptions).length > 0 ? addOptions : undefined, }) @@ -1405,6 +1576,7 @@ async function onSubmitAddTask() { addCookie.value = '' addProxy.value = '' addNavigateToDownloading.value = true + addUseCategory.value = settingUseCategoriesByDefault.value torrentBase64.value = '' torrentFileName.value = '' torrentFileExt.value = '' @@ -1519,7 +1691,7 @@ onUnmounted(() => {