fast-down-gui 0.1.45

超级快的下载器图形化界面
Documentation
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use arboard::Clipboard;
use fast_down_gui::{
    addons::{CHROME_EXT_IDS, FIREFOX_EXT_ID, auto_register, handle_browser_request},
    core::{App, TaskSet, start_entry, start_new_entry},
    ipc::{check_ipc_and_wake, init_ipc},
    os::{attach_console, get_auto_start, is_admin, setup_tray, try_restart_as_admin},
    persist::{DB_DIR, Database},
    ui::*,
    utils::{LogErr, show_task_dialog},
};
use file_alloc::init_fast_alloc;
use rfd::FileDialog;
use slint::{Model, ModelRc, ToSharedString, VecModel};
use std::{collections::HashSet, rc::Rc, sync::Arc};
use tracing::{info, level_filters::LevelFilter};
use tracing_appender::{
    non_blocking::WorkerGuard,
    rolling::{RollingFileAppender, Rotation},
};
use tracing_subscriber::{EnvFilter, Registry, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use url::Url;

#[global_allocator]
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;

const VERSION: &str = env!("CARGO_PKG_VERSION");

fn init_tracing() -> WorkerGuard {
    let file_appender = RollingFileAppender::builder()
        .rotation(Rotation::DAILY)
        .filename_prefix("fd")
        .filename_suffix("log")
        .max_log_files(3)
        .build(&*DB_DIR)
        .expect("无法初始化日志写入");
    let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
    let file_layer = fmt::layer().with_writer(non_blocking).with_ansi(false);
    let std_layer = fmt::layer().pretty();
    Registry::default()
        .with(
            EnvFilter::builder()
                .with_default_directive(LevelFilter::INFO.into())
                .from_env_lossy(),
        )
        .with(std_layer)
        .with(file_layer)
        .init();
    _guard
}

#[tokio::main]
async fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let args: Vec<_> = std::env::args().collect();
    if args
        .iter()
        .any(|arg| arg.contains(FIREFOX_EXT_ID) || CHROME_EXT_IDS.iter().any(|id| arg.contains(id)))
    {
        return handle_browser_request().await;
    }
    attach_console();
    let _guard = init_tracing();
    #[cfg(target_os = "linux")]
    let _gtk_timer = {
        let _ = gtk::init().log_err("初始化 gtk 错误");
        let timer = slint::Timer::default();
        timer.start(
            slint::TimerMode::Repeated,
            std::time::Duration::from_millis(50),
            move || {
                while gtk::events_pending() {
                    gtk::main_iteration_do(false);
                }
            },
        );
        timer
    };

    let _ = check_ipc_and_wake().await.log_err("检查 ipc 通道错误");
    let _ = auto_register().log_err("写入浏览器扩展通信配置失败");
    let ui = MainWindow::new()?;
    let db = Database::new().await;
    let run_as_admin = db.inner.general_config.lock().run_as_admin;
    let _ = try_restart_as_admin(run_as_admin).log_err("以管理员身份重启失败");
    init_fast_alloc();
    let task_set = TaskSet::new(db.inner.general_config.lock().max_concurrency);
    let auto = get_auto_start()
        .log_err("初始化开机自启错误")
        .ok()
        .map(Arc::new);
    if db.is_auto_start()
        && let Some(auto) = &auto
    {
        let _ = auto.enable().log_err("启用开机自启失败");
    }
    let entries = db.inner.data.iter().map(|e| e.to_entry_data(*e.key()));
    let list_model = Rc::new(VecModel::from_iter(entries));
    let app = App {
        db: db.clone(),
        task_set: task_set.clone(),
        ui: ui.as_weak(),
    };
    let _ = init_ipc(app.clone(), list_model.clone())
        .await
        .log_err("初始化 ipc 通道错误");

    let _tray = setup_tray(app.clone()).log_err("初始化托盘错误");
    setup_ui_lists(&ui, list_model.clone());
    ui.set_download_config(db.get_ui_download_config());
    ui.set_general_config(db.get_ui_general_config());
    ui.set_version(VERSION.into());
    ui.set_admin(is_admin());

    ui.on_exit({
        let app = app.clone();
        move || app.exit()
    });

    ui.on_browse_folder({
        let ui = ui.as_weak();
        move || {
            let ui = ui.clone();
            std::thread::spawn(move || {
                if let Some(folder) = FileDialog::new().pick_folder() {
                    let _ = ui.upgrade_in_event_loop(move |ui| {
                        ui.invoke_set_save_dir(folder.to_string_lossy().to_shared_string());
                    });
                }
            });
        }
    });

    ui.on_config_change({
        let app = app.clone();
        let auto = auto.clone();
        move |download_config, general_config| {
            info!(download_config = ?download_config, general_config = ?general_config, "配置已更新");
            app.set_config(download_config, general_config, auto.as_deref());
        }
    });

    ui.on_start_all({
        let app = app.clone();
        let list_model = list_model.clone();
        move |list| {
            for (i, mut entry) in list.iter().enumerate() {
                if entry.status == Status::Completed {
                    continue;
                }
                let is_started = start_entry(&app, &entry, &list_model);
                if is_started {
                    entry.status = Status::Waiting;
                    list.set_row_data(i, entry);
                }
            }
        }
    });
    ui.on_start_entry({
        let app = app.clone();
        let list_model = list_model.clone();
        move |gid| {
            for i in (0..list_model.row_count()).rev() {
                let Some(mut entry) = list_model.row_data(i) else {
                    break;
                };
                if entry.gid == gid {
                    let is_started = start_entry(&app, &entry, &list_model);
                    if is_started {
                        entry.status = Status::Waiting;
                        list_model.set_row_data(i, entry);
                    }
                    break;
                }
            }
        }
    });

    ui.on_pause_all({
        let task_set = task_set.clone();
        move |list| {
            for entry in list.iter() {
                task_set.cancel_task(&entry.gid);
            }
        }
    });
    ui.on_pause_entry({
        let task_set = task_set.clone();
        move |gid| task_set.cancel_task(&gid)
    });

    ui.on_remove_all({
        let task_set = task_set.clone();
        let list_model = list_model.clone();
        let db = db.clone();
        move |list| {
            let ids_to_remove: HashSet<_> = list.iter().map(|e| e.gid).collect();
            let mut kept_items = Vec::new();
            for item in list_model.iter() {
                if !ids_to_remove.contains(&item.gid) {
                    kept_items.push(item);
                }
            }
            list_model.set_vec(kept_items);
            for gid in ids_to_remove.iter() {
                task_set.cancel_task(gid);
                let _ = db.remove_entry(*gid).log_err("数据库移除条目失败");
            }
        }
    });
    ui.on_remove_entry({
        let task_set = task_set.clone();
        let list_model = list_model.clone();
        move |gid| {
            for i in (0..list_model.row_count()).rev() {
                let Some(item) = list_model.row_data(i) else {
                    break;
                };
                if item.gid == gid {
                    list_model.remove(i);
                    break;
                }
            }
            task_set.cancel_task(&gid);
            let _ = db.remove_entry(gid).log_err("数据库移除条目失败");
        }
    });

    ui.on_add_task({
        let app = app.clone();
        let list_model = list_model.clone();
        let db = app.db.clone();
        move || {
            let url = Clipboard::new()
                .ok()
                .and_then(|mut c| c.get_text().ok())
                .filter(|s| Url::parse(s).is_ok_and(|u| matches!(u.scheme(), "http" | "https")))
                .unwrap_or_default();
            let app = app.clone();
            let list_model = list_model.clone();
            let _ = show_task_dialog(
                url.into(),
                DialogType::AddTask,
                db.get_ui_download_config(),
                false,
                move |urls, config, _| {
                    let valid_urls = urls.lines().filter_map(|s| {
                        Url::parse(s)
                            .ok()
                            .filter(|u| matches!(u.scheme(), "http" | "https"))
                    });
                    for url in valid_urls {
                        start_new_entry(&app, url, &config, &list_model);
                    }
                },
            )
            .log_err("添加任务对话框启动失败");
        }
    });

    ui.on_open_entry(|path| {
        let _ = open::that(path).log_err("打开文件失败");
    });
    ui.on_open_folder_entry(|path| {
        #[cfg(target_os = "macos")]
        let _ = std::process::Command::new("open")
            .arg("-R")
            .arg(path)
            .spawn();
        #[cfg(not(target_os = "macos"))]
        showfile::show_path_in_file_manager(path);
    });

    ui.on_detail_entry({
        let db = app.db.clone();
        move |gid| {
            let Some(mut entry) = db.inner.data.get(&gid).map(|e| e.clone()) else {
                return;
            };
            let db = db.clone();
            let _ = show_task_dialog(
                entry.url.to_shared_string(),
                DialogType::EditTask,
                entry.config.to_ui_download_config(),
                false,
                move |urls, config, _| {
                    let mut valid_urls = urls.lines().filter_map(|s| {
                        Url::parse(s)
                            .ok()
                            .filter(|u| matches!(u.scheme(), "http" | "https"))
                    });
                    if let Some(url) = valid_urls.next() {
                        entry.url = url;
                        entry.config = (&config).into();
                        let _ = db.init_entry(gid, entry).log_err("更新任务配置失败");
                    }
                },
            )
            .log_err("添加任务对话框启动失败");
        }
    });

    ui.on_view_log(|| {
        let _ = open::that(DB_DIR.as_os_str()).log_err("打开日志文件夹失败");
    });

    let _ = slint::spawn_local({
        let app = app.clone();
        async move {
            loop {
                app.task_set.wait_last().await;
                let Some(ui) = app.ui.upgrade() else { break };
                let visible = ui.window().is_visible();
                info!(main_window_visible = visible, "所有任务已完成");
                if !visible && app.db.is_exit_after_download() {
                    break;
                }
            }
            app.exit();
        }
    })
    .log_err("无法检测程序下载状态");

    let is_hidden = args.iter().any(|s| s == "--hidden");
    #[cfg(target_os = "linux")]
    {
        ui.show()?;
        if is_hidden {
            let ui = ui.as_weak();
            let _ = slint::spawn_local(async move {
                if let Some(ui) = ui.upgrade() {
                    let _ = ui.hide().log_err("隐藏窗口失败");
                }
            })
            .log_err("隐藏窗口失败");
        }
    }
    #[cfg(not(target_os = "linux"))]
    if !is_hidden {
        ui.show()?;
    }
    slint::run_event_loop_until_quit()?;
    Ok(())
}

/// 设置 UI 列表的各种过滤视图
fn setup_ui_lists(ui: &MainWindow, list_model: Rc<VecModel<EntryData>>) {
    ui.set_all_list(ModelRc::new(
        list_model.clone().sort_by(|a, b| b.gid.cmp(&a.gid)),
    ));
    let filter_view = |status: Status| {
        ModelRc::new(
            list_model
                .clone()
                .filter(move |e| e.status == status)
                .sort_by(|a, b| b.gid.cmp(&a.gid)),
        )
    };
    ui.set_running_list(filter_view(Status::Running));
    ui.set_waiting_list(filter_view(Status::Waiting));
    ui.set_paused_list(filter_view(Status::Paused));
    ui.set_completed_list(filter_view(Status::Completed));
    ui.set_error_list(filter_view(Status::Error));
}