feat: refine download UX and native host flow

This commit is contained in:
tongki078
2026-02-25 11:12:23 +09:00
parent 34f63acf49
commit d85fdc1101
12 changed files with 474 additions and 72 deletions

View File

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

View File

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

9
package-lock.json generated
View File

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

View File

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

2
src-tauri/Cargo.lock generated
View File

@@ -77,7 +77,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"base64 0.22.1",
"log",

View File

@@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.1.1"
version = "0.1.2"
description = "A Tauri App"
authors = ["you"]
license = ""

View File

@@ -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<u32>,
}
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> {
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()

View File

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

View File

@@ -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<Aria2TaskSnapshot>({
waiting: [],
stopped: [],
})
const pendingTasksByGid = ref<Record<string, PendingTask>>({})
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<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) {
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<string> {
@@ -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(() => {
<template>
<main class="app-shell" :class="{ 'app-drop-active': appDropActive }">
<aside class="sidebar">
<div class="brand">m</div>
<div class="brand">G</div>
<div class="side-icons">
<button class="side-icon" :class="{ active: page === 'downloads' }" title="목록" @click="openDownloadsPage">
@@ -1588,7 +1760,7 @@ onUnmounted(() => {
<div class="task-actions-pill">
<button
class="icon-pill ghost"
:disabled="loadingTaskAction || task.status === 'stopped'"
:disabled="loadingTaskAction || !isTaskToggleable(task)"
:title="taskPrimaryTitle(task)"
@click="onTaskAction(task, taskPrimaryAction(task))"
>
@@ -1619,6 +1791,7 @@ onUnmounted(() => {
<div class="task-meta-row">
<span>{{ formatBytes(task.completedLength) }} / {{ formatBytes(task.totalLength) }}</span>
<span class="task-meta-right">
<span>{{ taskListStateText(task) }}</span>
<span>{{ formatSpeed(task.downloadSpeed) }}</span>
</span>
</div>
@@ -1880,6 +2053,7 @@ onUnmounted(() => {
폴더 선택
</button>
</div>
<label class="check-row"><input v-model="settingUseCategoriesByDefault" type="checkbox" /> 기본값으로 범주 사용</label>
</section>
<section class="settings-group settings-card-section two-col">
@@ -2115,6 +2289,31 @@ onUnmounted(() => {
<input v-model="downloadDir" type="text" placeholder="/Users/.../Downloads" />
</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">
<div v-if="addShowAdvanced" class="modal-advanced">
<label>

View File

@@ -1,7 +1,7 @@
:root {
--bg-app: #121317;
--bg-sidebar: #0a0b0d;
--bg-sidebar-active: #202227;
--bg-sidebar: #2b1f57;
--bg-sidebar-active: #473385;
--bg-main: #15171c;
--bg-card: #1f2127;
--bg-card-soft: #2a2d34;
@@ -57,7 +57,7 @@ body {
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid #14161b;
border-right: 1px solid #3f2f74;
padding: 12px 8px;
display: flex;
flex-direction: column;
@@ -91,7 +91,7 @@ body {
}
.side-icon:hover,
.side-icon.add { background: #15181e; color: #d8def0; }
.side-icon.add { background: #3d2f72; color: #ede8ff; }
.sidebar nav {
margin-top: 6px;
@@ -102,7 +102,7 @@ body {
}
.sidebar nav a {
color: #98a3b8;
color: #d2caf3;
text-decoration: none;
font-size: 0.74rem;
text-align: center;
@@ -111,7 +111,7 @@ body {
}
.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; }
@@ -779,6 +779,82 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.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 {
position: relative;
display: block;
}
.category-select-label::after {
content: "";
position: absolute;
right: 10px;
top: 50%;
width: 7px;
height: 7px;
border-right: 1.5px solid #8f98ab;
border-bottom: 1.5px solid #8f98ab;
transform: translateY(-60%) rotate(45deg);
pointer-events: none;
}
.category-select-label select {
width: 100%;
height: 33px;
border: 1px solid #3f4654;
border-radius: 4px;
padding: 7px 28px 7px 9px;
font-size: 0.84rem;
color: var(--text-main);
background: #1d2128;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
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 {
border: 1px dashed #5a606f;
border-radius: 8px;

View File

@@ -1,4 +1,3 @@
import { execFile } from 'node:child_process'
import { appendFile, mkdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -45,40 +44,7 @@ async function enqueueExternalAdd(payload) {
}
function focusGdownApp() {
return new Promise((resolve) => {
if (process.platform !== 'darwin') {
resolve({ ok: true, note: 'focus noop on non-macos in step1 host' })
return
}
const attempts = [
['osascript', ['-e', 'tell application id "com.tauri.dev" to activate']],
['osascript', ['-e', 'tell application "gdown" to activate']],
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "gdown" to true']],
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "Gdown" to true']],
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "app" to true']],
['open', ['-b', 'com.tauri.dev']],
['open', ['-a', 'gdown']],
]
const run = (index) => {
if (index >= attempts.length) {
resolve({ ok: false, message: 'all focus strategies failed' })
return
}
const [bin, args] = attempts[index]
execFile(bin, args, (error) => {
if (!error) {
resolve({ ok: true, strategy: `${bin} ${args.join(' ')}` })
return
}
run(index + 1)
})
}
run(0)
})
return Promise.resolve({ ok: true, note: 'focus disabled by design' })
}
async function handleRequest(message) {
@@ -119,7 +85,6 @@ async function handleRequest(message) {
proxy: proxy || undefined,
split: Number.isFinite(split) && split > 0 ? Math.round(split) : undefined,
})
await focusGdownApp()
return {
ok: true,

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
EXTENSION_ID="${1:-alaohbbicffclloghmknhlmfdbobcigc}"
EXTENSION_ID="${1:-makoclohjdpempbndoaljeadpngefhcf}"
HOST_NAME="org.gdown.nativehost"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUNNER_PATH="$SCRIPT_DIR/.runtime/run-host-macos.sh"