Skip to main content

j_cli/command/
open.rs

1use crate::config::YamlConfig;
2use crate::constants::{DEFAULT_SEARCH_ENGINE, config_key, search_engine, section, shell};
3use crate::{error, info};
4use std::path::Path;
5use std::process::Command;
6
7/// 通过别名打开应用/文件/URL
8/// args[0] = alias, args[1..] = 额外参数
9pub fn handle_open(args: &[String], config: &YamlConfig) {
10    if args.is_empty() {
11        error!("❌ 请指定要打开的别名");
12        return;
13    }
14
15    let alias = &args[0];
16
17    // 检查别名是否存在
18    if !config.alias_exists(alias) {
19        error!(
20            "❌ 无法找到别名对应的路径或网址 {{{}}}。请检查配置文件。",
21            alias
22        );
23        return;
24    }
25
26    // 如果是浏览器
27    if config.contains(section::BROWSER, alias) {
28        handle_open_browser(args, config);
29        return;
30    }
31
32    // 如果是编辑器
33    if config.contains(section::EDITOR, alias) {
34        if args.len() == 2 {
35            let file_path = &args[1];
36            open_with_path(alias, Some(file_path), config);
37        } else {
38            open_alias(alias, config);
39        }
40        return;
41    }
42
43    // 如果是 VPN
44    if config.contains(section::VPN, alias) {
45        open_alias(alias, config);
46        return;
47    }
48
49    // 如果是自定义脚本
50    if config.contains(section::SCRIPT, alias) {
51        run_script(args, config);
52        return;
53    }
54
55    // 默认作为普通路径打开(支持带参数执行 CLI 工具)
56    open_alias_with_args(alias, &args[1..], config);
57}
58
59/// 打开浏览器,可能带 URL 参数
60fn handle_open_browser(args: &[String], config: &YamlConfig) {
61    let alias = &args[0];
62    if args.len() == 1 {
63        // 直接打开浏览器
64        open_alias(alias, config);
65    } else {
66        // j <browser_alias> <url_alias_or_search_text> [engine]
67        let url_alias_or_text = &args[1];
68
69        // 尝试从 inner_url 或 outer_url 获取 URL
70        let url = if let Some(u) = config.get_property(section::INNER_URL, url_alias_or_text) {
71            u.clone()
72        } else if let Some(u) = config.get_property(section::OUTER_URL, url_alias_or_text) {
73            // outer_url 需要先启动 VPN
74            if let Some(vpn_map) = config.get_section(section::VPN) {
75                if let Some(vpn_alias) = vpn_map.keys().next() {
76                    open_alias(vpn_alias, config);
77                }
78            }
79            u.clone()
80        } else if is_url_like(url_alias_or_text) {
81            // 直接是 URL
82            url_alias_or_text.clone()
83        } else {
84            // 搜索引擎搜索
85            let engine = if args.len() >= 3 {
86                args[2].as_str()
87            } else {
88                config
89                    .get_property(section::SETTING, config_key::SEARCH_ENGINE)
90                    .map(|s| s.as_str())
91                    .unwrap_or(DEFAULT_SEARCH_ENGINE)
92            };
93            get_search_url(url_alias_or_text, engine)
94        };
95
96        open_with_path(alias, Some(&url), config);
97    }
98}
99
100/// 新窗口执行标志
101const NEW_WINDOW_FLAG: &str = "-w";
102const NEW_WINDOW_FLAG_LONG: &str = "--new-window";
103
104/// 运行脚本
105/// 支持 -w / --new-window 标志:在新终端窗口中执行脚本
106/// 用法:j <script_alias> [-w] [args...]
107fn run_script(args: &[String], config: &YamlConfig) {
108    let alias = &args[0];
109    if let Some(script_path) = config.get_property(section::SCRIPT, alias) {
110        // 展开脚本路径中的 ~
111        let script_path = clean_path(script_path);
112
113        // 检测 -w / --new-window 标志,并从参数中过滤掉
114        let new_window = args[1..]
115            .iter()
116            .any(|s| s == NEW_WINDOW_FLAG || s == NEW_WINDOW_FLAG_LONG);
117        let script_args: Vec<String> = args[1..]
118            .iter()
119            .filter(|s| s.as_str() != NEW_WINDOW_FLAG && s.as_str() != NEW_WINDOW_FLAG_LONG)
120            .map(|s| clean_path(s))
121            .collect();
122        let script_arg_refs: Vec<&str> = script_args.iter().map(|s| s.as_str()).collect();
123
124        if new_window {
125            info!("⚙️ 即将在新窗口执行脚本,路径: {}", script_path);
126            run_script_in_new_window(&script_path, &script_arg_refs, config);
127        } else {
128            info!("⚙️ 即将执行脚本,路径: {}", script_path);
129            run_script_in_current_terminal(&script_path, &script_arg_refs, config);
130        }
131    }
132}
133
134/// 为 Command 注入别名路径环境变量
135fn inject_alias_envs(cmd: &mut Command, config: &YamlConfig) {
136    for (key, value) in config.collect_alias_envs() {
137        cmd.env(&key, &value);
138    }
139}
140
141/// 在当前终端直接执行脚本
142fn run_script_in_current_terminal(script_path: &str, script_args: &[&str], config: &YamlConfig) {
143    let result = if cfg!(target_os = "windows") {
144        let mut cmd = Command::new("cmd.exe");
145        cmd.arg("/c").arg(script_path).args(script_args);
146        inject_alias_envs(&mut cmd, config);
147        cmd.status()
148    } else {
149        // macOS / Linux: 使用 sh 直接执行
150        let mut cmd = Command::new("sh");
151        cmd.arg(script_path).args(script_args);
152        inject_alias_envs(&mut cmd, config);
153        cmd.status()
154    };
155
156    match result {
157        Ok(status) => {
158            if status.success() {
159                info!("✅ 脚本执行完成");
160            } else {
161                error!("❌ 脚本执行失败,退出码: {}", status);
162            }
163        }
164        Err(e) => error!("💥 执行脚本失败: {}", e),
165    }
166}
167
168/// 在新终端窗口中执行脚本
169/// 脚本自身决定是否包含等待按键逻辑(通过 TUI 编辑器创建时可预填模板)
170/// 脚本执行完后自动 exit 关闭 shell,使新窗口可被关闭
171fn run_script_in_new_window(script_path: &str, script_args: &[&str], config: &YamlConfig) {
172    let os = std::env::consts::OS;
173
174    // 构建环境变量导出语句(用于新窗口中注入)
175    let env_exports = build_env_export_string(config);
176
177    if os == shell::MACOS_OS {
178        // macOS: 使用 osascript 在新 Terminal 窗口中执行
179        // 末尾追加 ; exit 让 shell 退出,Terminal.app 会根据偏好设置自动关闭窗口
180        let script_cmd = if script_args.is_empty() {
181            format!("sh {}", shell_escape(script_path))
182        } else {
183            let args_str = script_args
184                .iter()
185                .map(|a| shell_escape(a))
186                .collect::<Vec<_>>()
187                .join(" ");
188            format!("sh {} {}", shell_escape(script_path), args_str)
189        };
190
191        let full_cmd = if env_exports.is_empty() {
192            format!("{}; exit", script_cmd)
193        } else {
194            format!("{} {}; exit", env_exports, script_cmd)
195        };
196
197        // AppleScript: 在 Terminal.app 中打开新窗口并执行命令
198        let apple_script = format!(
199            "tell application \"Terminal\"\n\
200                activate\n\
201                do script \"{}\"\n\
202            end tell",
203            full_cmd.replace('\\', "\\\\").replace('"', "\\\"")
204        );
205
206        let result = Command::new("osascript")
207            .arg("-e")
208            .arg(&apple_script)
209            .status();
210
211        match result {
212            Ok(status) => {
213                if status.success() {
214                    info!("✅ 已在新终端窗口中启动脚本");
215                } else {
216                    error!("❌ 启动新终端窗口失败,退出码: {}", status);
217                }
218            }
219            Err(e) => error!("💥 调用 osascript 失败: {}", e),
220        }
221    } else if os == shell::WINDOWS_OS {
222        // Windows: 使用 start cmd /c 在新窗口执行
223        let script_cmd = if script_args.is_empty() {
224            script_path.to_string()
225        } else {
226            format!("{} {}", script_path, script_args.join(" "))
227        };
228
229        // Windows 通过 set 命令设置环境变量
230        let full_cmd = if env_exports.is_empty() {
231            script_cmd
232        } else {
233            format!("{} && {}", env_exports, script_cmd)
234        };
235
236        let result = Command::new("cmd")
237            .args(["/c", "start", "cmd", "/c", &full_cmd])
238            .status();
239
240        match result {
241            Ok(status) => {
242                if status.success() {
243                    info!("✅ 已在新终端窗口中启动脚本");
244                } else {
245                    error!("❌ 启动新终端窗口失败,退出码: {}", status);
246                }
247            }
248            Err(e) => error!("💥 启动新窗口失败: {}", e),
249        }
250    } else {
251        // Linux: 尝试常见的终端模拟器
252        // 末尾追加 ; exit 让 shell 退出,终端模拟器会自动关闭窗口
253        let script_cmd = if script_args.is_empty() {
254            format!("sh {}", script_path)
255        } else {
256            format!("sh {} {}", script_path, script_args.join(" "))
257        };
258
259        let full_cmd = if env_exports.is_empty() {
260            format!("{}; exit", script_cmd)
261        } else {
262            format!("{} {}; exit", env_exports, script_cmd)
263        };
264
265        // 尝试 gnome-terminal → xterm → 降级到当前终端
266        let terminals = [
267            ("gnome-terminal", vec!["--", "sh", "-c", &full_cmd]),
268            ("xterm", vec!["-e", &full_cmd]),
269            ("konsole", vec!["-e", &full_cmd]),
270        ];
271
272        for (term, term_args) in &terminals {
273            if let Ok(status) = Command::new(term).args(term_args).status() {
274                if status.success() {
275                    info!("✅ 已在新终端窗口中启动脚本");
276                    return;
277                }
278            }
279        }
280
281        // 所有终端都失败,降级到当前终端执行
282        info!("⚠️ 未找到可用的终端模拟器,降级到当前终端执行");
283        run_script_in_current_terminal(script_path, script_args, config);
284    }
285}
286
287/// 构建环境变量导出字符串(用于新窗口执行时注入)
288/// macOS/Linux 格式: export J_CHROME='/Applications/Google Chrome.app'; export J_VSCODE=...
289/// Windows 格式: set "J_CHROME=/Applications/Google Chrome.app" && set "J_VSCODE=..."
290fn build_env_export_string(config: &YamlConfig) -> String {
291    let envs = config.collect_alias_envs();
292    if envs.is_empty() {
293        return String::new();
294    }
295
296    let os = std::env::consts::OS;
297    if os == shell::WINDOWS_OS {
298        envs.iter()
299            .map(|(k, v)| format!("set \"{}={}\"", k, v))
300            .collect::<Vec<_>>()
301            .join(" && ")
302    } else {
303        // 修复:统一对所有值使用单引号包裹,避免特殊字符(&!|等)导致 shell 解析错误
304        // 单引号内所有字符都按字面值处理,包括空格、&、!、| 等
305        envs.iter()
306            .map(|(k, v)| {
307                // 对值中的单引号进行转义:' → '\''
308                let escaped_value = v.replace('\'', "'\\''");
309                format!("export {}='{}';", k, escaped_value)
310            })
311            .collect::<Vec<_>>()
312            .join(" ")
313    }
314}
315
316/// Shell 参数转义(为包含空格等特殊字符的参数添加引号)
317fn shell_escape(s: &str) -> String {
318    if s.contains(' ') || s.contains('"') || s.contains('\'') || s.contains('\\') {
319        // 用单引号包裹,内部单引号转义为 '\'''
320        format!("'{}'", s.replace('\'', "'\\''"))
321    } else {
322        s.to_string()
323    }
324}
325
326/// 打开一个别名对应的路径(不带额外参数)
327fn open_alias(alias: &str, config: &YamlConfig) {
328    open_alias_with_args(alias, &[], config);
329}
330
331/// 打开一个别名对应的路径,支持传递额外参数
332/// 自动判断路径类型:
333/// - CLI 可执行文件 → 在当前终端用 Command::new() 执行(stdin/stdout 继承,支持管道)
334/// - GUI 应用 (.app) / 其他文件 → 系统 open 命令打开
335fn open_alias_with_args(alias: &str, extra_args: &[String], config: &YamlConfig) {
336    if let Some(path) = config.get_path_by_alias(alias) {
337        let path = clean_path(path);
338        // 展开参数中的 ~
339        let expanded_args: Vec<String> = extra_args.iter().map(|s| clean_path(s)).collect();
340        if is_cli_executable(&path) {
341            // CLI 工具:在当前终端直接执行,继承 stdin/stdout(管道可用)
342            let result = Command::new(&path).args(&expanded_args).status();
343            match result {
344                Ok(status) => {
345                    if !status.success() {
346                        error!("❌ 执行 {{{}}} 失败,退出码: {}", alias, status);
347                    }
348                }
349                Err(e) => error!("💥 执行 {{{}}} 失败: {}", alias, e),
350            }
351        } else {
352            // GUI 应用或普通文件:系统 open 命令打开
353            if extra_args.is_empty() {
354                do_open(&path);
355            } else {
356                // GUI 应用带参数打开(如 open -a App file)
357                let os = std::env::consts::OS;
358                let result = if os == shell::MACOS_OS {
359                    Command::new("open")
360                        .args(["-a", &path])
361                        .args(&expanded_args)
362                        .status()
363                } else if os == shell::WINDOWS_OS {
364                    Command::new(shell::WINDOWS_CMD)
365                        .args([shell::WINDOWS_CMD_FLAG, "start", "", &path])
366                        .args(&expanded_args)
367                        .status()
368                } else {
369                    Command::new("xdg-open").arg(&path).status()
370                };
371                if let Err(e) = result {
372                    error!("💥 启动 {{{}}} 失败: {}", alias, e);
373                    return;
374                }
375            }
376            info!("✅ 启动 {{{}}} : {{{}}}", alias, path);
377        }
378    } else {
379        error!("❌ 未找到别名对应的路径或网址: {}。请检查配置文件。", alias);
380    }
381}
382
383/// 判断一个路径是否为 CLI 可执行文件(非 GUI 应用)
384/// 规则:
385/// - macOS 的 .app 目录 → 不是 CLI 工具,是 GUI 应用
386/// - URL(http/https)→ 不是 CLI 工具
387/// - 普通文件且具有可执行权限 → 是 CLI 工具
388fn is_cli_executable(path: &str) -> bool {
389    // URL 不是可执行文件
390    if path.starts_with("http://") || path.starts_with("https://") {
391        return false;
392    }
393
394    // macOS .app 目录是 GUI 应用
395    if path.ends_with(".app") || path.contains(".app/") {
396        return false;
397    }
398
399    let p = Path::new(path);
400
401    // 文件必须存在且是普通文件(不是目录)
402    if !p.is_file() {
403        return false;
404    }
405
406    // 检查可执行权限(Unix)
407    #[cfg(unix)]
408    {
409        use std::os::unix::fs::PermissionsExt;
410        if let Ok(metadata) = p.metadata() {
411            return metadata.permissions().mode() & 0o111 != 0;
412        }
413    }
414
415    // Windows 上通过扩展名判断
416    #[cfg(windows)]
417    {
418        if let Some(ext) = p.extension() {
419            let ext = ext.to_string_lossy().to_lowercase();
420            return matches!(ext.as_str(), "exe" | "cmd" | "bat" | "com");
421        }
422    }
423
424    false
425}
426
427/// 使用指定应用打开某个文件/URL
428fn open_with_path(alias: &str, file_path: Option<&str>, config: &YamlConfig) {
429    if let Some(app_path) = config.get_property(section::PATH, alias) {
430        let app_path = clean_path(app_path);
431        let os = std::env::consts::OS;
432        // 展开文件路径参数中的 ~
433        let file_path_expanded = file_path.map(|fp| clean_path(fp));
434        let file_path = file_path_expanded.as_deref();
435
436        let result = if os == shell::MACOS_OS {
437            match file_path {
438                Some(fp) => Command::new("open").args(["-a", &app_path, fp]).status(),
439                None => Command::new("open").arg(&app_path).status(),
440            }
441        } else if os == shell::WINDOWS_OS {
442            match file_path {
443                Some(fp) => Command::new(shell::WINDOWS_CMD)
444                    .args([shell::WINDOWS_CMD_FLAG, "start", "", &app_path, fp])
445                    .status(),
446                None => Command::new(shell::WINDOWS_CMD)
447                    .args([shell::WINDOWS_CMD_FLAG, "start", "", &app_path])
448                    .status(),
449            }
450        } else {
451            error!("💥 当前操作系统不支持此功能: {}", os);
452            return;
453        };
454
455        match result {
456            Ok(_) => {
457                let target = file_path.unwrap_or("");
458                info!("✅ 启动 {{{}}} {} : {{{}}}", alias, target, app_path);
459            }
460            Err(e) => error!("💥 启动 {} 失败: {}", alias, e),
461        }
462    } else {
463        error!("❌ 未找到别名对应的路径: {}。", alias);
464    }
465}
466
467/// 跨平台 open 命令
468fn do_open(path: &str) {
469    let os = std::env::consts::OS;
470    let result = if os == shell::MACOS_OS {
471        Command::new("open").arg(path).status()
472    } else if os == shell::WINDOWS_OS {
473        Command::new(shell::WINDOWS_CMD)
474            .args([shell::WINDOWS_CMD_FLAG, "start", "", path])
475            .status()
476    } else {
477        // Linux fallback
478        Command::new("xdg-open").arg(path).status()
479    };
480
481    if let Err(e) = result {
482        crate::error!("💥 打开 {} 失败: {}", path, e);
483    }
484}
485
486/// 清理路径:去除引号和转义符,展开 ~
487fn clean_path(path: &str) -> String {
488    let mut path = path.trim().to_string();
489
490    // 去除两端引号
491    if path.len() >= 2 {
492        if (path.starts_with('\'') && path.ends_with('\''))
493            || (path.starts_with('"') && path.ends_with('"'))
494        {
495            path = path[1..path.len() - 1].to_string();
496        }
497    }
498
499    // 去除转义空格
500    path = path.replace("\\ ", " ");
501
502    // 展开 ~
503    if path.starts_with('~') {
504        if let Some(home) = dirs::home_dir() {
505            if path == "~" {
506                path = home.to_string_lossy().to_string();
507            } else if path.starts_with("~/") {
508                path = format!("{}{}", home.to_string_lossy(), &path[1..]);
509            }
510        }
511    }
512
513    path
514}
515
516/// 简单判断是否像 URL
517fn is_url_like(s: &str) -> bool {
518    s.starts_with("http://") || s.starts_with("https://")
519}
520
521/// 根据搜索引擎获取搜索 URL
522fn get_search_url(query: &str, engine: &str) -> String {
523    let pattern = match engine.to_lowercase().as_str() {
524        "google" => search_engine::GOOGLE,
525        "bing" => search_engine::BING,
526        "baidu" => search_engine::BAIDU,
527        _ => {
528            info!(
529                "未指定搜索引擎,使用默认搜索引擎:{}",
530                DEFAULT_SEARCH_ENGINE
531            );
532            search_engine::BING
533        }
534    };
535    pattern.replace("{}", query)
536}