//! Tauri-команды — мост между фронтендом и бэкендом. //! Все команды возвращают `Result<_, String>`; строка ошибки приходит на фронт как reject. use crate::models::{InstalledApp, Outbound, Profile, Settings, Subscription}; use crate::{import, profiles, settings, subscriptions}; #[cfg(desktop)] use tauri_plugin_shell::ShellExt; use uuid::Uuid; fn now_unix() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } /// Список запускаемых Android-приложений для нативного per-app picker. #[tauri::command] pub async fn list_installed_apps( app: tauri::AppHandle, ) -> Result, String> { #[cfg(mobile)] { crate::connection::list_installed_apps(app).await } #[cfg(desktop)] { let _ = app; Ok(Vec::new()) } } /// Показывает системный запрос Android 13+ на добавление Quick Settings tile. #[tauri::command] pub async fn request_android_quick_tile(app: tauri::AppHandle) -> Result { #[cfg(mobile)] { crate::connection::request_quick_tile(app).await } #[cfg(desktop)] { let _ = app; Ok(false) } } /// Показывает системный запрос лаунчера на закрепление домашнего виджета. #[tauri::command] pub async fn request_android_widget(app: tauri::AppHandle) -> Result { #[cfg(mobile)] { crate::connection::request_widget(app).await } #[cfg(desktop)] { let _ = app; Ok(false) } } /// Запускает sidecar `sing-box version` и возвращает первую строку вывода. #[tauri::command] #[cfg(desktop)] pub async fn singbox_version(app: tauri::AppHandle) -> Result { let output = app .shell() .sidecar("sing-box") .map_err(|e| format!("не удалось найти sidecar sing-box: {e}"))? .arg("version") .output() .await .map_err(|e| format!("не удалось запустить sing-box: {e}"))?; if !output.status.success() { return Err(format!( "sing-box завершился с ошибкой: {}", String::from_utf8_lossy(&output.stderr) )); } let stdout = String::from_utf8_lossy(&output.stdout); Ok(stdout.lines().next().unwrap_or("").trim().to_string()) } #[tauri::command] #[cfg(mobile)] pub async fn singbox_version(_app: tauri::AppHandle) -> Result { // Версия libbox пинится в scripts/build-libbox-android.ps1 — держим синхронно. Ok("sing-box 1.14.0 (Android libbox)".into()) } /// Возвращает текущие настройки (или значения по умолчанию, если файла нет). #[tauri::command] pub async fn get_settings(app: tauri::AppHandle) -> Result { let current = settings::load(&app)?; #[cfg(mobile)] let _ = crate::connection::set_language(&app, ¤t.language).await; Ok(current) } /// Сохраняет настройки на диск и возвращает сохранённое значение. #[tauri::command] pub async fn save_settings(app: tauri::AppHandle, settings: Settings) -> Result { settings::save(&app, &settings)?; #[cfg(desktop)] let _ = crate::refresh_tray_language(&app, &settings.language); #[cfg(mobile)] let _ = crate::connection::set_language(&app, &settings.language).await; Ok(settings) } /// Возвращает все профили. #[tauri::command] pub fn list_profiles(app: tauri::AppHandle) -> Result, String> { profiles::list(&app) } /// Создаёт профиль с новым id, сохраняет и возвращает его. #[tauri::command] pub fn create_profile( app: tauri::AppHandle, name: String, outbound: Outbound, ) -> Result { let mut all = profiles::list(&app)?; let profile = Profile { id: Uuid::new_v4().to_string(), name, outbound, subscription_id: None, }; all.push(profile.clone()); profiles::save_all(&app, &all)?; Ok(profile) } /// Обновляет существующий профиль (по id). Ошибка, если не найден. #[tauri::command] pub fn update_profile(app: tauri::AppHandle, profile: Profile) -> Result { let mut all = profiles::list(&app)?; let idx = all .iter() .position(|p| p.id == profile.id) .ok_or_else(|| format!("профиль {} не найден", profile.id))?; all[idx] = profile.clone(); profiles::save_all(&app, &all)?; Ok(profile) } /// Удаляет профиль по id. Ошибка, если не найден. #[tauri::command] pub fn delete_profile(app: tauri::AppHandle, id: String) -> Result<(), String> { let mut all = profiles::list(&app)?; let before = all.len(); all.retain(|p| p.id != id); if all.len() == before { return Err(format!("профиль {id} не найден")); } profiles::save_all(&app, &all) } /// Импортирует профиль из ссылки (`hysteria2://…`) или JSON sing-box. /// `name` переопределяет имя; если пусто — берётся из ссылки/тега. #[tauri::command] pub fn import_profile( app: tauri::AppHandle, input: String, name: Option, ) -> Result { let (parsed_name, outbound) = import::parse(&input)?; let final_name = name .map(|n| n.trim().to_string()) .filter(|n| !n.is_empty()) .unwrap_or(parsed_name); let mut all = profiles::list(&app)?; let profile = Profile { id: Uuid::new_v4().to_string(), name: final_name, outbound, subscription_id: None, }; all.push(profile.clone()); profiles::save_all(&app, &all)?; Ok(profile) } /// Все подписки. #[tauri::command] pub fn list_subscriptions(app: tauri::AppHandle) -> Result, String> { subscriptions::list(&app) } /// Добавляет подписку: скачивает URL, создаёт профили из списка серверов. #[tauri::command] pub async fn add_subscription( app: tauri::AppHandle, name: String, url: String, ) -> Result { let body = subscriptions::fetch(&url).await?; let parsed = subscriptions::parse_list(&body); if parsed.is_empty() { return Err("в подписке не найдено серверов".into()); } let sub_id = Uuid::new_v4().to_string(); let mut profs = profiles::list(&app)?; let count = parsed.len(); for (pname, outbound) in parsed { profs.push(Profile { id: Uuid::new_v4().to_string(), name: pname, outbound, subscription_id: Some(sub_id.clone()), }); } profiles::save_all(&app, &profs)?; let sub = Subscription { id: sub_id, name, url, count, updated_at: Some(now_unix()), }; let mut subs = subscriptions::list(&app)?; subs.push(sub.clone()); subscriptions::save_all(&app, &subs)?; Ok(sub) } /// Обновляет подписку: перекачивает список, заменяет её профили. #[tauri::command] pub async fn update_subscription( app: tauri::AppHandle, id: String, ) -> Result { let mut subs = subscriptions::list(&app)?; let idx = subs .iter() .position(|s| s.id == id) .ok_or_else(|| format!("подписка {id} не найдена"))?; let body = subscriptions::fetch(&subs[idx].url).await?; let parsed = subscriptions::parse_list(&body); if parsed.is_empty() { return Err("в подписке не найдено серверов".into()); } // выкидываем старые профили этой подписки, добавляем новые let mut profs: Vec = profiles::list(&app)? .into_iter() .filter(|p| p.subscription_id.as_deref() != Some(id.as_str())) .collect(); let count = parsed.len(); for (pname, outbound) in parsed { profs.push(Profile { id: Uuid::new_v4().to_string(), name: pname, outbound, subscription_id: Some(id.clone()), }); } profiles::save_all(&app, &profs)?; subs[idx].count = count; subs[idx].updated_at = Some(now_unix()); subscriptions::save_all(&app, &subs)?; Ok(subs[idx].clone()) } /// Удаляет подписку вместе с её профилями. #[tauri::command] pub fn delete_subscription(app: tauri::AppHandle, id: String) -> Result<(), String> { let mut subs = subscriptions::list(&app)?; subs.retain(|s| s.id != id); subscriptions::save_all(&app, &subs)?; let profs: Vec = profiles::list(&app)? .into_iter() .filter(|p| p.subscription_id.as_deref() != Some(id.as_str())) .collect(); profiles::save_all(&app, &profs) } /// Экспортирует профиль в выбранном формате: `"link"` — share-ссылка /// (vless://…, для socks/http ссылки нет → JSON-фолбэк), `"json"` — sing-box /// outbound JSON. AmneziaWG в формате `link` отдаёт упакованную `vpn://`-ссылку. #[tauri::command] pub fn export_profile(app: tauri::AppHandle, id: String, format: String) -> Result { let profile = profiles::list(&app)? .into_iter() .find(|p| p.id == id) .ok_or_else(|| format!("профиль {id} не найден"))?; let out = match format.as_str() { "json" => crate::export::to_json(&profile.outbound), _ => crate::export::to_share(&profile.name, &profile.outbound), }; Ok(out) } /// Дублирует профиль по id (новый id, имя «… (копия)»). #[tauri::command] pub fn duplicate_profile(app: tauri::AppHandle, id: String) -> Result { let mut all = profiles::list(&app)?; let src = all .iter() .find(|p| p.id == id) .ok_or_else(|| format!("профиль {id} не найден"))? .clone(); let copy = Profile { id: Uuid::new_v4().to_string(), name: format!("{} (копия)", src.name), outbound: src.outbound, subscription_id: None, }; all.push(copy.clone()); profiles::save_all(&app, &all)?; Ok(copy) }