1use std::io::Read;
8use std::sync::Arc;
9
10use crate::state::AppState;
11
12const SETTINGS_TEMPLATE: &str = include_str!("../../../settings-template.jsonc");
13
14pub fn get_platform() -> serde_json::Value {
16 let platform = match std::env::consts::OS {
17 "macos" => "darwin",
18 "windows" => "win32",
19 other => other,
20 };
21 serde_json::json!(platform)
22}
23
24pub fn get_user_name() -> serde_json::Value {
26 serde_json::json!(whoami::username())
27}
28
29pub fn get_host_name() -> serde_json::Value {
31 let hostname = whoami::fallible::hostname().unwrap_or_else(|_| "unknown".to_string());
32 serde_json::json!(hostname)
33}
34
35pub fn get_is_dev() -> serde_json::Value {
38 let mode = agentmux_common::RuntimeMode::from_env().or_else(|| {
39 std::env::current_exe()
40 .ok()
41 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
42 .map(|d| agentmux_common::RuntimeMode::current(&d))
43 });
44 serde_json::json!(matches!(mode, Some(agentmux_common::RuntimeMode::Dev { .. })))
45}
46
47pub fn get_data_dir(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
49 let dir = state.version_data_dir.lock();
50 match dir.as_ref() {
51 Some(d) => Ok(serde_json::json!(d)),
52 None => Err("Data dir not initialized yet".to_string()),
53 }
54}
55
56pub fn get_config_dir(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
58 let dir = state.version_config_dir.lock();
59 match dir.as_ref() {
60 Some(d) => Ok(serde_json::json!(d)),
61 None => Err("Config dir not initialized yet".to_string()),
62 }
63}
64
65pub fn get_user_home_dir(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
72 let dir = state.user_home_dir.lock();
73 match dir.as_ref() {
74 Some(d) => Ok(serde_json::json!(d)),
75 None => Err("User home dir not initialized yet".to_string()),
76 }
77}
78
79pub fn ensure_auth_dir(
82 state: &Arc<AppState>,
83 args: &serde_json::Value,
84) -> Result<serde_json::Value, String> {
85 let provider_id = args
86 .get("provider_id")
87 .or_else(|| args.get("providerId"))
88 .and_then(|v| v.as_str())
89 .ok_or_else(|| "Missing provider_id".to_string())?;
90
91 if provider_id.contains('/')
93 || provider_id.contains('\\')
94 || provider_id.contains("..")
95 || provider_id.is_empty()
96 {
97 return Err(format!(
98 "Invalid provider_id '{}': must not contain path separators or '..'",
99 provider_id
100 ));
101 }
102
103 let config_dir = state.version_config_dir.lock();
104 let config_dir = config_dir
105 .as_ref()
106 .ok_or_else(|| "Config dir not initialized yet".to_string())?;
107
108 let auth_dir = std::path::PathBuf::from(config_dir)
109 .join("auth")
110 .join(provider_id);
111 std::fs::create_dir_all(&auth_dir)
112 .map_err(|e| format!("Failed to create auth dir for {}: {}", provider_id, e))?;
113
114 Ok(serde_json::json!(auth_dir.to_string_lossy()))
115}
116
117pub fn get_env(args: &serde_json::Value) -> serde_json::Value {
119 let key = args
120 .get("key")
121 .and_then(|v| v.as_str())
122 .unwrap_or_default();
123 match std::env::var(key) {
124 Ok(val) => serde_json::json!(val),
125 Err(_) => serde_json::Value::Null,
126 }
127}
128
129pub fn get_about_modal_details(state: &Arc<AppState>) -> serde_json::Value {
131 let version = env!("CARGO_PKG_VERSION");
132 let endpoints = state.backend_endpoints.lock();
133
134 serde_json::json!({
135 "version": version,
136 "gitHash": env!("AGENTMUX_GIT_HASH"),
137 "buildTime": env!("AGENTMUX_BUILD_TIME").parse::<i64>().unwrap_or(0),
138 "platform": match std::env::consts::OS {
139 "macos" => "darwin",
140 "windows" => "win32",
141 other => other,
142 },
143 "arch": std::env::consts::ARCH,
144 "backendEndpoints": {
145 "ws": endpoints.ws_endpoint,
146 "web": endpoints.web_endpoint,
147 }
148 })
149}
150
151pub fn get_host_info(state: &Arc<AppState>) -> serde_json::Value {
153 let version = env!("CARGO_PKG_VERSION");
154 let endpoints = state.backend_endpoints.lock();
155 let ipc_port = *state.ipc_port.lock();
156 let data_dir = state.version_data_dir.lock().clone().unwrap_or_default();
157 let pid = std::process::id();
158
159 let local_ip = local_ip_address().unwrap_or_else(|| "127.0.0.1".to_string());
161
162 let os_info = format!("{} {}",
163 match std::env::consts::OS {
164 "windows" => "Windows",
165 "macos" => "macOS",
166 "linux" => "Linux",
167 other => other,
168 },
169 std::env::consts::ARCH
170 );
171
172 serde_json::json!({
173 "hostname": whoami::fallible::hostname().unwrap_or_else(|_| "unknown".to_string()),
174 "os": os_info,
175 "localIp": local_ip,
176 "instanceId": format!("v{}", version),
177 "version": version,
178 "dataDir": data_dir,
179 "hostType": "CEF 146",
180 "pid": pid,
181 "ports": {
182 "ipc": format!("127.0.0.1:{}", ipc_port),
183 "web": endpoints.web_endpoint,
184 "ws": endpoints.ws_endpoint,
185 "devtools": "127.0.0.1:9222",
186 }
187 })
188}
189
190fn local_ip_address() -> Option<String> {
192 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
195 socket.connect("8.8.8.8:80").ok()?;
196 let addr = socket.local_addr().ok()?;
197 Some(addr.ip().to_string())
198}
199
200pub fn get_docsite_url(state: &Arc<AppState>) -> serde_json::Value {
202 let endpoints = state.backend_endpoints.lock();
203 if !endpoints.web_endpoint.is_empty() {
204 serde_json::json!(format!("http://{}/docsite/", endpoints.web_endpoint))
205 } else {
206 serde_json::json!("https://docs.agentmux.ai")
207 }
208}
209
210pub fn open_in_editor(args: &serde_json::Value) -> Result<serde_json::Value, String> {
212 let path = args
213 .get("path")
214 .and_then(|v| v.as_str())
215 .ok_or_else(|| "Missing path".to_string())?;
216
217 #[cfg(target_os = "windows")]
218 {
219 std::process::Command::new("explorer")
221 .arg(path)
222 .spawn()
223 .map_err(|e| format!("Failed to open file: {}", e))?;
224 return Ok(serde_json::Value::Null);
225 }
226
227 #[cfg(not(target_os = "windows"))]
228 {
229 let cli_editors = ["code", "cursor", "zed", "subl", "atom"];
230 for editor in &cli_editors {
231 if std::process::Command::new(editor).arg(path).spawn().is_ok() {
232 return Ok(serde_json::Value::Null);
233 }
234 }
235 }
236
237 #[cfg(target_os = "macos")]
238 {
239 std::process::Command::new("open")
240 .arg(path)
241 .spawn()
242 .map_err(|e| e.to_string())?;
243 return Ok(serde_json::Value::Null);
244 }
245 #[cfg(target_os = "linux")]
246 {
247 std::process::Command::new("xdg-open")
248 .arg(path)
249 .spawn()
250 .map_err(|e| e.to_string())?;
251 return Ok(serde_json::Value::Null);
252 }
253
254 #[allow(unreachable_code)]
255 Ok(serde_json::Value::Null)
256}
257
258pub fn ensure_settings_file(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
260 let config_dir_str = state
261 .version_config_dir
262 .lock()
263 .clone()
264 .ok_or_else(|| "Config dir not initialized yet".to_string())?;
265 let config_dir = std::path::PathBuf::from(&config_dir_str);
266
267 std::fs::create_dir_all(&config_dir)
268 .map_err(|e| format!("Failed to create config dir: {}", e))?;
269
270 let settings_path = config_dir.join("settings.json");
271
272 let existing = read_settings_jsonc(&settings_path);
274
275 let merged = merge_into_template(SETTINGS_TEMPLATE, &existing);
277 std::fs::write(&settings_path, &merged)
278 .map_err(|e| format!("Failed to write settings.json: {}", e))?;
279
280 Ok(serde_json::json!(settings_path.to_string_lossy()))
281}
282
283pub enum CliLoginStdin {
287 Pipe(tokio::process::ChildStdin),
292 Pty(Box<dyn std::io::Write + Send>),
296}
297
298impl CliLoginStdin {
299 pub async fn write_line(&mut self, line: &str) -> std::io::Result<()> {
302 let payload = format!("{}\n", line);
303 match self {
304 CliLoginStdin::Pipe(s) => {
305 use tokio::io::AsyncWriteExt;
306 s.write_all(payload.as_bytes()).await?;
307 s.flush().await?;
308 Ok(())
309 }
310 CliLoginStdin::Pty(w) => {
311 use std::io::Write;
312 tokio::task::block_in_place(|| {
317 w.write_all(payload.as_bytes())?;
318 w.flush()
319 })
320 }
321 }
322 }
323}
324
325pub async fn run_cli_login(
327 state: Arc<AppState>,
328 args: &serde_json::Value,
329) -> Result<serde_json::Value, String> {
330 let cli_path = args
331 .get("cli_path")
332 .or_else(|| args.get("cliPath"))
333 .and_then(|v| v.as_str())
334 .ok_or_else(|| "Missing cli_path".to_string())?
335 .to_string();
336
337 let login_args: Vec<String> = args
338 .get("login_args")
339 .or_else(|| args.get("loginArgs"))
340 .and_then(|v| v.as_array())
341 .map(|arr| {
342 arr.iter()
343 .filter_map(|v| v.as_str().map(|s| s.to_string()))
344 .collect()
345 })
346 .unwrap_or_default();
347
348 let auth_env: std::collections::HashMap<String, String> = args
349 .get("auth_env")
350 .or_else(|| args.get("authEnv"))
351 .and_then(|v| v.as_object())
352 .map(|obj| {
353 obj.iter()
354 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
355 .collect()
356 })
357 .unwrap_or_default();
358
359 let requires_tty = args
365 .get("requires_tty")
366 .or_else(|| args.get("requiresTty"))
367 .and_then(|v| v.as_bool())
368 .unwrap_or(false);
369
370 if requires_tty {
371 return run_cli_login_pty(state, cli_path, login_args, auth_env).await;
372 }
373
374 let mut cmd = make_cli_cmd(&cli_path);
375 cmd.args(&login_args)
376 .envs(&auth_env)
377 .stdin(std::process::Stdio::piped())
378 .stdout(std::process::Stdio::piped())
379 .stderr(std::process::Stdio::piped());
380
381 #[cfg(windows)]
382 {
383 cmd.creation_flags(0x08000000); }
385
386 let mut child = cmd
387 .spawn()
388 .map_err(|e| format!("failed to spawn {cli_path}: {e}"))?;
389
390 tracing::info!(cli = %cli_path, "run_cli_login: spawned (pipes), browser should open");
391
392 {
394 let mut stored_stdin = state.cli_login_stdin.lock();
395 *stored_stdin = child.stdin.take().map(CliLoginStdin::Pipe);
396 }
397
398 let stdout = child.stdout.take();
402 let stderr = child.stderr.take();
403
404 let auth_url: Option<String> = tokio::time::timeout(
405 std::time::Duration::from_secs(2),
406 async {
407 use tokio::io::AsyncBufReadExt;
408 let mut combined = Vec::new();
409 if let Some(s) = stdout {
410 let mut lines = tokio::io::BufReader::new(s).lines();
411 while let Ok(Some(line)) = lines.next_line().await {
412 if let Some(url) = extract_url(&line) {
413 return Some(url);
414 }
415 combined.push(line);
416 if combined.len() > 20 { break; }
417 }
418 }
419 if let Some(s) = stderr {
420 let mut lines = tokio::io::BufReader::new(s).lines();
421 while let Ok(Some(line)) = lines.next_line().await {
422 if let Some(url) = extract_url(&line) {
423 return Some(url);
424 }
425 if combined.len() > 40 { break; }
426 combined.push(line);
427 }
428 }
429 None
430 },
431 ).await.unwrap_or(None);
432
433 if let Some(ref url) = auth_url {
434 tracing::info!(url = %url, "run_cli_login: captured auth URL");
435 } else {
436 tracing::warn!("run_cli_login: no auth URL captured within 2s");
437 }
438
439 let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
440 {
441 let mut stored = state.cli_login_cancel.lock();
442 *stored = Some(cancel_tx);
443 }
444
445 let state_for_cleanup = state.clone();
446 tokio::spawn(async move {
447 tokio::select! {
448 result = child.wait() => {
449 match result {
450 Ok(status) => tracing::info!(
451 exit_code = ?status.code(),
452 "run_cli_login: child exited"
453 ),
454 Err(e) => tracing::warn!(
455 error = %e,
456 "run_cli_login: child wait error"
457 ),
458 }
459 }
460 _ = cancel_rx => {
461 tracing::info!("run_cli_login: cancel signal received, killing child");
462 let _ = child.kill().await;
463 }
464 }
465 *state_for_cleanup.cli_login_stdin.lock() = None;
467 });
468
469 Ok(serde_json::json!({ "auth_url": auth_url }))
470}
471
472async fn run_cli_login_pty(
487 state: Arc<AppState>,
488 cli_path: String,
489 login_args: Vec<String>,
490 auth_env: std::collections::HashMap<String, String>,
491) -> Result<serde_json::Value, String> {
492 use portable_pty::{native_pty_system, CommandBuilder, PtySize};
493
494 let pty_system = native_pty_system();
495 let pair = pty_system
496 .openpty(PtySize {
497 rows: 24,
498 cols: 80,
499 pixel_width: 0,
500 pixel_height: 0,
501 })
502 .map_err(|e| format!("openpty for {cli_path}: {e}"))?;
503
504 let mut cmd = CommandBuilder::new(&cli_path);
505 for a in &login_args {
506 cmd.arg(a);
507 }
508 for (k, v) in &auth_env {
509 cmd.env(k, v);
510 }
511 if let Ok(cwd) = std::env::current_dir() {
512 cmd.cwd(cwd);
513 }
514
515 let child = pair
516 .slave
517 .spawn_command(cmd)
518 .map_err(|e| format!("PTY spawn of {cli_path}: {e}"))?;
519
520 let child_pid = child.process_id();
525 if let Some(pid) = child_pid {
526 *state.cli_login_pty_pid.lock() = Some(pid);
527 }
528
529 let reader = pair
530 .master
531 .try_clone_reader()
532 .map_err(|e| format!("PTY try_clone_reader: {e}"))?;
533 let writer = pair
534 .master
535 .take_writer()
536 .map_err(|e| format!("PTY take_writer: {e}"))?;
537
538 tracing::info!(cli = %cli_path, pid = ?child_pid, "run_cli_login: spawned (PTY), waiting for OAuth URL");
539
540 {
543 let mut stored = state.cli_login_stdin.lock();
544 *stored = Some(CliLoginStdin::Pty(writer));
545 }
546
547 let (url_tx, url_rx) = tokio::sync::oneshot::channel::<Option<String>>();
557 tokio::task::spawn_blocking(move || {
558 use std::io::BufRead;
559 let mut reader = std::io::BufReader::new(reader);
560 let mut found: Option<String> = None;
561 let mut line = String::new();
562 loop {
563 line.clear();
564 match reader.read_line(&mut line) {
565 Ok(0) => break, Ok(_) => {
567 if let Some(u) = extract_url(&line) {
568 found = Some(u);
569 break;
570 }
571 }
572 Err(e) => {
573 tracing::warn!(error = %e, "run_cli_login_pty: read error");
574 break;
575 }
576 }
577 }
578 let _ = url_tx.send(found);
579 });
582
583 let auth_url: Option<String> = match tokio::time::timeout(
584 std::time::Duration::from_secs(15),
585 url_rx,
586 )
587 .await
588 {
589 Ok(Ok(u)) => u,
590 Ok(Err(_)) | Err(_) => None,
591 };
592 if let Some(ref url) = auth_url {
593 tracing::info!(url = %url, "run_cli_login_pty: captured auth URL");
594 } else {
595 tracing::warn!("run_cli_login_pty: no auth URL captured within 15s");
596 }
597
598 let state_for_cleanup = state.clone();
607 tokio::task::spawn_blocking(move || {
608 let mut child = child;
609 match child.wait() {
610 Ok(status) => tracing::info!(
611 exit_code = ?status.exit_code(),
612 "run_cli_login_pty: child exited"
613 ),
614 Err(e) => tracing::warn!(
615 error = %e,
616 "run_cli_login_pty: child wait error"
617 ),
618 }
619 drop(pair);
621 *state_for_cleanup.cli_login_stdin.lock() = None;
622 *state_for_cleanup.cli_login_pty_pid.lock() = None;
623 });
624
625 Ok(serde_json::json!({ "auth_url": auth_url }))
626}
627
628fn extract_url(line: &str) -> Option<String> {
631 let clean: String = {
633 let mut out = String::with_capacity(line.len());
634 let bytes = line.as_bytes();
635 let mut i = 0;
636 while i < bytes.len() {
637 if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i+1] == b'[' {
638 i += 2;
640 while i < bytes.len() && !(bytes[i] as char).is_ascii_alphabetic() {
641 i += 1;
642 }
643 i += 1;
644 } else {
645 out.push(bytes[i] as char);
646 i += 1;
647 }
648 }
649 out
650 };
651
652 if let Some(start) = clean.find("https://") {
654 let rest = &clean[start..];
655 let end = rest.find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
656 .unwrap_or(rest.len());
657 let url = &rest[..end];
658 if url.contains("oauth") || url.contains("auth") || url.contains("login") {
659 return Some(url.to_string());
660 }
661 }
662 None
663}
664
665pub fn cancel_cli_login(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
671 let sender = {
673 let mut stored = state.cli_login_cancel.lock();
674 stored.take()
675 };
676 if let Some(tx) = sender {
677 let _ = tx.send(());
678 tracing::info!("cancel_cli_login: pipe-path cancel signal sent");
679 }
680 let pid = {
682 let mut stored = state.cli_login_pty_pid.lock();
683 stored.take()
684 };
685 if let Some(pid) = pid {
686 if let Err(e) = kill_pid(pid) {
687 tracing::warn!(pid, error = %e, "cancel_cli_login: kill_pid failed");
688 } else {
689 tracing::info!(pid, "cancel_cli_login: PTY child killed");
690 }
691 }
692 Ok(serde_json::Value::Null)
693}
694
695#[cfg(windows)]
697fn kill_pid(pid: u32) -> std::io::Result<()> {
698 let status = std::process::Command::new("taskkill")
701 .args(["/F", "/T", "/PID", &pid.to_string()])
702 .stdin(std::process::Stdio::null())
703 .stdout(std::process::Stdio::null())
704 .stderr(std::process::Stdio::null())
705 .status()?;
706 if status.success() {
707 Ok(())
708 } else {
709 Err(std::io::Error::other(format!("taskkill exit {:?}", status.code())))
710 }
711}
712
713#[cfg(unix)]
714fn kill_pid(pid: u32) -> std::io::Result<()> {
715 let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
717 if ret != 0 {
718 return Err(std::io::Error::last_os_error());
719 }
720 Ok(())
721}
722
723fn make_cli_cmd(cli_path: &str) -> tokio::process::Command {
726 agentmux_common::make_cli_cmd(cli_path)
727}
728
729fn read_settings_jsonc(path: &std::path::Path) -> serde_json::Map<String, serde_json::Value> {
732 if !path.exists() {
733 return serde_json::Map::new();
734 }
735 match std::fs::read_to_string(path) {
736 Ok(content) => {
737 let stripped = json_comments::StripComments::new(content.as_bytes());
738 let mut json_bytes = Vec::new();
739 std::io::BufReader::new(stripped)
740 .read_to_end(&mut json_bytes)
741 .unwrap_or_default();
742 let json_str = strip_trailing_commas(&String::from_utf8_lossy(&json_bytes));
743 match serde_json::from_str::<serde_json::Value>(&json_str) {
744 Ok(serde_json::Value::Object(map)) => map,
745 _ => serde_json::Map::new(),
746 }
747 }
748 Err(_) => serde_json::Map::new(),
749 }
750}
751
752fn strip_trailing_commas(input: &str) -> String {
753 let mut result = String::with_capacity(input.len());
754 let mut in_string = false;
755 let mut last_comma_pos: Option<usize> = None;
756
757 for ch in input.chars() {
758 if in_string {
759 result.push(ch);
760 if ch == '"' {
761 let backslashes = result[..result.len() - 1]
762 .chars()
763 .rev()
764 .take_while(|&c| c == '\\')
765 .count();
766 if backslashes % 2 == 0 {
767 in_string = false;
768 }
769 }
770 continue;
771 }
772 match ch {
773 '"' => {
774 in_string = true;
775 last_comma_pos = None;
776 result.push(ch);
777 }
778 ',' => {
779 last_comma_pos = Some(result.len());
780 result.push(ch);
781 }
782 '}' | ']' => {
783 if let Some(pos) = last_comma_pos {
784 result.replace_range(pos..pos + 1, " ");
785 }
786 last_comma_pos = None;
787 result.push(ch);
788 }
789 _ if ch.is_whitespace() => {
790 result.push(ch);
791 }
792 _ => {
793 last_comma_pos = None;
794 result.push(ch);
795 }
796 }
797 }
798 result
799}
800
801fn merge_into_template(
802 template: &str,
803 user_settings: &serde_json::Map<String, serde_json::Value>,
804) -> String {
805 if user_settings.is_empty() {
806 return template.to_string();
807 }
808
809 let mut remaining: std::collections::HashMap<&str, &serde_json::Value> =
810 user_settings.iter().map(|(k, v)| (k.as_str(), v)).collect();
811 let mut lines: Vec<String> = Vec::new();
812
813 for line in template.lines() {
814 if let Some(key) = extract_commented_setting_key(line) {
815 if let Some(value) = remaining.remove(key) {
816 let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
817 let val_str = serde_json::to_string(value).unwrap_or_default();
818 lines.push(format!("{}\"{}\": {},", indent, key, val_str));
819 continue;
820 }
821 }
822 lines.push(line.to_string());
823 }
824
825 if !remaining.is_empty() {
826 if let Some(brace_pos) = lines.iter().rposition(|l| l.trim() == "}") {
827 let mut extra: Vec<String> = Vec::new();
828 extra.push(String::new());
829 extra.push(" // -- User Overrides --".to_string());
830 let mut sorted_keys: Vec<&&str> = remaining.keys().collect();
831 sorted_keys.sort();
832 for key in sorted_keys {
833 let value = remaining[*key];
834 let val_str = serde_json::to_string(value).unwrap_or_default();
835 extra.push(format!(" \"{}\": {},", key, val_str));
836 }
837 for (i, line) in extra.into_iter().enumerate() {
838 lines.insert(brace_pos + i, line);
839 }
840 }
841 }
842
843 let mut result = lines.join("\n");
844 if !result.ends_with('\n') {
845 result.push('\n');
846 }
847 result
848}
849
850pub fn open_external(args: &serde_json::Value) -> Result<serde_json::Value, String> {
852 let url = args
853 .get("url")
854 .and_then(|v| v.as_str())
855 .ok_or_else(|| "Missing url".to_string())?;
856
857 if !url.starts_with("http://") && !url.starts_with("https://") && !url.starts_with("devtools://") {
859 return Err(format!("Refusing to open URL with unsupported scheme: {}", url));
860 }
861
862 #[cfg(target_os = "windows")]
863 {
864 let _ = std::process::Command::new("rundll32.exe")
873 .args(["url.dll,FileProtocolHandler", url])
874 .spawn()
875 .map_err(|e| format!("Failed to open URL: {}", e))?;
876 }
877 #[cfg(target_os = "macos")]
878 {
879 let _ = std::process::Command::new("open")
880 .arg(url)
881 .spawn()
882 .map_err(|e| format!("Failed to open URL: {}", e))?;
883 }
884 #[cfg(target_os = "linux")]
885 {
886 let _ = std::process::Command::new("xdg-open")
887 .arg(url)
888 .spawn()
889 .map_err(|e| format!("Failed to open URL: {}", e))?;
890 }
891
892 Ok(serde_json::Value::Null)
893}
894
895fn extract_commented_setting_key(line: &str) -> Option<&str> {
896 let trimmed = line.trim_start();
897 let rest = trimmed.strip_prefix("//")?;
898 let rest = rest.trim_start();
899 let rest = rest.strip_prefix('"')?;
900 let end = rest.find('"')?;
901 Some(&rest[..end])
902}