use crate::constants::{INSTALL_SOURCE, VERSION};
use colored::Colorize;
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEvent},
execute, terminal,
};
use std::io::{self, Write};
#[cfg(target_os = "macos")]
fn fix_codesign_and_quarantine(bin_path: &std::path::Path) {
let _ = std::process::Command::new("xattr")
.args(["-cr"])
.arg(bin_path)
.status();
match std::process::Command::new("codesign")
.args(["--force", "-s", "-"])
.arg(bin_path)
.status()
{
Ok(s) if s.success() => {
}
_ => {
println!(
"{}",
" 警告: codesign 签名失败,新版本可能无法启动"
.to_string()
.yellow()
);
}
}
}
#[cfg(not(target_os = "macos"))]
fn fix_codesign_and_quarantine(_bin_path: &std::path::Path) {}
fn get_github_auth_token() -> Option<String> {
if let Ok(token) = std::env::var("GITHUB_TOKEN")
&& !token.is_empty()
{
return Some(token);
}
if let Ok(output) = std::process::Command::new("gh")
.args(["auth", "token"])
.output()
&& output.status.success()
{
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !token.is_empty() {
return Some(token);
}
}
None
}
pub fn handle_update(check_only: bool, interactive: bool) {
match INSTALL_SOURCE {
"github" => handle_github_update(check_only, interactive),
"cargo" => handle_cargo_update(check_only, interactive),
_ => show_unknown_source_hint(interactive),
}
}
fn handle_github_update(check_only: bool, interactive: bool) {
println!("{}", "检测到 GitHub Release 安装方式".green());
println!("当前版本: {}", VERSION.cyan());
if check_only {
check_for_update();
} else {
perform_update(interactive);
}
}
fn check_for_update() {
println!("{}", "正在检查更新...".yellow());
let auth_token = get_github_auth_token();
if auth_token.is_some() {
println!("{}", "使用 GitHub 认证...".dimmed());
}
let mut binding = self_update::backends::github::ReleaseList::configure();
let mut binding = binding.repo_owner("LingoJack").repo_name("j");
if let Some(ref token) = auth_token {
binding = binding.auth_token(token);
}
match binding.build() {
Ok(release_list) => match release_list.fetch() {
Ok(releases) => {
if let Some(latest) = releases.first() {
let latest_version = latest.version.trim_start_matches('v');
println!("最新版本: {}", latest_version.cyan());
if latest_version == VERSION {
println!("{}", "已是最新版本".green());
} else {
println!("{}", "发现新版本!运行 'j update' 进行更新".yellow());
}
} else {
println!("{}", "未找到发布版本".red());
}
}
Err(e) => {
println!("{} {}", "检查更新失败:".red(), e);
println!("请尝试手动更新:");
println!(
" curl -fsSL https://raw.githubusercontent.com/LingoJack/j/main/install.sh | sh"
);
}
},
Err(e) => {
println!("{} {}", "配置更新源失败:".red(), e);
}
}
}
fn perform_update(interactive: bool) {
println!("{}", "正在更新...".yellow());
#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
let target = "darwin-arm64";
#[cfg(all(target_arch = "x86_64", target_os = "macos"))]
let target = "darwin-x64";
#[cfg(not(any(
all(target_arch = "aarch64", target_os = "macos"),
all(target_arch = "x86_64", target_os = "macos")
)))]
let target = {
println!("{}", "当前平台暂不支持自动更新,请手动更新".red());
return;
};
#[cfg(unix)]
let is_root = unsafe { libc::getuid() == 0 };
#[cfg(not(unix))]
let is_root = false;
if is_root {
perform_update_internal(target, interactive);
return;
}
let exe_path = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
println!("{} {}", "无法获取当前可执行文件路径:".red(), e);
return;
}
};
let exe_dir = match exe_path.parent() {
Some(d) => d,
None => {
println!("{}", "无法获取可执行文件所在目录".red());
return;
}
};
let can_actually_write = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(exe_dir.join(".j_write_test"))
.map(|_| {
let _ = std::fs::remove_file(exe_dir.join(".j_write_test"));
true
})
.unwrap_or(false);
if can_actually_write {
perform_update_internal(target, interactive);
return;
}
println!(
"{}",
"需要管理员权限来更新 j(安装目录需要 root 权限)".yellow()
);
println!("{}", "正在请求管理员权限...".cyan());
let exe_str = exe_path.to_string_lossy();
let script = format!(
r#"do shell script "{} update" with administrator privileges"#,
exe_str
);
let result = std::process::Command::new("osascript")
.arg("-e")
.arg(&script)
.status();
match result {
Ok(status) if status.success() => {
println!("{}", "更新完成!".green());
}
Ok(status) => {
println!(
"{} 退出码: {}",
"更新失败".red(),
status.code().unwrap_or(-1)
);
println!("请尝试手动更新:");
println!(" {}", "sudo j update".cyan());
}
Err(e) => {
println!("{} {}", "请求权限失败:".red(), e);
println!("请尝试手动更新:");
println!(" {}", "sudo j update".cyan());
}
}
}
fn perform_update_internal(target: &str, interactive: bool) {
let auth_token = get_github_auth_token();
if auth_token.is_some() {
println!("{}", "使用 GitHub 认证...".dimmed());
}
let mut binding = self_update::backends::github::Update::configure();
let mut binding = binding
.repo_owner("LingoJack")
.repo_name("j")
.bin_name("j")
.show_download_progress(true)
.current_version(VERSION)
.target(target);
if let Some(ref token) = auth_token {
binding = binding.auth_token(token);
}
match binding.build() {
Ok(updater) => match updater.update() {
Ok(status) => {
if let Ok(exe_path) = std::env::current_exe() {
fix_codesign_and_quarantine(&exe_path);
}
println!(
"{} {}",
"更新成功!".green(),
format!("版本: {}", status.version()).cyan()
);
install_indicator_from_release(status.version());
if interactive {
restart_self();
}
}
Err(e) => {
let err_str = e.to_string();
if err_str.contains("403") || err_str.contains("rate limit") {
println!("{} {}", "更新失败:".red(), e);
println!(
"{}",
"GitHub API 请求被限流,尝试使用 curl 方式更新...".yellow()
);
perform_update_curl(target, interactive);
} else {
println!("{} {}", "更新失败:".red(), e);
println!("请尝试手动更新:");
println!(
" curl -fsSL https://raw.githubusercontent.com/LingoJack/j/main/install.sh | sh"
);
}
}
},
Err(e) => {
println!("{} {}", "配置更新失败:".red(), e);
}
}
}
const OPTIONAL_FEATURES: &[(&str, &str)] = &[(
"browser_cdp",
"浏览器自动化 (CDP 模式,需本地有 Chrome/Chromium)",
)];
fn menu_total_lines() -> u16 {
(1 + 1 + OPTIONAL_FEATURES.len() + 1 + 1 + 1 + 1) as u16
}
fn select_features() -> Vec<String> {
let mut selected = vec![false; OPTIONAL_FEATURES.len()];
let mut cursor_pos: usize = 0;
let mut is_first_draw = true;
if terminal::enable_raw_mode().is_err() {
return vec![];
}
let mut stdout = io::stdout();
let _ = draw_feature_menu(&mut stdout, &selected, cursor_pos, is_first_draw);
is_first_draw = false;
loop {
if let Ok(Event::Key(KeyEvent { code, .. })) = event::read() {
match code {
KeyCode::Up | KeyCode::Char('k') => {
cursor_pos = cursor_pos.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if cursor_pos < OPTIONAL_FEATURES.len() {
cursor_pos += 1;
}
}
KeyCode::Char(' ') => {
if cursor_pos < OPTIONAL_FEATURES.len() {
selected[cursor_pos] = !selected[cursor_pos];
}
}
KeyCode::Enter => {
if cursor_pos == OPTIONAL_FEATURES.len() {
break;
}
if cursor_pos < OPTIONAL_FEATURES.len() {
selected[cursor_pos] = !selected[cursor_pos];
}
}
KeyCode::Esc | KeyCode::Char('q') => {
break;
}
_ => {} }
let _ = draw_feature_menu(&mut stdout, &selected, cursor_pos, is_first_draw);
}
}
let _ = terminal::disable_raw_mode();
println!();
selected
.iter()
.enumerate()
.filter(|(_, s)| **s)
.map(|(i, _)| OPTIONAL_FEATURES[i].0.to_string())
.collect()
}
fn draw_feature_menu(
stdout: &mut io::Stdout,
selected: &[bool],
cursor_pos: usize,
is_first_draw: bool,
) -> io::Result<()> {
let total_lines = menu_total_lines();
if !is_first_draw {
execute!(stdout, cursor::MoveUp(total_lines))?;
}
execute!(stdout, terminal::Clear(terminal::ClearType::FromCursorDown))?;
write!(
stdout,
" {} {}\r\n",
"?".cyan().bold(),
"选择要启用的可选 Features:".bold()
)?;
write!(stdout, "\r\n")?;
for (i, (name, desc)) in OPTIONAL_FEATURES.iter().enumerate() {
let is_focused = cursor_pos == i;
let is_selected = selected[i];
let checkbox = if is_selected {
"◉".green().bold().to_string()
} else {
"○".dimmed().to_string()
};
let pointer = if is_focused { "❯" } else { " " };
if is_focused {
write!(
stdout,
" {} {} {} {}\r\n",
pointer.cyan().bold(),
checkbox,
name.cyan().bold(),
format!("({})", desc).dimmed()
)?;
} else {
write!(
stdout,
" {} {} {} {}\r\n",
pointer,
checkbox,
name,
format!("({})", desc).dimmed()
)?;
}
}
write!(stdout, "\r\n")?;
let confirm_focused = cursor_pos == OPTIONAL_FEATURES.len();
if confirm_focused {
write!(
stdout,
" {} {}\r\n",
"❯".cyan().bold(),
"确认安装".green().bold()
)?;
} else {
write!(stdout, " {}\r\n", "确认安装".dimmed())?;
}
write!(stdout, "\r\n")?;
write!(
stdout,
" {} ↑↓ 移动 {} 切换 {} 确认 {} 跳过\r\n",
"•".dimmed(),
"空格".dimmed(),
"Enter".dimmed(),
"Esc".dimmed()
)?;
stdout.flush()?;
Ok(())
}
fn handle_cargo_update(check_only: bool, interactive: bool) {
println!("{}", "检测到 cargo 安装方式".green());
println!("当前版本: {}", VERSION.cyan());
if check_only {
println!();
println!("如需更新,运行:");
println!(" {}", "j update".cyan());
println!(" 或: {}", "cargo install j-cli".cyan());
return;
}
println!();
let selected_features = select_features();
let mut args = vec!["install", "j-cli"];
let features_str;
if !selected_features.is_empty() {
features_str = selected_features.join(",");
args.push("--features");
args.push(&features_str);
}
let cmd_display = format!("cargo {}", args.join(" "));
println!("{}", "正在通过 cargo 更新 j-cli...".yellow());
println!("执行: {}", cmd_display.cyan());
if !selected_features.is_empty() {
println!("启用 Features: {}", selected_features.join(", ").green());
}
println!();
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
match std::process::Command::new(&cargo).args(&args).spawn() {
Ok(mut child) => match child.wait() {
Ok(status) if status.success() => {
println!();
println!("{}", "更新成功!".green());
if interactive {
restart_self();
}
}
Ok(status) => {
println!();
println!(
"{} 退出码: {}",
"更新失败".red(),
status.code().unwrap_or(-1)
);
}
Err(e) => {
println!("{} {}", "等待 cargo 执行失败:".red(), e);
}
},
Err(e) => {
println!("{} {}", "启动 cargo 失败:".red(), e);
println!("请确认 cargo 已安装并在 PATH 中,或手动运行:");
println!(" {}", "cargo install j-cli --force".cyan());
}
}
}
fn show_unknown_source_hint(interactive: bool) {
println!("{}", "无法确定安装来源,尝试通过 cargo 更新...".yellow());
handle_cargo_update(false, interactive);
}
fn perform_update_curl(target: &str, interactive: bool) {
let version = get_latest_version_curl();
let version_display = version.as_deref().unwrap_or("未知").to_string();
println!("最新版本: {}", version_display.cyan());
let version = match version {
Some(v) => v,
None => {
println!("{}", "无法获取最新版本号".red());
println!("请尝试手动更新:");
println!(
" curl -fsSL https://raw.githubusercontent.com/LingoJack/j/main/install.sh | sh"
);
return;
}
};
let exe_path = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
println!("{} {}", "无法获取当前可执行文件路径:".red(), e);
return;
}
};
let exe_dir = match exe_path.parent() {
Some(d) => d.to_path_buf(),
None => {
println!("{}", "无法获取可执行文件所在目录".red());
return;
}
};
let tag = if version.starts_with('v') {
version.clone()
} else {
format!("v{}", version)
};
let asset_name = format!("j-{}", target);
let url = format!(
"https://github.com/LingoJack/j/releases/download/{}/{}.tar.gz",
tag, asset_name
);
println!("下载地址: {}", url.dimmed());
let tmp_dir = std::env::temp_dir().join("j-update-curl");
let _ = std::fs::create_dir_all(&tmp_dir);
let tmp_tar = tmp_dir.join(format!("{}.tar.gz", asset_name));
println!("{}", "正在下载...".yellow());
let download = std::process::Command::new("curl")
.args(["-fsSL", "--progress-bar", "-o"])
.arg(&tmp_tar)
.arg(&url)
.status();
match download {
Ok(status) if status.success() => {}
Ok(status) => {
println!(
"{} 退出码: {}",
"下载失败".red(),
status.code().unwrap_or(-1)
);
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
Err(e) => {
println!("{} {}", "下载失败:".red(), e);
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
}
println!("{}", "正在解压...".yellow());
let extract = std::process::Command::new("tar")
.args(["-xzf"])
.arg(&tmp_tar)
.args(["-C"])
.arg(&tmp_dir)
.status();
match extract {
Ok(status) if status.success() => {}
Ok(status) => {
println!(
"{} 退出码: {}",
"解压失败".red(),
status.code().unwrap_or(-1)
);
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
Err(e) => {
println!("{} {}", "解压失败:".red(), e);
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
}
let src_bin = tmp_dir.join("j");
let dst_bin = exe_dir.join("j");
if !src_bin.exists() {
println!("{}", "解压后未找到 j 二进制文件".red());
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
match std::fs::copy(&src_bin, &dst_bin) {
Ok(_) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dst_bin, std::fs::Permissions::from_mode(0o755));
}
fix_codesign_and_quarantine(&dst_bin);
println!(
"{} {}",
"更新成功!".green(),
format!("版本: {}", version_display).cyan()
);
install_indicator_from_release(&version);
if interactive {
restart_self();
}
}
Err(e) => {
println!("{} {}", "安装失败:".red(), e);
println!("可能需要管理员权限,请尝试:");
println!(" {}", "sudo j update".cyan());
}
}
let _ = std::fs::remove_dir_all(&tmp_dir);
}
fn get_latest_version_curl() -> Option<String> {
println!("{}", "正在获取最新版本号...".yellow());
let auth_token = get_github_auth_token();
let mut api_cmd = std::process::Command::new("curl");
api_cmd.args(["-fsSL", "-H", "User-Agent: j-cli-updater"]);
if let Some(ref token) = auth_token {
api_cmd.args(["-H", &format!("Authorization: token {}", token)]);
}
api_cmd.arg("https://api.github.com/repos/LingoJack/j/releases/latest");
if let Ok(output) = api_cmd.output()
&& output.status.success()
{
let body = String::from_utf8_lossy(&output.stdout);
let re = regex::Regex::new(r#"v[0-9]+\.[0-9]+\.[0-9]+"#).ok();
for line in body.lines() {
if line.contains("\"tag_name\"")
&& let Some(re) = &re
&& let Some(m) = re.find(line)
{
return Some(m.as_str().to_string());
}
}
}
if let Ok(output) = std::process::Command::new("curl")
.args(["-fsSL", "-o", "/dev/null", "-w", "%{url_effective}"])
.arg("https://github.com/LingoJack/j/releases/latest")
.output()
&& output.status.success()
{
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let re = regex::Regex::new(r#"v[0-9]+\.[0-9]+\.[0-9]+"#).ok();
if let Some(re) = re
&& let Some(m) = re.find(&url)
{
return Some(m.as_str().to_string());
}
}
None
}
fn install_indicator_from_release(version: &str) {
let j_dir = match std::env::current_exe() {
Ok(p) => match p.parent() {
Some(dir) => dir.to_path_buf(),
None => return,
},
Err(_) => return,
};
let tag = if version.starts_with('v') {
version.to_string()
} else {
format!("v{}", version)
};
let url = format!(
"https://github.com/LingoJack/j/releases/download/{}/j-darwin-arm64.tar.gz",
tag
);
println!("{}", "正在安装 j-indicator...".yellow());
let tmp_dir = std::env::temp_dir().join("j-update-indicator");
let _ = std::fs::create_dir_all(&tmp_dir);
let tmp_tar = tmp_dir.join("j-darwin-arm64.tar.gz");
let download = std::process::Command::new("curl")
.args(["-fsSL", "-o"])
.arg(&tmp_tar)
.arg(&url)
.output();
match download {
Ok(output) if output.status.success() => {}
_ => {
println!(
"{}",
" j-indicator 下载失败,跳过(不影响 j 主程序)".dimmed()
);
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
}
let extract = std::process::Command::new("tar")
.args(["-xzf"])
.arg(&tmp_tar)
.args(["-C"])
.arg(&tmp_dir)
.arg("j-indicator")
.output();
match extract {
Ok(output) if output.status.success() => {
let src = tmp_dir.join("j-indicator");
let dst = j_dir.join("j-indicator");
if src.exists() {
match std::fs::copy(&src, &dst) {
Ok(_) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
&dst,
std::fs::Permissions::from_mode(0o755),
);
}
fix_codesign_and_quarantine(&dst);
println!("{}", " j-indicator 已安装".green());
}
Err(e) => {
println!(
"{}",
format!(" j-indicator 拷贝失败: {}(不影响 j 主程序)", e).dimmed()
);
}
}
}
}
_ => {
println!(
"{}",
" j-indicator 提取失败,跳过(不影响 j 主程序)".dimmed()
);
}
}
let _ = std::fs::remove_dir_all(&tmp_dir);
}
fn restart_self() {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
println!("{} {}", "无法获取当前可执行文件路径:".red(), e);
println!("请手动重启 j 以使用新版本。");
return;
}
};
println!("{}", "正在重启 j 以加载新版本...".cyan());
let exe_cstr = match std::ffi::CString::new(exe.to_string_lossy().as_bytes()) {
Ok(s) => s,
Err(e) => {
println!("{} {}", "路径包含非法字符:".red(), e);
println!("请手动重启 j 以使用新版本。");
return;
}
};
let err = nix::unistd::execv(&exe_cstr, &[&exe_cstr]);
println!("{} {:?}", "重启失败:".red(), err);
println!("请手动重启 j 以使用新版本。");
}