1use std::io::{self, BufRead};
18
19use anyhow::{Context, Result, bail};
20use serde::Serialize;
21
22use crate::common::connect_gateway;
23
24#[derive(Debug, Clone, Copy, clap::ValueEnum)]
27#[non_exhaustive]
28pub enum SecurityFirmArg {
29 #[value(name = "FutuHK", alias = "hk", alias = "futu-hk", alias = "1")]
30 FutuHK,
31 #[value(
32 name = "FutuUS",
33 alias = "us",
34 alias = "futu-us",
35 alias = "2",
36 alias = "moomoo",
37 alias = "mm"
38 )]
39 FutuUS,
40 #[value(name = "FutuSG", alias = "sg", alias = "futu-sg", alias = "3")]
41 FutuSG,
42 #[value(name = "FutuAU", alias = "au", alias = "futu-au", alias = "4")]
43 FutuAU,
44 #[value(name = "FutuCA", alias = "ca", alias = "futu-ca", alias = "5")]
45 FutuCA,
46 #[value(name = "FutuMY", alias = "my", alias = "futu-my", alias = "6")]
47 FutuMY,
48 #[value(name = "FutuJP", alias = "jp", alias = "futu-jp", alias = "7")]
49 FutuJP,
50}
51
52impl SecurityFirmArg {
53 pub fn as_i32(self) -> i32 {
54 match self {
55 Self::FutuHK => 1,
56 Self::FutuUS => 2,
57 Self::FutuSG => 3,
58 Self::FutuAU => 4,
59 Self::FutuCA => 5,
60 Self::FutuMY => 6,
61 Self::FutuJP => 7,
62 }
63 }
64}
65
66pub async fn run(
67 gateway: &str,
68 lock: bool,
69 from_stdin: bool,
70 trade_pwd_account: Option<&str>,
71 otp: Option<String>,
72 security_firm: Option<SecurityFirmArg>,
73 acc_ids: Vec<u64>,
76 format: crate::output::OutputFormat,
79) -> Result<()> {
80 let (client, _push_rx) = connect_gateway(gateway, "futucli-unlock").await?;
81
82 if lock {
83 futu_trd::account::unlock_trade(
85 &client,
86 "",
87 false,
88 None,
89 security_firm.map(|s| s.as_i32()),
90 acc_ids,
91 )
92 .await
93 .context("lock trade failed")?;
94 match format {
95 crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl => {
96 let outcome = futu_trd::account::UnlockTradeOutcome {
97 total_requested: 0,
98 total_unlocked: 0,
99 need_otp: false,
100 failed_accounts: vec![],
101 message: None,
102 };
103 println!(
104 "{}",
105 render_unlock_trade_output(format, "lock", gateway, &outcome)?
106 );
107 }
108 _ => {
109 println!("Trade locked on gateway {gateway}.");
110 }
111 }
112 return Ok(());
113 }
114
115 let pwd = read_password(from_stdin, trade_pwd_account)?;
116 if pwd.is_empty() {
117 bail!("empty password");
118 }
119 let pwd_md5 = format!("{:x}", md5::compute(pwd.as_bytes()));
120
121 let outcome = futu_trd::account::unlock_trade(
122 &client,
123 &pwd_md5,
124 true,
125 otp.as_deref(),
126 security_firm.map(|s| s.as_i32()),
127 acc_ids,
128 )
129 .await
130 .context("unlock trade failed")?;
131
132 if matches!(
134 format,
135 crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl
136 ) {
137 println!(
138 "{}",
139 render_unlock_trade_output(format, "unlock", gateway, &outcome)?
140 );
141 return Ok(());
142 }
143
144 if outcome.need_otp {
145 println!(
146 "⚠️ 服务端要求 OTP / 令牌动态密码。失败账户:{:?}",
147 outcome.failed_accounts
148 );
149 println!(
150 " 重试:`futucli unlock-trade --otp REPLACE_WITH_6DIGIT_OTP`(保留相同密码来源)"
151 );
152 println!(
153 " ⚠️ 把 `REPLACE_WITH_6DIGIT_OTP` 换成富途令牌 app 里当前显示的 6 位动态密码,别原样粘贴"
154 );
155 return Ok(());
156 }
157 println!(
158 "Trade unlock: {}/{} accounts unlocked.",
159 outcome.total_unlocked, outcome.total_requested
160 );
161 if outcome.total_unlocked < outcome.total_requested {
162 println!(
163 "⚠️ 失败账户(常见原因:该账户品种权限未开通 / 影子子账户):{:?}",
164 outcome.failed_accounts
165 );
166 if let Some(msg) = &outcome.message {
167 println!(" daemon 信息:{msg}");
168 }
169 }
170 println!("Cipher is cached in the gateway process; will expire when gateway restarts.");
171 Ok(())
172}
173
174#[derive(Serialize)]
175struct UnlockTradeCliOutput<'a> {
176 ok: bool,
177 action: &'a str,
178 gateway: &'a str,
179 total_requested: usize,
180 total_unlocked: usize,
181 need_otp: bool,
182 failed_accounts: &'a [u64],
183 #[serde(skip_serializing_if = "Option::is_none")]
184 message: Option<&'a str>,
185 cipher_cached: bool,
186}
187
188fn render_unlock_trade_output(
189 format: crate::output::OutputFormat,
190 action: &str,
191 gateway: &str,
192 outcome: &futu_trd::account::UnlockTradeOutcome,
193) -> Result<String> {
194 let output = UnlockTradeCliOutput {
195 ok: !outcome.need_otp && outcome.total_unlocked == outcome.total_requested,
196 action,
197 gateway,
198 total_requested: outcome.total_requested,
199 total_unlocked: outcome.total_unlocked,
200 need_otp: outcome.need_otp,
201 failed_accounts: &outcome.failed_accounts,
202 message: outcome.message.as_deref(),
203 cipher_cached: action == "unlock" && !outcome.need_otp && outcome.total_unlocked > 0,
204 };
205
206 match format {
207 crate::output::OutputFormat::Json => {
208 serde_json::to_string_pretty(&output).map_err(Into::into)
209 }
210 crate::output::OutputFormat::Jsonl => serde_json::to_string(&output).map_err(Into::into),
211 crate::output::OutputFormat::Table | crate::output::OutputFormat::Markdown => Ok(format!(
212 "Trade {action}: {}/{} accounts unlocked.",
213 outcome.total_unlocked, outcome.total_requested
214 )),
215 }
216}
217
218fn read_password(from_stdin: bool, trade_pwd_account: Option<&str>) -> Result<String> {
219 if from_stdin {
220 let mut line = String::new();
221 io::stdin()
222 .lock()
223 .read_line(&mut line)
224 .context("read password from stdin")?;
225 return Ok(trim_stdin_password_line(&line));
226 }
227
228 let trade_pwd_account_env = std::env::var("FUTU_TRADE_PWD_ACCOUNT").ok();
229 let futu_account_env = std::env::var("FUTU_ACCOUNT").ok();
230 let env_pwd = std::env::var("FUTU_TRADE_PWD").ok();
231
232 if let Some(p) = trade_password_from_sources(
233 trade_pwd_account,
234 trade_pwd_account_env.as_deref(),
235 futu_account_env.as_deref(),
236 env_pwd.as_deref(),
237 read_keyring_password_entry,
238 ) {
239 return Ok(p);
240 }
241
242 rpassword::prompt_password("Trade password: ").context("read password from tty")
243}
244
245fn non_empty_trimmed(s: &str) -> Option<String> {
246 let s = s.trim();
247 (!s.is_empty()).then(|| s.to_string())
248}
249
250fn trade_pwd_account_from(
251 explicit: Option<&str>,
252 trade_pwd_account_env: Option<&str>,
253 futu_account_env: Option<&str>,
254) -> Option<String> {
255 explicit
256 .and_then(non_empty_trimmed)
257 .or_else(|| trade_pwd_account_env.and_then(non_empty_trimmed))
258 .or_else(|| futu_account_env.and_then(non_empty_trimmed))
259}
260
261fn read_keyring_password_entry(username: &str) -> Option<String> {
262 if let Ok(entry) = keyring::Entry::new(futu_auth::KEYRING_SERVICE, username)
263 && let Ok(pwd) = entry.get_password()
264 && !pwd.is_empty()
265 {
266 return Some(pwd);
267 }
268 None
269}
270
271fn trade_password_from_sources(
272 account_hint: Option<&str>,
273 trade_pwd_account_env: Option<&str>,
274 futu_account_env: Option<&str>,
275 env_pwd: Option<&str>,
276 mut read_keyring: impl FnMut(&str) -> Option<String>,
277) -> Option<String> {
278 if let Some(pwd) = env_pwd.and_then(non_empty_trimmed) {
279 return Some(pwd);
280 }
281
282 if let Some(account) =
283 trade_pwd_account_from(account_hint, trade_pwd_account_env, futu_account_env)
284 {
285 let scoped_username = futu_auth::keyring_username_for_trade_pwd(&account);
286 if let Some(pwd) = read_keyring(&scoped_username) {
287 return Some(pwd);
288 }
289 }
290
291 read_keyring(futu_auth::KEYRING_USERNAME_TRADE_PWD)
292}
293
294fn trim_stdin_password_line(line: &str) -> String {
295 line.trim_end_matches(['\n', '\r']).to_string()
296}
297
298fn read_keychain_password(kind: &str, from_stdin: bool) -> Result<String> {
299 if from_stdin {
300 let mut line = String::new();
301 io::stdin()
302 .lock()
303 .read_line(&mut line)
304 .with_context(|| format!("read {kind} password from stdin"))?;
305 let password = trim_stdin_password_line(&line);
306 if password.is_empty() {
307 bail!("empty password");
308 }
309 return Ok(password);
310 }
311
312 let pwd1 = rpassword::prompt_password(format!("{kind} password: "))
313 .context("read password from tty")?;
314 if pwd1.is_empty() {
315 bail!("empty password");
316 }
317 let pwd2 = rpassword::prompt_password("Confirm password: ").context("read confirm from tty")?;
318 if pwd1 != pwd2 {
319 bail!("passwords do not match");
320 }
321 Ok(pwd1)
322}
323
324pub async fn set_trade_pwd(account: &str, from_stdin: bool) -> Result<()> {
330 let account = account.trim();
331 if account.is_empty() {
332 bail!("--account is required");
333 }
334 let pwd = read_keychain_password("Trade", from_stdin)?;
335 let username = futu_auth::keyring_username_for_trade_pwd(account);
336 let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
337 .context("create keyring entry")?;
338 entry
339 .set_password(&pwd)
340 .context("write password to OS keychain")?;
341 println!(
342 "✓ trade password saved to OS keychain (service={}, account={})",
343 futu_auth::KEYRING_SERVICE,
344 username
345 );
346 println!(
347 " futucli unlock-trade / futu-mcp read it with --trade-pwd-account {account} \
348 (or FUTU_TRADE_PWD_ACCOUNT={account})."
349 );
350 Ok(())
351}
352
353pub async fn clear_trade_pwd(account: &str) -> Result<()> {
355 let account = account.trim();
356 if account.is_empty() {
357 bail!("--account is required");
358 }
359 let username = futu_auth::keyring_username_for_trade_pwd(account);
360 let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
361 .context("create keyring entry")?;
362 match entry.delete_credential() {
363 Ok(()) => println!("✓ trade password removed from OS keychain (account={account})"),
364 Err(keyring::Error::NoEntry) => println!("(no entry existed; nothing to remove)"),
365 Err(e) => return Err(anyhow::anyhow!("delete from keychain failed: {e}")),
366 }
367 Ok(())
368}
369
370pub async fn set_login_pwd(account: &str, from_stdin: bool) -> Result<()> {
379 if account.is_empty() {
380 bail!("--account is required");
381 }
382 let pwd = read_keychain_password("Login", from_stdin)?;
383 let username = futu_auth::keyring_username_for_login_pwd(account);
384 let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
385 .context("create keyring entry")?;
386 entry
387 .set_password(&pwd)
388 .context("write password to OS keychain")?;
389 println!(
390 "✓ login password saved to OS keychain (service={}, account={})",
391 futu_auth::KEYRING_SERVICE,
392 username
393 );
394 println!(" futu-opend will read it automatically when --login-pwd / FUTU_PWD is not set.");
395 Ok(())
396}
397
398pub async fn clear_login_pwd(account: &str) -> Result<()> {
400 if account.is_empty() {
401 bail!("--account is required");
402 }
403 let username = futu_auth::keyring_username_for_login_pwd(account);
404 let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
405 .context("create keyring entry")?;
406 match entry.delete_credential() {
407 Ok(()) => println!("✓ login password removed from OS keychain (account={account})"),
408 Err(keyring::Error::NoEntry) => println!("(no entry existed; nothing to remove)"),
409 Err(e) => return Err(anyhow::anyhow!("delete from keychain failed: {e}")),
410 }
411 Ok(())
412}
413
414#[cfg(test)]
415mod tests;