1use std::path::PathBuf;
7
8use anyhow::{Context, Result, anyhow};
9use chrono::Utc;
10use futu_auth::store;
11
12fn default_keys_path() -> Result<PathBuf> {
14 let base =
15 dirs::config_dir().ok_or_else(|| anyhow!("cannot resolve config dir (set --keys-file)"))?;
16 Ok(base.join("futu").join("keys.json"))
17}
18
19fn resolve_path(path: Option<PathBuf>) -> Result<PathBuf> {
20 match path {
21 Some(p) => Ok(p),
22 None => default_keys_path(),
23 }
24}
25
26pub async fn list(keys_file: Option<PathBuf>, json: bool) -> Result<()> {
27 let path = resolve_path(keys_file)?;
28
29 if json {
32 if !path.exists() {
33 println!("[]");
34 return Ok(());
35 }
36 let records =
37 store::list_keys(&path).with_context(|| format!("read {}", path.display()))?;
38 let json_str =
39 serde_json::to_string_pretty(&records).with_context(|| "serialize keys to JSON")?;
40 println!("{json_str}");
41 return Ok(());
42 }
43
44 if !path.exists() {
45 println!("(no keys.json at {})", path.display());
46 return Ok(());
47 }
48 let records = store::list_keys(&path).with_context(|| format!("read {}", path.display()))?;
49
50 if records.is_empty() {
51 println!("(empty keys.json at {})", path.display());
52 return Ok(());
53 }
54
55 println!("keys.json: {}", path.display());
56 println!("total: {}", records.len());
57 println!();
58 println!(
63 "{:<20} {:<8} {:<32} {:<8} {:<10} {:<10} {:<14} {:<10} {:<13} {:<10} {:<18} NOTE",
64 "ID",
65 "STATUS",
66 "SCOPES",
67 "MARKETS",
68 "SYMBOLS",
69 "ACCOUNTS",
70 "LIMITS",
71 "DAILY",
72 "WINDOW",
73 "BOUND",
74 "EXPIRES",
75 );
76 println!("{}", "-".repeat(180));
77 let now = Utc::now();
78 for rec in &records {
79 let status = if rec.is_expired(now) {
80 "EXPIRED"
81 } else {
82 "active"
83 };
84 let scopes = {
85 let mut v: Vec<&str> = rec.scopes.iter().map(|s| s.as_str()).collect();
86 v.sort();
87 v.join(",")
88 };
89 let markets = rec
90 .allowed_markets
91 .as_ref()
92 .map(|s| {
93 let mut v: Vec<&str> = s.iter().map(|x| x.as_str()).collect();
94 v.sort();
95 v.join(",")
96 })
97 .unwrap_or_else(|| "*".to_string());
98 let symbols = match rec.allowed_symbols.as_ref() {
100 None => "*".to_string(),
101 Some(s) if s.is_empty() => "*".to_string(),
102 Some(s) if s.len() == 1 => s.iter().next().cloned().unwrap_or_else(|| "*".to_string()),
103 Some(s) => format!("{} syms", s.len()),
104 };
105 let accounts = format_accounts_summary(rec);
107 let limits = format_limits_summary(rec);
108 let daily = rec
110 .max_daily_value
111 .map(humanize_money)
112 .unwrap_or_else(|| "-".to_string());
113 let window = rec.hours_window.clone().unwrap_or_else(|| "-".to_string());
114 let bound = match rec.allowed_machines.as_deref() {
115 None => "*".to_string(),
116 Some([]) => "FROZEN".to_string(),
117 Some(list) if list.len() == 1 => "1 machine".to_string(),
118 Some(list) => format!("{} machines", list.len()),
119 };
120 let expires = rec
121 .expires_at
122 .map(|t| t.format("%Y-%m-%d %H:%MZ").to_string())
123 .unwrap_or_else(|| "-".to_string());
124 let note = rec.note.clone().unwrap_or_default();
125 println!(
126 "{:<20} {:<8} {:<32} {:<8} {:<10} {:<10} {:<14} {:<10} {:<13} {:<10} {:<18} {}",
127 truncate(&rec.id, 20),
128 status,
129 truncate(&scopes, 32),
130 truncate(&markets, 8),
131 truncate(&symbols, 10),
132 truncate(&accounts, 10),
133 truncate(&limits, 14),
134 truncate(&daily, 10),
135 truncate(&window, 13),
136 bound,
137 expires,
138 truncate(¬e, 40),
139 );
140 }
141 Ok(())
142}
143
144fn format_accounts_summary(rec: &futu_auth::KeyRecord) -> String {
151 let acc_count = rec.allowed_acc_ids.as_ref().map_or(0, |s| s.len());
152 let card_count = rec.allowed_card_nums.as_ref().map_or(0, |v| v.len());
153 let total = acc_count + card_count;
154 if total == 0 {
155 return "*".to_string();
156 }
157 if acc_count == 1 && card_count == 0 {
158 return rec
159 .allowed_acc_ids
160 .as_ref()
161 .and_then(|s| s.iter().next())
162 .map(|id| id.to_string())
163 .unwrap_or_else(|| "1 acc".to_string());
164 }
165 if acc_count == 0 && card_count == 1 {
166 return format!(
167 "~{}",
168 rec.allowed_card_nums
169 .as_ref()
170 .and_then(|v| v.first())
171 .cloned()
172 .unwrap_or_default()
173 );
174 }
175 format!("{total} accs")
176}
177
178fn format_limits_summary(rec: &futu_auth::KeyRecord) -> String {
182 let mut parts: Vec<String> = Vec::new();
183 if let Some(r) = rec.max_orders_per_minute {
184 parts.push(format!("rate={r}/m"));
185 }
186 if let Some(sides) = rec.allowed_trd_sides.as_ref()
187 && !sides.is_empty()
188 {
189 let mut v: Vec<String> = sides
191 .iter()
192 .map(|s| match s.as_str() {
193 "BUY" => "buy".into(),
194 "SELL" => "sell".into(),
195 "SELL_SHORT" => "short".into(),
196 "BUY_BACK" => "cover".into(),
197 other => other.to_ascii_lowercase(),
198 })
199 .collect();
200 v.sort();
201 parts.push(v.join(","));
202 }
203 if let Some(v) = rec.max_order_value {
204 parts.push(format!("ord={}", humanize_money(v)));
205 }
206 if parts.is_empty() {
207 "-".into()
208 } else {
209 parts.join(",")
210 }
211}
212
213fn humanize_money(v: f64) -> String {
214 if v >= 1_000_000.0 {
215 format!("{:.0}m", v / 1_000_000.0)
216 } else if v >= 1_000.0 {
217 format!("{:.0}k", v / 1_000.0)
218 } else {
219 format!("{v:.0}")
220 }
221}
222
223pub async fn revoke(id: String, keys_file: Option<PathBuf>, yes: bool) -> Result<()> {
224 let path = resolve_path(keys_file)?;
225 if !path.exists() {
226 return Err(anyhow!("keys.json not found at {}", path.display()));
227 }
228 if !yes {
229 eprintln!("About to revoke key id={:?} from {}.", id, path.display());
230 eprintln!("Pass --yes to confirm. Aborting.");
231 return Ok(());
232 }
233 let removed =
234 store::remove_key(&path, &id).with_context(|| format!("remove from {}", path.display()))?;
235 if removed {
236 println!("revoked: id={:?} ({})", id, path.display());
237 println!("note: running gateway must receive SIGHUP to pick up the change. Run:");
238 println!(" kill -HUP $(pgrep futu-opend) # Linux / macOS,shell 会自动探测 pid");
239 println!(
240 " # Windows / 没装 pgrep:先 `ps aux | grep futu-opend` 查 pid,再 `kill -HUP <pid>`"
241 );
242 } else {
243 println!("no key with id={:?} in {}", id, path.display());
244 }
245 Ok(())
246}
247
248fn truncate(s: &str, n: usize) -> String {
249 if s.chars().count() <= n {
250 s.to_string()
251 } else {
252 let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
253 out.push('…');
254 out
255 }
256}
257
258#[cfg(test)]
259mod tests;