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; use engine::{ aria2_add_torrent, aria2_add_uri, aria2_change_global_option, aria2_get_task_detail, aria2_list_tasks, aria2_pause_all, aria2_pause_task, aria2_remove_task, aria2_remove_task_record, aria2_resume_all, aria2_resume_task, detect_aria2_binary, engine_start, engine_status, engine_stop, load_torrent_file, open_path_in_file_manager, stop_engine_for_exit, yt_dlp_add_uri, EngineState, }; #[tauri::command] async fn focus_main_window(app: tauri::AppHandle) -> Result<(), String> { let window = app .get_webview_window("main") .ok_or_else(|| "메인 창을 찾을 수 없습니다.".to_string())?; window .show() .map_err(|error| format!("창 표시 실패: {error}"))?; window .unminimize() .map_err(|error| format!("창 복원 실패: {error}"))?; window .set_focus() .map_err(|error| format!("창 포커스 실패: {error}"))?; Ok(()) } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct ExternalAddRequest { url: String, out: Option, dir: Option, referer: Option, user_agent: Option, authorization: Option, cookie: Option, proxy: Option, split: Option, extractor: Option, format: 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")) } #[tauri::command] fn take_external_add_requests() -> Result, String> { let path = external_add_queue_path()?; let parent = path .parent() .ok_or_else(|| "큐 디렉터리 경로를 계산할 수 없습니다.".to_string())?; fs::create_dir_all(parent).map_err(|err| format!("큐 디렉터리 생성 실패: {err}"))?; if !path.exists() { return Ok(Vec::new()); } let content = fs::read_to_string(&path).map_err(|err| format!("큐 읽기 실패: {err}"))?; let mut requests: Vec = Vec::new(); for line in content.lines() { let trimmed = line.trim(); if trimmed.is_empty() { continue; } if let Ok(item) = serde_json::from_str::(trimmed) { requests.push(item); } } let mut file = fs::OpenOptions::new() .write(true) .truncate(true) .open(&path) .map_err(|err| format!("큐 초기화 실패: {err}"))?; file .write_all(b"") .map_err(|err| format!("큐 파일 쓰기 실패: {err}"))?; Ok(requests) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .manage(EngineState::default()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_deep_link::init()) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { .. } = event { if window.label() == "main" { let state = window.state::(); stop_engine_for_exit(&state); } } }) .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() .level(log::LevelFilter::Info) .build(), )?; } Ok(()) }) .invoke_handler(tauri::generate_handler![ engine_start, engine_stop, engine_status, detect_aria2_binary, aria2_add_torrent, aria2_add_uri, yt_dlp_add_uri, aria2_change_global_option, aria2_get_task_detail, aria2_list_tasks, aria2_pause_task, aria2_resume_task, aria2_remove_task, aria2_remove_task_record, aria2_pause_all, aria2_resume_all, load_torrent_file, open_path_in_file_manager, focus_main_window, take_external_add_requests ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }