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