Ctrl K
ComunidadGuías…

src-tauri/src/commands.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! 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<Vec<InstalledApp>, 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<bool, String> {
    #[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<bool, String> {
    #[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<String, String> {
    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<String, String> {
    // Версия 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<Settings, String> {
    let current = settings::load(&app)?;
    #[cfg(mobile)]
    let _ = crate::connection::set_language(&app, &current.language).await;
    Ok(current)
}

/// Сохраняет настройки на диск и возвращает сохранённое значение.
#[tauri::command]
pub async fn save_settings(app: tauri::AppHandle, settings: Settings) -> Result<Settings, String> {
    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<Vec<Profile>, String> {
    profiles::list(&app)
}

/// Создаёт профиль с новым id, сохраняет и возвращает его.
#[tauri::command]
pub fn create_profile(
    app: tauri::AppHandle,
    name: String,
    outbound: Outbound,
) -> Result<Profile, String> {
    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<Profile, String> {
    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<String>,
) -> Result<Profile, String> {
    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<Vec<Subscription>, String> {
    subscriptions::list(&app)
}

/// Добавляет подписку: скачивает URL, создаёт профили из списка серверов.
#[tauri::command]
pub async fn add_subscription(
    app: tauri::AppHandle,
    name: String,
    url: String,
) -> Result<Subscription, String> {
    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<Subscription, String> {
    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<Profile> = 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<Profile> = 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<String, String> {
    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<Profile, String> {
    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)
}