12 files changed
+316
-128
No files matched your search
@@ -306,7 +306,6 @@ jobs:
|
||||
for platform in windows linux darwin; do
|
||||
node scripts/manifest.mjs \
|
||||
--directory artifacts \
|
||||
--output "artifacts/portable-update-${platform}.json" \
|
||||
--platform "$platform" \
|
||||
--repository "${{ github.repository }}" \
|
||||
--gitcode-repository "${{ vars.GITCODE_GUI_REPOSITORY }}" \
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
7.2.132
|
||||
7.2.136
|
||||
+18
-2
@@ -4,6 +4,17 @@ import { basename, join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { validateAppVersion } from './version.mjs';
|
||||
|
||||
export function portableUpdateManifestName(platform) {
|
||||
const normalizedPlatform = String(platform).trim().toLowerCase();
|
||||
if (!['windows', 'linux', 'darwin'].includes(normalizedPlatform)) {
|
||||
throw new Error(`Unsupported update platform: ${platform}`);
|
||||
}
|
||||
// Keep the broken legacy macOS updater on a manifest URL that is no longer published.
|
||||
return normalizedPlatform === 'darwin'
|
||||
? 'portable-update-darwin-v2.json'
|
||||
: `portable-update-${normalizedPlatform}.json`;
|
||||
}
|
||||
|
||||
export async function generatePortableUpdateManifest({
|
||||
directory,
|
||||
output,
|
||||
@@ -22,7 +33,10 @@ export async function generatePortableUpdateManifest({
|
||||
const normalizedPlatform = String(platform).trim().toLowerCase();
|
||||
const platformSpec = platformSpecs[normalizedPlatform];
|
||||
if (!platformSpec) throw new Error(`Unsupported update platform: ${platform}`);
|
||||
const resolvedOutput = resolve(output ?? join(resolvedDirectory, `portable-update-${normalizedPlatform}.json`));
|
||||
const resolvedOutput = resolve(output ?? join(
|
||||
resolvedDirectory,
|
||||
portableUpdateManifestName(normalizedPlatform),
|
||||
));
|
||||
const resolvedRepository = repository ?? 'router-for-me/EasyCLIProxyAPI';
|
||||
const resolvedGitcodeRepository = String(gitcodeRepository ?? '').trim();
|
||||
const normalizedRawTag = String(rawTag ?? '').trim();
|
||||
@@ -99,7 +113,9 @@ async function main() {
|
||||
}
|
||||
const directory = resolve(args.get('--directory') ?? 'artifacts');
|
||||
const platform = args.get('--platform') ?? 'windows';
|
||||
const output = resolve(args.get('--output') ?? join(directory, `portable-update-${platform}.json`));
|
||||
const output = resolve(
|
||||
args.get('--output') ?? join(directory, portableUpdateManifestName(platform)),
|
||||
);
|
||||
const manifest = await generatePortableUpdateManifest({
|
||||
directory,
|
||||
output,
|
||||
|
||||
@@ -830,16 +830,18 @@ pub(crate) fn inspect_agent_config(
|
||||
Err(error) => (false, None, false, false, Some(error)),
|
||||
};
|
||||
let executable = find_agent_executable(client, home);
|
||||
// ZCode's desktop executable is an Electron app, not a CLI. Invoking it
|
||||
// with --version can start the GUI and block discovery, so only probe a
|
||||
// separately installed command-line entry point here.
|
||||
// Desktop application executables are not CLIs. Invoking them with
|
||||
// --version can start their GUI and block discovery, so never probe them
|
||||
// directly. ZCode may still expose a separate command-line entry point.
|
||||
let cli_version = if client == AgentClient::ZCode {
|
||||
find_named_agent_executable(home, &["zcode"])
|
||||
.filter(|path| executable.as_ref() != Some(path))
|
||||
.as_deref()
|
||||
.and_then(read_agent_version)
|
||||
} else {
|
||||
} else if should_probe_primary_agent_executable_version(client) {
|
||||
executable.as_deref().and_then(read_agent_version)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let codex_app_installation = (client == AgentClient::Codex)
|
||||
.then(|| find_codex_app_installation(home))
|
||||
@@ -855,7 +857,8 @@ pub(crate) fn inspect_agent_config(
|
||||
};
|
||||
let version = cli_version.clone().or_else(|| app_version.clone());
|
||||
let installed = version.is_some()
|
||||
|| (client == AgentClient::ZCode && executable.is_some())
|
||||
|| (matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode)
|
||||
&& executable.is_some())
|
||||
|| codex_app_installation.is_some();
|
||||
let launch_targets = agent_launch_targets(
|
||||
client,
|
||||
@@ -907,6 +910,10 @@ pub(crate) fn inspect_agent_config(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn should_probe_primary_agent_executable_version(client: AgentClient) -> bool {
|
||||
!matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode)
|
||||
}
|
||||
|
||||
pub(crate) fn agent_launch_targets(
|
||||
client: AgentClient,
|
||||
executable: Option<&Path>,
|
||||
@@ -1333,11 +1340,24 @@ pub(crate) fn read_claude_desktop_version(home: &Path) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
read_windows_claude_desktop_store_version().or_else(|| {
|
||||
find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path))
|
||||
find_claude_desktop_executable(home)
|
||||
.and_then(|path| read_windows_executable_version(&path))
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path))
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
find_claude_desktop_executable(home).and_then(|path| {
|
||||
let application = path.ancestors().find(|candidate| {
|
||||
candidate.extension().and_then(|value| value.to_str()) == Some("app")
|
||||
})?;
|
||||
read_macos_app_version(application)
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let _ = home;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_codex_app_installation_version(installation: &CodexAppTarget) -> Option<String> {
|
||||
|
||||
@@ -27,9 +27,6 @@ pub(crate) fn launch_agent(
|
||||
if !status.installed {
|
||||
return Err("未检测到 Pi CLI,请先安装 Pi 并重新检测".to_string());
|
||||
}
|
||||
if !status.configured {
|
||||
return Err("请先安装并配置 Pi CLIProxyAPI provider 插件".to_string());
|
||||
}
|
||||
let executable =
|
||||
find_pi_executable(&home).ok_or_else(|| "未找到 Pi CLI 可执行文件".to_string())?;
|
||||
let launch_directory = resolve_launch_directory(working_directory.as_deref(), &home)?;
|
||||
@@ -44,11 +41,6 @@ pub(crate) fn launch_agent(
|
||||
if !status.installed {
|
||||
return Err(format!("未检测到 {},请先安装并重新检测", client.name()));
|
||||
}
|
||||
validate_agent_launch_configuration(client, &status)?;
|
||||
if client == AgentClient::Codex && status.oauth_configuration {
|
||||
validate_codex_oauth_login(&home)?;
|
||||
}
|
||||
|
||||
let default_target = match client {
|
||||
AgentClient::ClaudeDesktop | AgentClient::ZCode => "app",
|
||||
AgentClient::Codex if find_codex_app_installation(&home).is_some() => "app",
|
||||
@@ -88,27 +80,11 @@ pub(crate) fn launch_agent(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn restart_codex_app(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<(), String> {
|
||||
pub(crate) async fn restart_codex_app(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|error| format!("无法获取用户目录: {error}"))?;
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let status = inspect_agent_config(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
config.port,
|
||||
effective_agent_api_key(&config),
|
||||
);
|
||||
if !status.installed {
|
||||
return Err("未检测到 Codex,请先安装并重新检测".to_string());
|
||||
}
|
||||
if status.oauth_configuration {
|
||||
validate_codex_oauth_login(&home)?;
|
||||
}
|
||||
let target = find_codex_app_installation(&home)
|
||||
.ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?;
|
||||
|
||||
@@ -120,21 +96,6 @@ pub(crate) async fn restart_codex_app(
|
||||
.map_err(|error| format!("重启 Codex App 任务失败: {error}"))?
|
||||
}
|
||||
|
||||
fn validate_agent_launch_configuration(
|
||||
client: AgentClient,
|
||||
status: &AgentConfigStatus,
|
||||
) -> Result<(), String> {
|
||||
if client == AgentClient::Codex
|
||||
|| (status.modification_enabled && status.modification_state == "applied")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"请先为 {} 应用配置修改,确保 CPA 配置生效后再启动",
|
||||
client.name()
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_launch_directory(value: Option<&str>, fallback: &Path) -> Result<PathBuf, String> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(fallback.to_path_buf());
|
||||
|
||||
+105
-38
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) const PORTABLE_UPDATE_HELPER_ACK_FILE: &str = "update-helper-started.ack";
|
||||
const PORTABLE_UPDATE_HELPER_START_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||
open_external_url_inner(&app, &url)
|
||||
@@ -581,34 +584,7 @@ pub(crate) async fn download_and_stage_portable_app_update(
|
||||
work_dir: work_dir.clone(),
|
||||
target_version: pending.version.clone(),
|
||||
};
|
||||
let descriptor_path = work_dir.join("update-descriptor.json");
|
||||
fs::write(
|
||||
&descriptor_path,
|
||||
serde_json::to_vec_pretty(&descriptor)
|
||||
.map_err(|error| format!("序列化应用更新描述失败: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("写入应用更新描述失败: {error}"))?;
|
||||
|
||||
let mut command = Command::new(&helper_path);
|
||||
command
|
||||
.arg("--portable-update-helper")
|
||||
.arg(&descriptor_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
configure_background_command(&mut command);
|
||||
command
|
||||
.spawn()
|
||||
.map_err(|error| format!("启动应用更新助手失败: {error}"))?;
|
||||
|
||||
update_app_task(app, |task| {
|
||||
task.cancellable = false;
|
||||
task.phase = "restarting".to_string();
|
||||
task.message = Some("更新已准备完成,应用即将重启".to_string());
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(350)).await;
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await
|
||||
}
|
||||
.await;
|
||||
|
||||
@@ -709,9 +685,6 @@ pub(crate) async fn download_and_stage_portable_app_update(
|
||||
.strip_prefix(¤t_app)
|
||||
.map_err(|_| "macOS 应用程序路径无效".to_string())?
|
||||
.to_path_buf();
|
||||
let helper_path = work_dir.join("EasyCLIProxyAPI-updater");
|
||||
fs::copy(¤t_exe, &helper_path)
|
||||
.map_err(|error| format!("准备应用更新助手失败: {error}"))?;
|
||||
let backup_app = current_app
|
||||
.parent()
|
||||
.ok_or_else(|| "macOS 应用程序路径没有父目录".to_string())?
|
||||
@@ -726,6 +699,10 @@ pub(crate) async fn download_and_stage_portable_app_update(
|
||||
work_dir: work_dir.clone(),
|
||||
target_version: pending.version.clone(),
|
||||
};
|
||||
// Keep the updater inside its signed application bundle. Copying the Mach-O
|
||||
// executable into a temporary directory strips the bundle context Gatekeeper
|
||||
// uses to validate it and can cause macOS to terminate it before it starts.
|
||||
let helper_path = macos_update_helper_path(¤t_exe);
|
||||
launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await
|
||||
}
|
||||
.await;
|
||||
@@ -765,7 +742,12 @@ fn ensure_portable_update_download(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
pub(crate) fn macos_update_helper_path(current_exe: &Path) -> PathBuf {
|
||||
current_exe.to_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||||
async fn launch_portable_update_helper<T: Serialize>(
|
||||
app: &tauri::AppHandle,
|
||||
helper_path: &Path,
|
||||
@@ -779,6 +761,8 @@ async fn launch_portable_update_helper<T: Serialize>(
|
||||
.map_err(|error| format!("序列化应用更新描述失败: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("写入应用更新描述失败: {error}"))?;
|
||||
let helper_ack_path = portable_update_helper_ack_path(work_dir);
|
||||
let _ = fs::remove_file(&helper_ack_path);
|
||||
let mut command = Command::new(helper_path);
|
||||
command
|
||||
.arg("--portable-update-helper")
|
||||
@@ -786,19 +770,68 @@ async fn launch_portable_update_helper<T: Serialize>(
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
command
|
||||
configure_background_command(&mut command);
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("启动应用更新助手失败: {error}"))?;
|
||||
wait_for_portable_update_helper_start(
|
||||
&mut child,
|
||||
&helper_ack_path,
|
||||
PORTABLE_UPDATE_HELPER_START_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
update_app_task(app, |task| {
|
||||
task.cancellable = false;
|
||||
task.phase = "restarting".to_string();
|
||||
task.message = Some("更新已准备完成,应用即将重启".to_string());
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(350)).await;
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||||
pub(crate) fn portable_update_helper_ack_path(work_dir: &Path) -> PathBuf {
|
||||
work_dir.join(PORTABLE_UPDATE_HELPER_ACK_FILE)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||||
pub(crate) fn acknowledge_portable_update_helper_start(work_dir: &Path) -> Result<(), String> {
|
||||
fs::write(
|
||||
portable_update_helper_ack_path(work_dir),
|
||||
std::process::id().to_string(),
|
||||
)
|
||||
.map_err(|error| format!("写入应用更新助手启动确认失败: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||||
async fn wait_for_portable_update_helper_start(
|
||||
child: &mut Child,
|
||||
ack_path: &Path,
|
||||
timeout: Duration,
|
||||
) -> Result<(), String> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if ack_path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.map_err(|error| format!("检查应用更新助手状态失败: {error}"))?
|
||||
{
|
||||
return Err(format!("应用更新助手未能启动,退出状态: {status}"));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!(
|
||||
"应用更新助手未能在 {} 秒内完成启动确认",
|
||||
timeout.as_secs()
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||||
pub(crate) async fn download_portable_update_archive(
|
||||
app: &tauri::AppHandle,
|
||||
@@ -1667,6 +1700,7 @@ pub(crate) fn cleanup_portable_update_payload(
|
||||
}));
|
||||
let _ = fs::remove_dir_all(descriptor.work_dir.join("staging"));
|
||||
let _ = fs::remove_file(&descriptor.ack_path);
|
||||
let _ = fs::remove_file(portable_update_helper_ack_path(&descriptor.work_dir));
|
||||
let _ = fs::remove_file(descriptor_path);
|
||||
}
|
||||
|
||||
@@ -1677,7 +1711,13 @@ pub(crate) fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), S
|
||||
)
|
||||
.map_err(|error| format!("解析应用更新描述失败: {error}"))?;
|
||||
validate_portable_update_descriptor(descriptor_path, &descriptor)?;
|
||||
wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120))?;
|
||||
acknowledge_portable_update_helper_start(&descriptor.work_dir)?;
|
||||
if let Err(error) =
|
||||
wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120))
|
||||
{
|
||||
cleanup_portable_update_payload(descriptor_path, &descriptor);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if let Err(error) = replace_portable_update_files(&descriptor) {
|
||||
let mut rollback = Command::new(&descriptor.current_exe);
|
||||
@@ -1792,7 +1832,12 @@ pub(crate) fn stage_macos_application_from_dmg(
|
||||
codesign
|
||||
.args(["--verify", "--deep", "--strict"])
|
||||
.arg(staged_app);
|
||||
run_macos_update_command(&mut codesign, "校验新版 macOS 应用签名")
|
||||
run_macos_update_command(&mut codesign, "校验新版 macOS 应用签名")?;
|
||||
let mut gatekeeper = Command::new("spctl");
|
||||
gatekeeper
|
||||
.args(["--assess", "--type", "execute", "--verbose=2"])
|
||||
.arg(staged_app);
|
||||
run_macos_update_command(&mut gatekeeper, "校验新版 macOS 应用系统信任状态")
|
||||
})();
|
||||
|
||||
let mut detach = Command::new("hdiutil");
|
||||
@@ -1964,7 +2009,12 @@ pub(crate) fn replace_macos_application(descriptor: &MacosUpdateDescriptor) -> R
|
||||
.current_app
|
||||
.parent()
|
||||
.ok_or_else(|| "macOS 应用更新目标路径无效".to_string())?;
|
||||
let replacement_app = app_parent.join(".EasyCLIProxyAPI.app.update-new");
|
||||
let replacement_app = app_parent.join(".EasyCLIProxyAPI-update-new.app");
|
||||
let legacy_replacement_app = app_parent.join(".EasyCLIProxyAPI.app.update-new");
|
||||
if legacy_replacement_app.exists() {
|
||||
fs::remove_dir_all(&legacy_replacement_app)
|
||||
.map_err(|error| format!("清理旧格式的 macOS 更新暂存失败: {error}"))?;
|
||||
}
|
||||
if replacement_app.exists() {
|
||||
fs::remove_dir_all(&replacement_app)
|
||||
.map_err(|error| format!("清理旧的 macOS 更新暂存失败: {error}"))?;
|
||||
@@ -1981,6 +2031,16 @@ pub(crate) fn replace_macos_application(descriptor: &MacosUpdateDescriptor) -> R
|
||||
let _ = fs::remove_dir_all(&replacement_app);
|
||||
return Err(error);
|
||||
}
|
||||
let mut gatekeeper = Command::new("spctl");
|
||||
gatekeeper
|
||||
.args(["--assess", "--type", "execute", "--verbose=2"])
|
||||
.arg(&replacement_app);
|
||||
if let Err(error) =
|
||||
run_macos_update_command(&mut gatekeeper, "校验待替换 macOS 应用系统信任状态")
|
||||
{
|
||||
let _ = fs::remove_dir_all(&replacement_app);
|
||||
return Err(error);
|
||||
}
|
||||
if descriptor.backup_app.exists() {
|
||||
fs::remove_dir_all(&descriptor.backup_app)
|
||||
.map_err(|error| format!("清理旧的 macOS 应用备份失败: {error}"))?;
|
||||
@@ -2001,6 +2061,7 @@ fn cleanup_macos_update_payload(descriptor_path: &Path, descriptor: &MacosUpdate
|
||||
let _ = fs::remove_dir_all(descriptor.work_dir.join("staging"));
|
||||
let _ = fs::remove_dir_all(descriptor.work_dir.join("mount"));
|
||||
let _ = fs::remove_file(&descriptor.ack_path);
|
||||
let _ = fs::remove_file(portable_update_helper_ack_path(&descriptor.work_dir));
|
||||
let _ = fs::remove_file(descriptor_path);
|
||||
}
|
||||
|
||||
@@ -2011,7 +2072,13 @@ pub(crate) fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), S
|
||||
)
|
||||
.map_err(|error| format!("解析应用更新描述失败: {error}"))?;
|
||||
validate_macos_update_descriptor(descriptor_path, &descriptor)?;
|
||||
wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120))?;
|
||||
acknowledge_portable_update_helper_start(&descriptor.work_dir)?;
|
||||
if let Err(error) =
|
||||
wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120))
|
||||
{
|
||||
cleanup_macos_update_payload(descriptor_path, &descriptor);
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = replace_macos_application(&descriptor) {
|
||||
let rollback_exe = descriptor
|
||||
.current_app
|
||||
|
||||
@@ -83,8 +83,10 @@ const RELEASE_DOWNLOAD_PREFIX: &str =
|
||||
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-windows.json";
|
||||
#[cfg(target_os = "linux")]
|
||||
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-linux.json";
|
||||
// Legacy macOS clients contain an updater that cannot launch outside its signed app bundle.
|
||||
// A separate channel makes those clients fall back to a one-time manual installation.
|
||||
#[cfg(target_os = "macos")]
|
||||
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-darwin.json";
|
||||
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-darwin-v2.json";
|
||||
const APP_RELEASE_DOWNLOAD_PREFIX: &str =
|
||||
"https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/";
|
||||
#[cfg(windows)]
|
||||
@@ -92,7 +94,7 @@ const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-windows.json";
|
||||
#[cfg(target_os = "linux")]
|
||||
const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-linux.json";
|
||||
#[cfg(target_os = "macos")]
|
||||
const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-darwin.json";
|
||||
const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-darwin-v2.json";
|
||||
const CODEX_MODEL_CATALOG_URL: &str = "https://raw.githubusercontent.com/router-for-me/EasyCLIProxyAPI/main/src-tauri/resources/codex_models/model-catalog.json";
|
||||
const CODEX_MODEL_CATALOG_OVERRIDE_DIR: &str = "codex_models";
|
||||
const CODEX_MODEL_CATALOG_SOURCE_FILE: &str = "model-catalog.json";
|
||||
|
||||
@@ -1148,6 +1148,22 @@ fn kimi_grok_and_zcode_use_cpa_runtime_context_windows() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_application_binaries_are_not_used_for_cli_version_probes() {
|
||||
assert!(!should_probe_primary_agent_executable_version(
|
||||
AgentClient::ClaudeDesktop
|
||||
));
|
||||
assert!(!should_probe_primary_agent_executable_version(
|
||||
AgentClient::ZCode
|
||||
));
|
||||
assert!(should_probe_primary_agent_executable_version(
|
||||
AgentClient::ClaudeCode
|
||||
));
|
||||
assert!(should_probe_primary_agent_executable_version(
|
||||
AgentClient::Codex
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_model_lists_mark_yaml_aliases_when_api_returns_only_model_ids() {
|
||||
let mut models = test_agent_models(&[
|
||||
|
||||
@@ -24,6 +24,33 @@ fn app_update_comparison_uses_semantic_versions() {
|
||||
assert!(!is_app_update_available("v0.2.0", "v0.1.9").unwrap());
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn portable_update_helper_writes_a_distinct_startup_ack() {
|
||||
let work_dir = agent_test_home("portable-update-helper-ack");
|
||||
acknowledge_portable_update_helper_start(&work_dir).unwrap();
|
||||
|
||||
let ack_path = portable_update_helper_ack_path(&work_dir);
|
||||
assert_eq!(
|
||||
ack_path.file_name().and_then(|value| value.to_str()),
|
||||
Some(PORTABLE_UPDATE_HELPER_ACK_FILE)
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&ack_path).unwrap(),
|
||||
std::process::id().to_string()
|
||||
);
|
||||
assert_ne!(ack_path.file_name().unwrap(), "update-started.ack");
|
||||
fs::remove_dir_all(work_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_update_helper_stays_inside_the_signed_app_bundle() {
|
||||
let current_exe =
|
||||
PathBuf::from("/Applications/EasyCLIProxyAPI.app/Contents/MacOS/EasyCLIProxyAPI");
|
||||
|
||||
assert_eq!(macos_update_helper_path(¤t_exe), current_exe);
|
||||
}
|
||||
|
||||
fn portable_update_test_asset(version: &str, arch: &str) -> PortableUpdateAsset {
|
||||
let (_, display, suffix) = portable_update_asset_platform().unwrap();
|
||||
let name = format!("EasyCLIProxyAPI-v{version}-{display}-{arch}.{suffix}");
|
||||
|
||||
+99
-9
@@ -1,8 +1,10 @@
|
||||
mod resp;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::executable_dir;
|
||||
use super::{
|
||||
apply_configured_proxy, current_core_status, management_authorization, management_endpoint,
|
||||
management_http_client, CoreProcessState, GuiConfigFile, GuiConfigState,
|
||||
apply_configured_proxy, core_base_dir, current_core_status, management_authorization,
|
||||
management_endpoint, management_http_client, CoreProcessState, GuiConfigFile, GuiConfigState,
|
||||
};
|
||||
use chrono::{DateTime, Local};
|
||||
use rusqlite::{
|
||||
@@ -436,7 +438,73 @@ impl UsageCollectorState {
|
||||
}
|
||||
|
||||
pub(crate) fn initialize_usage_storage() -> Result<(), String> {
|
||||
initialize_usage_storage_at(&usage_root_dir()?)
|
||||
let root = usage_root_dir()?;
|
||||
migrate_legacy_usage_storage(&root)?;
|
||||
initialize_usage_storage_at(&root)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn migrate_legacy_usage_storage(target: &Path) -> Result<(), String> {
|
||||
let legacy = executable_dir()?.join(USAGE_DIR_NAME);
|
||||
migrate_usage_storage_directory(&legacy, target)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn migrate_legacy_usage_storage(target: &Path) -> Result<(), String> {
|
||||
let _ = target;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn migrate_usage_storage_directory(source: &Path, target: &Path) -> Result<(), String> {
|
||||
if source == target || !source.is_dir() || target.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let target_parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| "使用记录目标目录没有父目录".to_string())?;
|
||||
fs::create_dir_all(target_parent)
|
||||
.map_err(|error| format!("创建使用记录迁移目标目录失败: {error}"))?;
|
||||
if fs::rename(source, target).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Err(error) = copy_usage_storage_directory(source, target) {
|
||||
let _ = fs::remove_dir_all(target);
|
||||
return Err(format!("迁移旧版使用记录失败: {error}"));
|
||||
}
|
||||
if let Err(error) = fs::remove_dir_all(source) {
|
||||
eprintln!(
|
||||
"旧版使用记录已复制,但无法清理原目录 {}: {error}",
|
||||
source.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn copy_usage_storage_directory(source: &Path, target: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(target).map_err(|error| format!("创建使用记录目录失败: {error}"))?;
|
||||
let entries = fs::read_dir(source).map_err(|error| format!("读取旧版使用记录失败: {error}"))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| format!("读取旧版使用记录项目失败: {error}"))?;
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|error| format!("读取旧版使用记录项目类型失败: {error}"))?;
|
||||
let destination = target.join(entry.file_name());
|
||||
if file_type.is_dir() {
|
||||
copy_usage_storage_directory(&entry.path(), &destination)?;
|
||||
} else if file_type.is_file() {
|
||||
fs::copy(entry.path(), &destination)
|
||||
.map_err(|error| format!("复制旧版使用记录失败: {error}"))?;
|
||||
} else {
|
||||
return Err(format!(
|
||||
"旧版使用记录包含不支持的文件类型: {}",
|
||||
entry.path().display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initialize_usage_storage_at(root: &Path) -> Result<(), String> {
|
||||
@@ -2729,12 +2797,7 @@ fn query_window_minutes(query: &UsageQuery, first: Option<i64>, last: Option<i64
|
||||
}
|
||||
|
||||
fn usage_root_dir() -> Result<PathBuf, String> {
|
||||
let executable =
|
||||
std::env::current_exe().map_err(|error| format!("读取程序路径失败: {error}"))?;
|
||||
let directory = executable
|
||||
.parent()
|
||||
.ok_or_else(|| "程序路径没有父目录".to_string())?;
|
||||
Ok(directory.join(USAGE_DIR_NAME))
|
||||
Ok(core_base_dir()?.join(USAGE_DIR_NAME))
|
||||
}
|
||||
|
||||
fn record_local_hour(record: &UsageRecord) -> String {
|
||||
@@ -2888,6 +2951,33 @@ mod tests {
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_usage_storage_is_moved_with_nested_files() {
|
||||
let root = test_root("storage-location-migration");
|
||||
let source = root.join("legacy").join(USAGE_DIR_NAME);
|
||||
let target = root.join("application-support").join(USAGE_DIR_NAME);
|
||||
fs::create_dir_all(source.join(USAGE_BACKUP_DIR_NAME)).unwrap();
|
||||
fs::write(source.join(USAGE_DATABASE_FILE), b"database").unwrap();
|
||||
fs::write(
|
||||
source.join(USAGE_BACKUP_DIR_NAME).join("usage.db.backup"),
|
||||
b"backup",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_usage_storage_directory(&source, &target).unwrap();
|
||||
|
||||
assert!(!source.exists());
|
||||
assert_eq!(
|
||||
fs::read(target.join(USAGE_DATABASE_FILE)).unwrap(),
|
||||
b"database"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(target.join(USAGE_BACKUP_DIR_NAME).join("usage.db.backup")).unwrap(),
|
||||
b"backup"
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn open_test_database(root: &Path) -> Connection {
|
||||
initialize_usage_storage_at(root).unwrap();
|
||||
open_usage_database_at(root).unwrap()
|
||||
|
||||
@@ -959,11 +959,7 @@ export function AgentsPage() {
|
||||
const appLaunchTarget = activeLaunchTargets.find((target) => target.id === 'app') ?? null;
|
||||
const launchEnabled = Boolean(
|
||||
activeStatus?.supportedPlatform
|
||||
&& activeStatus.installed
|
||||
&& (selected === 'codex'
|
||||
|| (selected === 'pi'
|
||||
? activeStatus.configured
|
||||
: activeStatus.modificationEnabled && activeStatus.modificationState === 'applied')),
|
||||
&& activeStatus.installed,
|
||||
);
|
||||
const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target);
|
||||
const activeLaunchDirectoryHistory = launchDirectoryHistory[selected] ?? [];
|
||||
@@ -1356,7 +1352,6 @@ export function AgentsPage() {
|
||||
setBusyAction(action);
|
||||
setLaunchError('');
|
||||
try {
|
||||
if (draftChanged) throw new Error(t('agents.error.applyFirst'));
|
||||
await invoke('launch_agent', {
|
||||
client: selected,
|
||||
target: target.id,
|
||||
@@ -1391,7 +1386,6 @@ export function AgentsPage() {
|
||||
setBusyAction('restart-app');
|
||||
setLaunchError('');
|
||||
try {
|
||||
if (draftChanged) throw new Error(t('agents.error.applyFirst'));
|
||||
await invoke('restart_codex_app');
|
||||
} catch (requestError) {
|
||||
if (!handleOAuthLoginError(requestError, 'launch')) {
|
||||
@@ -1887,10 +1881,8 @@ export function AgentsPage() {
|
||||
type="button"
|
||||
className="secondary-button agent-launch-button"
|
||||
onClick={() => void launchAgent(cliLaunchTarget)}
|
||||
disabled={busy || !canLaunchTarget(cliLaunchTarget) || draftChanged}
|
||||
title={draftChanged
|
||||
? t('agents.launch.applyFirst')
|
||||
: cliLaunchTarget?.detail ?? t('agents.launch.unavailable')}
|
||||
disabled={busy || !canLaunchTarget(cliLaunchTarget)}
|
||||
title={cliLaunchTarget?.detail ?? t('agents.launch.unavailable')}
|
||||
>
|
||||
{busyAction === 'launch-cli'
|
||||
? <LoaderCircle size={16} className="spin" />
|
||||
@@ -1903,10 +1895,8 @@ export function AgentsPage() {
|
||||
type="button"
|
||||
className="primary-button agent-launch-button"
|
||||
onClick={() => void launchAgent(appLaunchTarget)}
|
||||
disabled={busy || !canLaunchTarget(appLaunchTarget) || draftChanged}
|
||||
title={draftChanged
|
||||
? t('agents.launch.applyFirst')
|
||||
: appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
|
||||
disabled={busy || !canLaunchTarget(appLaunchTarget)}
|
||||
title={appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
|
||||
>
|
||||
{busyAction === 'launch-app'
|
||||
? <LoaderCircle size={16} className="spin" />
|
||||
@@ -1919,10 +1909,8 @@ export function AgentsPage() {
|
||||
type="button"
|
||||
className="secondary-button agent-launch-button"
|
||||
onClick={() => void restartCodexApp()}
|
||||
disabled={busy || !canLaunchTarget(appLaunchTarget) || draftChanged}
|
||||
title={draftChanged
|
||||
? t('agents.launch.applyFirst')
|
||||
: appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
|
||||
disabled={busy || !canLaunchTarget(appLaunchTarget)}
|
||||
title={appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
|
||||
>
|
||||
{busyAction === 'restart-app'
|
||||
? <LoaderCircle size={16} className="spin" />
|
||||
@@ -1937,12 +1925,8 @@ export function AgentsPage() {
|
||||
type="button"
|
||||
className="primary-button agent-launch-button"
|
||||
onClick={() => void launchAgent(defaultLaunchTarget)}
|
||||
disabled={busy || !canLaunchTarget(defaultLaunchTarget) || draftChanged}
|
||||
title={draftChanged
|
||||
? t('agents.launch.applyFirst')
|
||||
: defaultLaunchTarget?.detail ?? (launchEnabled
|
||||
? t('agents.launch.unavailable')
|
||||
: t('agents.launch.enableFirst'))}
|
||||
disabled={busy || !canLaunchTarget(defaultLaunchTarget)}
|
||||
title={defaultLaunchTarget?.detail ?? t('agents.launch.unavailable')}
|
||||
>
|
||||
{busyAction === 'launch'
|
||||
? <LoaderCircle size={16} className="spin" />
|
||||
|
||||
@@ -5,8 +5,15 @@ import { join } from 'node:path';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
generatePortableUpdateManifest,
|
||||
portableUpdateManifestName,
|
||||
} from '../scripts/manifest.mjs';
|
||||
|
||||
test('macOS uses a new update manifest channel', () => {
|
||||
expect(portableUpdateManifestName('windows')).toBe('portable-update-windows.json');
|
||||
expect(portableUpdateManifestName('linux')).toBe('portable-update-linux.json');
|
||||
expect(portableUpdateManifestName('darwin')).toBe('portable-update-darwin-v2.json');
|
||||
});
|
||||
|
||||
describe('Windows 便携更新清单', () => {
|
||||
test('URL、大小和哈希与实际上传资产一致', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-test-'));
|
||||
@@ -131,10 +138,9 @@ describe('跨平台便携更新清单', () => {
|
||||
`${platform} ${arch} full package`,
|
||||
);
|
||||
}
|
||||
const output = join(root, `portable-update-${platform}.json`);
|
||||
const output = join(root, portableUpdateManifestName(platform));
|
||||
const manifest = await generatePortableUpdateManifest({
|
||||
directory: root,
|
||||
output,
|
||||
platform,
|
||||
repository: 'router-for-me/EasyCLIProxyAPI',
|
||||
tag: 'v1.2.3',
|
||||
|
||||
Reference in new issue
Block a user