Skip to main content

j_cli/command/
open.rs

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