ek-ui 0.9.2

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
//! # ekapp —— 「ek-station 桌面」产品入口(0.9.0 打包案)。
//!
//! 三张脸(同一二进制):
//! - **`ekapp`(无参数)= 桌面主窗**:引擎 + ui/dm/oms 常驻 + 启动清点已存账户
//!   (行显「档案·密库引用」,手点「连接」= svctl.start 存档直连)。**不自动连**;
//!   行上的「自动连」是给守护复原(ek-station)与后续启动清点勾的。
//! - **`ekapp add-account …` = 无界面一次性写档**:受理三件套同款(明文键 +
//!   密码进 Windows 凭据管理器的密库引用),写完下次启动就能点。
//!   **密码永远走 stdin**(不进 argv:进程行能被旁人看到,红线)。
//! - **`ekapp list-accounts` = 已存档案清点**(只读元信息,不含任何密码值)。
//!
//! 与演示骨架(demo_tour)同构的部分刻意保持一致:boot → engine.start →
//! spawn_host_watcher → run_native + apply_cjk_fonts;**无脚本剧情** —— 产品窗
//! 开着等人手点。演示/巡回改的是 examples,不动本文件(判据面隔离)。

use std::sync::Arc;
use std::time::Duration;

// 臂折:CLI 模式不建窗,先分支后引。
fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    match args.first().map(|s| s.as_str()) {
        Some("add-account") => {
            if let Err(e) = cli_add_account(&args[1..]) {
                eprintln!("add-account 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("list-accounts") => cli_list_accounts(),
        Some(other) => {
            eprintln!(
                "未知子命令 `{other}`。用法:\n  ekapp              # 桌面主窗\n  ekapp add-account …\n  ekapp list-accounts"
            );
            std::process::exit(2);
        }
        None => desktop_main(),
    }
}

// ═════════════════════════════ 桌面主窗 ═════════════════════════════

fn desktop_main() {
    let engine = Arc::new(ek_core::main_engine::MainEngine::new(None));
    let shared = Arc::new(ek_ui::UiShared::new());

    // 产品装配:ui + dm + oms 全常驻(0.8.x 巡回的缺省面),supervisor 随 ui_kinds。
    ek_station::boot(
        &engine,
        &ek_ui::ui_kinds(&shared, true),
        &ek_station::StationConfig {
            plugins: vec!["ui".to_string(), "dm".to_string(), "oms".to_string()],
        },
    )
    .expect("boot");
    engine.start().expect("start");
    let watcher = ek_ui::spawn_host_watcher(&engine, &shared);

    // 启动清点(0.9.0):把已存档案列回账户清单(不 spawn 实例、不握手 ——
    // 用户裁定:桌面 = 手点连接;自动连接是行上的勾,交给守护/后续启动面)。
    // 在 runner 线程做:引用/装载可能有毫秒级盘 IO,不在渲染闭包里做。
    let shared_restore = Arc::clone(&shared);
    std::thread::spawn(move || {
        let rows = shared_restore.restore_saved_accounts();
        if rows.is_empty() {
            println!("[ekapp] 账户档案清点:0 行(空白首启:窗口「账户面板」→「添加账户」)");
        } else {
            println!(
                "[ekapp] 账户档案清点:恢复 {} 行(点『连接』即按存档起连)",
                rows.len()
            );
        }
    });

    struct DesktopApp {
        shared: Arc<ek_ui::UiShared>,
    }
    impl eframe::App for DesktopApp {
        fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
            ek_ui::render_body(ctx, &self.shared);
            ctx.request_repaint_after(Duration::from_millis(500));
        }
    }

    let opts = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([1280.0, 860.0])
            .with_title("ek-station 桌面"),
        ..Default::default()
    };
    let shared_main = Arc::clone(&shared);
    let result = eframe::run_native(
        "ek-station 桌面",
        opts,
        Box::new(|cc| {
            ek_ui::apply_cjk_fonts(&cc.egui_ctx);
            Ok(Box::new(DesktopApp {
                shared: Arc::clone(&shared_main),
            }) as Box<dyn eframe::App>)
        }),
    );
    shared.request_stop();
    let _ = watcher.join();
    if let Err(e) = result {
        eprintln!("ekapp: {e}");
        std::process::exit(1);
    }
}

// ═════════════════════════════ add-account ═════════════════════════════

fn usage() -> String {
    "用法:
  ekapp add-account --broker 9999 --user 278153 \
       --td tcp://182.254.243.31:30001 --md tcp://182.254.243.31:30011 \
       --app-id simnow_client_test --auth-code 0000000000000000 \
       [--classes 1(只期货;现时唯一合法档)] \
       [--md-user u --md-password-stdin]

  密码在提示后从 stdin 读一行(不进 argv;进程行能被旁人看到)。"
        .to_string()
}

fn cli_add_account(rest: &[String]) -> Result<(), String> {
    let mut a = ek_ui::appconfig::AcctSpec {
        broker_id: String::new(),
        user: String::new(),
        td_addr: String::new(),
        md_addr: String::new(),
        app_id: String::new(),
        auth_code: String::new(),
        classes: "1".to_string(),
        md_user: String::new(),
        md_password: None,
    };
    let mut i = 0;
    while i < rest.len() {
        let flag = rest[i].as_str();
        let need = |i: &mut usize, rest: &[String], what: &str| -> Result<String, String> {
            *i += 1;
            rest.get(*i)
                .cloned()
                .ok_or_else(|| format!("{what} 缺值(参数后要跟值)"))
        };
        match flag {
            "--broker" => a.broker_id = need(&mut i, rest, "--broker")?,
            "--user" => a.user = need(&mut i, rest, "--user")?,
            "--td" => a.td_addr = need(&mut i, rest, "--td")?,
            "--md" => a.md_addr = need(&mut i, rest, "--md")?,
            "--app-id" => a.app_id = need(&mut i, rest, "--app-id")?,
            "--auth-code" => a.auth_code = need(&mut i, rest, "--auth-code")?,
            "--classes" => a.classes = need(&mut i, rest, "--classes")?,
            "--md-user" => a.md_user = need(&mut i, rest, "--md-user")?,
            "--md-password-stdin" => a.md_password = Some(String::new()),
            other => {
                return Err(format!("未知参数 `{other}`\n{}", usage()));
            }
        }
        i += 1;
    }

    let guid = ek_ui::appconfig::derived_name(&a)?;

    // 密码:stdin 读一行 —— 永不进 argv(BD-APP-2 红线:进程行能被旁人看到)。
    eprint!("[ekapp] 输入 {guid} 的 CTP 密码(stdin 单行): ");

    let mut buf = String::new();
    std::io::stdin()
        .read_line(&mut buf)
        .map_err(|e| format!("stdin 读失败: {e}"))?;
    let password = buf.trim().to_string();
    if password.is_empty() {
        return Err("密码为空:档案不接受无密码账户(受理校验同款)".to_string());
    }
    if a.md_password.is_some() {
        eprint!("[ekapp] 输入 {guid} 的行情密码(stdin 单行;留空回落 td 密码): ");
        let mut mbuf = String::new();
        std::io::stdin()
            .read_line(&mut mbuf)
            .map_err(|e| format!("stdin 读失败: {e}"))?;
        let mp = mbuf.trim().to_string();
        // 留空 = 段里不出 md_password(行情会话回落 td 凭据)。
        a.md_password = (!mp.is_empty()).then_some(mp);
    }
    let store = ek_cfg::Store::eco().map_err(|e| format!("配置根不可用: {e:?}"))?;
    let gw = ek_ui::appconfig::write_account(&store, &a, &password)?;
    let md_txt = if a.md_addr.is_empty() {
        "—未配—".to_string()
    } else {
        a.md_addr.clone()
    };
    println!(
        "[ekapp] 已写档 ctp.{gw}.ini(td={td};md={md};密码=密库引用,值不落盘)。
下一步:启动 `ekapp`(桌面)点该行的『连接』;或起 `ek-station` 守护(自动连接先在桌面行上勾)。",
        td = a.td_addr,
        md = md_txt
    );
    Ok(())
}
// ═════════════════════════════ list-accounts ═════════════════════════════

fn cli_list_accounts() {
    let store = match ek_cfg::Store::eco() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("[ekapp] 配置根不可用: {e:?}");
            std::process::exit(1);
        }
    };
    let nss = store.list_namespaces().unwrap_or_default();
    let mut shown = 0usize;
    for ns in &nss {
        if !ns.starts_with("ctp.") {
            continue;
        }
        shown += 1;
        match store.load(ns) {
            Ok(ek_cfg::LoadOutcome::Loaded { map, .. }) => {
                let p = |k: &str| map.get_plain(k).unwrap_or("—").to_string();
                let has_pw = map
                    .get_secret_key("td_password")
                    .is_some_and(|k| !k.is_empty());
                println!(
                    "{}\n  账号 {} @ broker {}\n  td = {}\n  md = {}\n  凭据 = {} | 收录 = {}",
                    ns,
                    p("td_user"),
                    p("broker_id"),
                    p("td_addr"),
                    p("md_addr"),
                    if has_pw {
                        "密库引用 ✓"
                    } else {
                        "缺密码引用"
                    },
                    p("product_classes"),
                );
            }
            Ok(_) => println!("{ns}(一段都读不出;该档恐已坏)"),
            Err(e) => println!("{ns}(读失败:{e:?})"),
        }
    }
    if shown == 0 {
        println!("[ekapp] 档案清点:无 ctp.* 账户段(空白首启)");
    }
}