ek-ui 0.9.5

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` = 已存档案清点**(只读元信息,不含任何密码值)。
//! - **`ekapp tq-secret` = 天勤桥凭据入库**(0.9.5):两行依次走 stdin(user、password),
//!   值进 OS 密库(`ek/tq/user`、`ek/tq/password`),档案 `tq.ini` 只落引用行。
//!
//! 桌面路的形貌(0.9.5 阶段机):**开窗即组件配置面** —— 勾选组件 → 装配(参数面可改)
//! → 「启动运行」才出主界面,运行后装配面锁定只读(锁是界面层旗,底层不写死)。
//! 演示骨架(demo_tour)走的是 auto_boot 老序(巡回判据面隔离,不动本文件)。

// 臂折: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("tq-secret") => {
            if let Err(e) = cli_tq_secret() {
                eprintln!("tq-secret 失败: {e}");
                std::process::exit(1);
            }
        }
        Some(other) => {
            eprintln!(
                "未知子命令 `{other}`。用法:\n  ekapp              # 桌面主窗\n  ekapp add-account …\n  ekapp list-accounts"
            );
            std::process::exit(2);
        }
        None => desktop_main(),
    }
}

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

fn desktop_main() {
    // 0.9.5 阶段机:开窗**不装配不启动**(用户裁定「先配置组件、运行后才出主界面;
    // 运行后装配只可看」)。整条相路单源在 ek_ui::runtime::run_desktop
    // (原 bin 自带的 boot/start/DesktopApp 副本已删,消重)。
    if let Err(e) = ek_ui::runtime::run_desktop() {
        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.* 账户段(空白首启)");
    }
}

// ═════════════════════════════ tq-secret(天勤桥凭据,stdin 专用) ═════════════════════════════

/// 读一行 stdin(EOF = None)。口令永不进 argv、永不回显(红线同 add-account)。
fn read_stdin_line() -> Option<String> {
    use std::io::BufRead;
    let mut s = String::new();
    let n = std::io::stdin()
        .lock()
        .read_line(&mut s)
        .ok()
        .filter(|n| *n > 0)?;
    let _ = n;
    Some(s)
}

/// `ekapp tq-secret` —— 天勤桥(tq)凭据入库 + 档案段落引用行。
///
/// 形貌与账户面同款:值进 OS 密库(条目名 `ek/tq/user`、`ek/tq/password`,
/// ek-cfg secret 单源),档案 `tq.ini` 只写引用行 `secret://ek/tq/<key>`;
/// tq 插件开机从密库解密灌桥(`plugin.rs` spawn_engine 的 user/password 分支)。
/// 两行依次从 stdin 读:第一行 user,第二行 password(EOF 即止,绝不打印)。
fn cli_tq_secret() -> Result<(), String> {
    use ek_cfg::Store;
    use ek_cfg::secret::{self, SecretValue};
    use ek_core::section::{Field, SectionMap};

    println!("输入天勤 user(回车):");
    let user = read_stdin_line()
        .ok_or("stdin 缺 user 行")?
        .trim()
        .to_string();
    println!("输入天勤 password(回车,不回显):");
    let pass = read_stdin_line()
        .ok_or("stdin 缺 password 行")?
        .trim()
        .to_string();
    if user.is_empty() || pass.is_empty() {
        return Err("user/password 有空行,拒收".to_string());
    }
    secret::set(
        "tq",
        "user",
        SecretValue::from_bytes(user.as_bytes().to_vec()),
    )
    .map_err(|e| format!("密库写入 user 失败:{e}"))?;
    secret::set(
        "tq",
        "password",
        SecretValue::from_bytes(pass.as_bytes().to_vec()),
    )
    .map_err(|e| format!("密库写入 password 失败:{e}"))?;
    let store = Store::eco().map_err(|e| format!("档案托底根不可用:{e}"))?;
    // 两键都是引用形(Field::secret):tq 插件 apply_config 记 (field,key) →
    // spawn_engine 按 (user,password) 解密灌桥;明文永不在档案里出现。
    let map = SectionMap::new(1)
        .set("user", Field::secret("user"))
        .set("password", Field::secret("password"));
    store
        .store("tq", &map)
        .map_err(|e| format!("tq 段写入失败:{e}"))?;
    // 回执只报在场与引用,绝不报值。
    println!(
        "tq 凭据已入密库:user={} password={};档案段 = {}(两条引用行)",
        secret::get("tq", "user").is_some(),
        secret::get("tq", "password").is_some(),
        store.path_of("tq").unwrap_or_default().display()
    );
    Ok(())
}