feat: release v0.83.2 with full pending changes

This commit is contained in:
coso
2026-03-10 19:17:54 +08:00
parent e5d67a0d15
commit bfacf764b7
49 changed files with 2496 additions and 237 deletions
+16 -16
View File
@@ -6952,7 +6952,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"anyhow",
"arboard",
@@ -7054,7 +7054,7 @@ dependencies = [
[[package]]
name = "proxycast-agent"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"aster-core",
"async-trait",
@@ -7079,7 +7079,7 @@ dependencies = [
[[package]]
name = "proxycast-config"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"async-trait",
"parking_lot",
@@ -7095,7 +7095,7 @@ dependencies = [
[[package]]
name = "proxycast-core"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"aster-models",
"async-trait",
@@ -7135,7 +7135,7 @@ dependencies = [
[[package]]
name = "proxycast-credential"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -7170,7 +7170,7 @@ dependencies = [
[[package]]
name = "proxycast-gateway"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -7191,7 +7191,7 @@ dependencies = [
[[package]]
name = "proxycast-infra"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -7211,7 +7211,7 @@ dependencies = [
[[package]]
name = "proxycast-mcp"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -7243,7 +7243,7 @@ dependencies = [
[[package]]
name = "proxycast-processor"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"async-trait",
"parking_lot",
@@ -7262,7 +7262,7 @@ dependencies = [
[[package]]
name = "proxycast-providers"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"anyhow",
"async-stream",
@@ -7316,7 +7316,7 @@ dependencies = [
[[package]]
name = "proxycast-server"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"aster-core",
"async-stream",
@@ -7361,7 +7361,7 @@ dependencies = [
[[package]]
name = "proxycast-server-utils"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"axum 0.7.9",
"futures",
@@ -7376,7 +7376,7 @@ dependencies = [
[[package]]
name = "proxycast-services"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"anyhow",
"aster-core",
@@ -7417,7 +7417,7 @@ dependencies = [
[[package]]
name = "proxycast-skills"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -7433,7 +7433,7 @@ dependencies = [
[[package]]
name = "proxycast-terminal"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7460,7 +7460,7 @@ dependencies = [
[[package]]
name = "proxycast-websocket"
version = "0.83.0"
version = "0.83.1"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.83.1"
version = "0.83.2"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/proxycast"
@@ -191,7 +191,7 @@ version = "2.4"
[package]
name = "proxycast"
version = "0.83.1"
version = "0.83.2"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+81 -2
View File
@@ -14,6 +14,9 @@ const TOOL_RESULT_MAX_TEXT_PARTS: usize = 256;
const TOOL_RESULT_MAX_OUTPUT_CHARS: usize = 16_000;
const TOOL_RESULT_MAX_IMAGES: usize = 12;
const TOOL_RESULT_TRUNCATED_NOTICE: &str = "\n\n[event_converter] 工具输出已截断";
const TOOL_RESULT_DIAG_WARN_JSON_BYTES: usize = 64 * 1024;
const TOOL_RESULT_DIAG_WARN_OUTPUT_CHARS: usize = 8_000;
const TOOL_RESULT_DIAG_WARN_IMAGE_COUNT: usize = 4;
fn enhance_execution_error_text(raw: &str) -> String {
if !raw.contains("Execution error: No such file or directory (os error 2)") {
@@ -221,6 +224,45 @@ fn maybe_filter_web_content(raw: &str) -> String {
struct ExtractedToolResult {
output: String,
images: Vec<TauriToolImage>,
diagnostics: ToolResultDiagnostics,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ToolResultDiagnostics {
raw_json_bytes: Option<usize>,
output_chars: usize,
image_count: usize,
text_truncated: bool,
images_truncated: bool,
}
fn log_tool_result_diagnostics(tool_id: &str, diagnostics: &ToolResultDiagnostics) {
let raw_json_bytes = diagnostics.raw_json_bytes.unwrap_or(0);
let should_warn = diagnostics.text_truncated
|| diagnostics.images_truncated
|| raw_json_bytes >= TOOL_RESULT_DIAG_WARN_JSON_BYTES
|| diagnostics.output_chars >= TOOL_RESULT_DIAG_WARN_OUTPUT_CHARS
|| diagnostics.image_count >= TOOL_RESULT_DIAG_WARN_IMAGE_COUNT;
if should_warn {
tracing::warn!(
"[AsterAgent][Diag] tool_end payload summary: tool_id={}, raw_json_bytes={}, output_chars={}, image_count={}, text_truncated={}, images_truncated={}",
tool_id,
raw_json_bytes,
diagnostics.output_chars,
diagnostics.image_count,
diagnostics.text_truncated,
diagnostics.images_truncated
);
} else {
tracing::debug!(
"[AsterAgent][Diag] tool_end payload summary: tool_id={}, raw_json_bytes={}, output_chars={}, image_count={}",
tool_id,
raw_json_bytes,
diagnostics.output_chars,
diagnostics.image_count
);
}
}
fn parse_mime_type_from_data_url(data_url: &str) -> Option<String> {
@@ -387,6 +429,8 @@ fn extract_tool_result_data<T: serde::Serialize>(result: &T) -> ExtractedToolRes
let output = extract_tool_result_text(result);
let mut images = Vec::new();
let mut seen_sources = std::collections::HashSet::new();
let mut raw_json_bytes = None;
let mut images_truncated = false;
for data_url in extract_data_urls_from_text(&output) {
push_tool_image_if_new(
@@ -397,10 +441,25 @@ fn extract_tool_result_data<T: serde::Serialize>(result: &T) -> ExtractedToolRes
}
if let Ok(json) = serde_json::to_value(result) {
let _ = collect_tool_result_images(&json, &mut images, &mut seen_sources);
raw_json_bytes = serde_json::to_vec(&json).ok().map(|bytes| bytes.len());
images_truncated = collect_tool_result_images(&json, &mut images, &mut seen_sources);
}
ExtractedToolResult { output, images }
let output_chars = output.chars().count();
let image_count = images.len();
let text_truncated = output.contains(TOOL_RESULT_TRUNCATED_NOTICE);
ExtractedToolResult {
output,
images,
diagnostics: ToolResultDiagnostics {
raw_json_bytes,
output_chars,
image_count,
text_truncated,
images_truncated,
},
}
}
/// Tauri Agent 事件
@@ -629,6 +688,7 @@ fn convert_message(message: Message) -> Vec<TauriAgentEvent> {
let (success, output, error, images) = match &tool_response.tool_result {
Ok(result) => {
let extracted = extract_tool_result_data(result);
log_tool_result_diagnostics(&tool_response.id, &extracted.diagnostics);
(
true,
extracted.output,
@@ -1022,5 +1082,24 @@ mod tests {
let extracted = extract_tool_result_data(&payload);
assert_eq!(extracted.images.len(), TOOL_RESULT_MAX_IMAGES);
assert!(extracted.diagnostics.images_truncated);
}
#[test]
fn test_extract_tool_result_data_should_record_diagnostics() {
let payload = serde_json::json!({
"content": [
{
"type": "text",
"text": "hello"
}
]
});
let extracted = extract_tool_result_data(&payload);
assert_eq!(extracted.diagnostics.output_chars, 5);
assert_eq!(extracted.diagnostics.image_count, 0);
assert_eq!(extracted.diagnostics.text_truncated, false);
assert!(extracted.diagnostics.raw_json_bytes.is_some());
}
}
@@ -21,6 +21,9 @@ const WEB_SEARCH_REQUIRED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_REQUIRED_TOOLS
const WEB_SEARCH_ALLOWED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_ALLOWED_TOOLS";
const WEB_SEARCH_DISALLOWED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_DISALLOWED_TOOLS";
const WEB_SEARCH_PREFLIGHT_ENABLED_ENV: &str = "PROXYCAST_WEB_SEARCH_PREFLIGHT_ENABLED";
const STREAM_EVENT_DIAG_WARN_TEXT_DELTA_CHARS: usize = 2_000;
const STREAM_EVENT_DIAG_WARN_TOOL_OUTPUT_CHARS: usize = 8_000;
const STREAM_EVENT_DIAG_WARN_CONTEXT_STEPS: usize = 24;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestToolPolicy {
@@ -188,6 +191,68 @@ pub struct ReplyAttemptError {
pub emitted_any: bool,
}
#[derive(Debug, Default)]
struct StreamEventDiagnostics {
text_delta_count: usize,
tool_start_count: usize,
tool_end_count: usize,
error_count: usize,
context_trace_events: usize,
max_text_delta_chars: usize,
max_tool_output_chars: usize,
max_context_trace_steps: usize,
}
fn update_stream_event_diagnostics(
diagnostics: &mut StreamEventDiagnostics,
event: &TauriAgentEvent,
) {
match event {
TauriAgentEvent::TextDelta { text } => {
diagnostics.text_delta_count += 1;
let char_count = text.chars().count();
diagnostics.max_text_delta_chars = diagnostics.max_text_delta_chars.max(char_count);
if char_count >= STREAM_EVENT_DIAG_WARN_TEXT_DELTA_CHARS {
tracing::warn!(
"[AsterAgent][Diag] large text_delta observed: chars={}",
char_count
);
}
}
TauriAgentEvent::ToolStart { .. } => {
diagnostics.tool_start_count += 1;
}
TauriAgentEvent::ToolEnd { tool_id, result } => {
diagnostics.tool_end_count += 1;
let output_chars = result.output.chars().count();
diagnostics.max_tool_output_chars = diagnostics.max_tool_output_chars.max(output_chars);
if output_chars >= STREAM_EVENT_DIAG_WARN_TOOL_OUTPUT_CHARS {
tracing::warn!(
"[AsterAgent][Diag] large tool_end output observed: tool_id={}, output_chars={}, success={}",
tool_id,
output_chars,
result.success
);
}
}
TauriAgentEvent::ContextTrace { steps } => {
diagnostics.context_trace_events += 1;
diagnostics.max_context_trace_steps =
diagnostics.max_context_trace_steps.max(steps.len());
if steps.len() >= STREAM_EVENT_DIAG_WARN_CONTEXT_STEPS {
tracing::warn!(
"[AsterAgent][Diag] large context_trace observed: steps={}",
steps.len()
);
}
}
TauriAgentEvent::Error { .. } => {
diagnostics.error_count += 1;
}
_ => {}
}
}
#[derive(Debug, Clone, Default)]
pub struct StreamReplyExecution {
pub text_output: String,
@@ -541,6 +606,7 @@ where
let mut emitted_any = false;
let mut text_chunks: Vec<String> = Vec::new();
let mut event_errors: Vec<String> = Vec::new();
let mut diagnostics = StreamEventDiagnostics::default();
while let Some(event_result) = stream.next().await {
match event_result {
@@ -580,6 +646,7 @@ where
}
_ => {}
}
update_stream_event_diagnostics(&mut diagnostics, &tauri_event);
on_event(&tauri_event);
}
if let Some(message) = inline_provider_error {
@@ -607,6 +674,18 @@ where
});
}
tracing::info!(
"[AsterAgent][Diag] stream summary: text_deltas={}, tool_starts={}, tool_ends={}, context_traces={}, errors={}, max_text_delta_chars={}, max_tool_output_chars={}, max_context_trace_steps={}",
diagnostics.text_delta_count,
diagnostics.tool_start_count,
diagnostics.tool_end_count,
diagnostics.context_trace_events,
diagnostics.error_count,
diagnostics.max_text_delta_chars,
diagnostics.max_tool_output_chars,
diagnostics.max_context_trace_steps
);
Ok(StreamReplyExecution {
text_output: text_chunks.join(""),
event_errors,
+579
View File
@@ -0,0 +1,579 @@
use rusqlite::{Connection, DatabaseName};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const APP_DATA_DIR_NAME: &str = "proxycast";
const LEGACY_HOME_DIR_NAME: &str = ".proxycast";
const DATABASE_FILE_NAME: &str = "proxycast.db";
const MIGRATION_MARKER_FILE: &str = ".migration_completed";
const USER_SIGNAL_TABLES: &[&str] = &[
"contents",
"agent_sessions",
"general_chat_sessions",
"materials",
"api_keys",
"heartbeat_executions",
];
pub fn preferred_data_dir() -> Result<PathBuf, String> {
let dir = dirs::data_dir()
.ok_or_else(|| "无法获取应用数据目录".to_string())?
.join(APP_DATA_DIR_NAME);
fs::create_dir_all(&dir).map_err(|e| format!("无法创建应用数据目录 {}: {e}", dir.display()))?;
Ok(dir)
}
pub fn legacy_home_dir() -> Result<PathBuf, String> {
Ok(dirs::home_dir()
.ok_or_else(|| "无法获取主目录".to_string())?
.join(LEGACY_HOME_DIR_NAME))
}
pub fn preferred_database_path() -> Result<PathBuf, String> {
Ok(preferred_data_dir()?.join(DATABASE_FILE_NAME))
}
pub fn legacy_database_path() -> Result<PathBuf, String> {
Ok(legacy_home_dir()?.join(DATABASE_FILE_NAME))
}
pub fn resolve_database_path() -> Result<PathBuf, String> {
let preferred_root = preferred_data_dir()?;
let legacy_root = legacy_home_dir()?;
resolve_database_path_from_roots(&preferred_root, &legacy_root)
}
pub fn resolve_logs_dir() -> Result<PathBuf, String> {
let preferred_root = preferred_data_dir()?;
let legacy_root = legacy_home_dir()?;
resolve_subdir_with_legacy_copy_from_roots(&preferred_root, &legacy_root, "logs")
}
pub fn resolve_request_logs_dir() -> Result<PathBuf, String> {
let preferred_root = preferred_data_dir()?;
let legacy_root = legacy_home_dir()?;
resolve_subdir_with_legacy_copy_from_roots(&preferred_root, &legacy_root, "request_logs")
}
fn resolve_database_path_from_roots(
preferred_root: &Path,
legacy_root: &Path,
) -> Result<PathBuf, String> {
fs::create_dir_all(preferred_root)
.map_err(|e| format!("无法创建数据库目录 {}: {e}", preferred_root.display()))?;
let preferred_path = preferred_root.join(DATABASE_FILE_NAME);
let marker_path = preferred_root.join(MIGRATION_MARKER_FILE);
// 标记文件存在 → 迁移已完成,直接用 preferred 路径
if marker_path.exists() {
return Ok(preferred_path);
}
let legacy_path = legacy_root.join(DATABASE_FILE_NAME);
// 无旧库 → 全新安装,写标记后直接返回
if !legacy_path.exists() {
write_migration_marker(&marker_path);
return Ok(preferred_path);
}
// preferred 库不存在 → 首次迁移
if !preferred_path.exists() {
let result = migrate_or_fallback_to_legacy(&legacy_path, &preferred_path);
if result
.as_ref()
.map(|p| p == &preferred_path)
.unwrap_or(false)
{
write_migration_marker(&marker_path);
}
return result;
}
// 两个库都存在,检查是否需要用旧库覆盖空的新库
let preferred_signal = inspect_database_signal(&preferred_path);
let legacy_signal = inspect_database_signal(&legacy_path);
if should_replace_preferred_with_legacy(
preferred_path.as_path(),
preferred_signal.as_ref(),
legacy_path.as_path(),
legacy_signal.as_ref(),
) {
let result = migrate_or_fallback_to_legacy(&legacy_path, &preferred_path);
if result
.as_ref()
.map(|p| p == &preferred_path)
.unwrap_or(false)
{
write_migration_marker(&marker_path);
}
return result;
}
// preferred 库已有用户数据,迁移完成
write_migration_marker(&marker_path);
Ok(preferred_path)
}
fn write_migration_marker(marker_path: &Path) {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_default();
if let Err(e) = fs::write(marker_path, timestamp) {
tracing::warn!(
"[路径迁移] 写入迁移标记失败 {}(下次启动会重新检测): {e}",
marker_path.display()
);
}
}
fn migrate_or_fallback_to_legacy(
legacy_path: &Path,
preferred_path: &Path,
) -> Result<PathBuf, String> {
match migrate_legacy_database(legacy_path, preferred_path) {
Ok(()) => {
tracing::info!(
"[路径迁移] 数据库已从旧路径迁移到 {}",
preferred_path.display()
);
Ok(preferred_path.to_path_buf())
}
Err(error) => {
tracing::warn!(
"[路径迁移] 数据库迁移失败,回退旧路径 {}: {}",
legacy_path.display(),
error
);
Ok(legacy_path.to_path_buf())
}
}
}
fn resolve_subdir_with_legacy_copy_from_roots(
preferred_root: &Path,
legacy_root: &Path,
subdir: &str,
) -> Result<PathBuf, String> {
let preferred_dir = preferred_root.join(subdir);
fs::create_dir_all(&preferred_dir)
.map_err(|e| format!("无法创建目录 {}: {e}", preferred_dir.display()))?;
// 标记文件存在 → 迁移已完成,跳过旧目录扫描
let marker_path = preferred_root.join(MIGRATION_MARKER_FILE);
if marker_path.exists() {
return Ok(preferred_dir);
}
let legacy_dir = legacy_root.join(subdir);
if legacy_dir.exists() {
copy_dir_contents_if_missing(&legacy_dir, &preferred_dir)?;
}
Ok(preferred_dir)
}
fn migrate_legacy_database(legacy_path: &Path, preferred_path: &Path) -> Result<(), String> {
if let Some(parent) = preferred_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("无法创建数据库目录 {}: {e}", parent.display()))?;
}
let source = Connection::open(legacy_path)
.map_err(|e| format!("打开旧数据库失败 {}: {e}", legacy_path.display()))?;
source
.busy_timeout(std::time::Duration::from_secs(5))
.map_err(|e| format!("设置旧数据库 busy_timeout 失败: {e}"))?;
let _ = source.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
backup_existing_database(preferred_path)?;
remove_database_with_sidecars(preferred_path)?;
match source.backup(DatabaseName::Main, preferred_path, None) {
Ok(()) => Ok(()),
Err(error) => {
let _ = remove_database_with_sidecars(preferred_path);
Err(format!(
"复制旧数据库 {} -> {} 失败: {error}",
legacy_path.display(),
preferred_path.display()
))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DatabaseSignal {
user_signal: u64,
has_schema: bool,
}
fn inspect_database_signal(path: &Path) -> Option<DatabaseSignal> {
if !path.exists() {
return None;
}
let conn = Connection::open(path).ok()?;
let has_schema = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table'",
[],
|row| row.get::<_, u64>(0),
)
.ok()
.map(|count| count > 0)
.unwrap_or(false);
let user_signal = USER_SIGNAL_TABLES
.iter()
.map(|table| {
let sql = format!("SELECT COUNT(*) FROM {table}");
conn.query_row(&sql, [], |row| row.get::<_, u64>(0))
.unwrap_or(0)
})
.sum();
Some(DatabaseSignal {
user_signal,
has_schema,
})
}
fn should_replace_preferred_with_legacy(
preferred_path: &Path,
preferred_signal: Option<&DatabaseSignal>,
legacy_path: &Path,
legacy_signal: Option<&DatabaseSignal>,
) -> bool {
let Some(legacy_signal) = legacy_signal else {
return false;
};
let Some(preferred_signal) = preferred_signal else {
return true;
};
if !preferred_signal.has_schema && legacy_signal.has_schema {
tracing::warn!(
"[路径迁移] 当前数据库 {} 无有效 schema,准备回退旧库 {}",
preferred_path.display(),
legacy_path.display()
);
return true;
}
if preferred_signal.user_signal == 0 && legacy_signal.user_signal > 0 {
tracing::warn!(
"[路径迁移] 当前数据库 {} 缺少用户数据,检测到旧库 {} 含历史数据,准备自动恢复",
preferred_path.display(),
legacy_path.display()
);
return true;
}
false
}
fn backup_existing_database(path: &Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
let backup_path = path.with_file_name(format!(
"{DATABASE_FILE_NAME}.bootstrap-backup-{suffix}.bak"
));
fs::copy(path, &backup_path).map_err(|e| {
format!(
"备份当前数据库失败 {} -> {}: {e}",
path.display(),
backup_path.display()
)
})?;
Ok(())
}
fn remove_database_with_sidecars(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_file(path)
.map_err(|e| format!("删除旧数据库文件失败 {}: {e}", path.display()))?;
}
for suffix in ["-wal", "-shm"] {
let sidecar = PathBuf::from(format!("{}{}", path.display(), suffix));
if sidecar.exists() {
fs::remove_file(&sidecar)
.map_err(|e| format!("删除数据库伴生文件失败 {}: {e}", sidecar.display()))?;
}
}
Ok(())
}
fn copy_dir_contents_if_missing(from: &Path, to: &Path) -> Result<(), String> {
let entries =
fs::read_dir(from).map_err(|e| format!("读取目录失败 {}: {e}", from.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("读取目录项失败 {}: {e}", from.display()))?;
let source_path = entry.path();
let target_path = to.join(entry.file_name());
if source_path.is_dir() {
fs::create_dir_all(&target_path)
.map_err(|e| format!("创建目录失败 {}: {e}", target_path.display()))?;
copy_dir_contents_if_missing(&source_path, &target_path)?;
continue;
}
if target_path.exists() {
continue;
}
fs::copy(&source_path, &target_path).map_err(|e| {
format!(
"复制文件失败 {} -> {}: {e}",
source_path.display(),
target_path.display()
)
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn resolve_database_path_migrates_legacy_database() {
let temp = tempdir().unwrap();
let preferred_root = temp.path().join("appdata").join("proxycast");
let legacy_root = temp.path().join("home").join(".proxycast");
fs::create_dir_all(&legacy_root).unwrap();
let legacy_db = legacy_root.join(DATABASE_FILE_NAME);
let conn = Connection::open(&legacy_db).unwrap();
conn.execute(
"CREATE TABLE sample (id INTEGER PRIMARY KEY, name TEXT)",
[],
)
.unwrap();
conn.execute("INSERT INTO sample (name) VALUES ('proxycast')", [])
.unwrap();
let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
assert_eq!(resolved, preferred_root.join(DATABASE_FILE_NAME));
assert!(resolved.exists());
let migrated = Connection::open(resolved).unwrap();
let name: String = migrated
.query_row("SELECT name FROM sample LIMIT 1", [], |row| row.get(0))
.unwrap();
assert_eq!(name, "proxycast");
}
#[test]
fn resolve_logs_dir_copies_legacy_files() {
let temp = tempdir().unwrap();
let preferred_root = temp.path().join("appdata").join("proxycast");
let legacy_root = temp.path().join("home").join(".proxycast");
let legacy_logs = legacy_root.join("logs");
fs::create_dir_all(&legacy_logs).unwrap();
fs::write(legacy_logs.join("proxycast.log"), "legacy log").unwrap();
let resolved =
resolve_subdir_with_legacy_copy_from_roots(&preferred_root, &legacy_root, "logs")
.unwrap();
assert_eq!(resolved, preferred_root.join("logs"));
assert_eq!(
fs::read_to_string(resolved.join("proxycast.log")).unwrap(),
"legacy log"
);
}
#[test]
fn resolve_database_path_replaces_bootstrap_db_with_legacy_data() {
let temp = tempdir().unwrap();
let preferred_root = temp.path().join("appdata").join("proxycast");
let legacy_root = temp.path().join("home").join(".proxycast");
fs::create_dir_all(&preferred_root).unwrap();
fs::create_dir_all(&legacy_root).unwrap();
let preferred_db = preferred_root.join(DATABASE_FILE_NAME);
let preferred_conn = Connection::open(&preferred_db).unwrap();
preferred_conn
.execute(
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
[],
)
.unwrap();
let legacy_db = legacy_root.join(DATABASE_FILE_NAME);
let legacy_conn = Connection::open(&legacy_db).unwrap();
legacy_conn
.execute(
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
[],
)
.unwrap();
legacy_conn
.execute(
"CREATE TABLE agent_sessions (id INTEGER PRIMARY KEY, name TEXT)",
[],
)
.unwrap();
legacy_conn
.execute("INSERT INTO contents (title) VALUES ('legacy')", [])
.unwrap();
let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
let conn = Connection::open(resolved).unwrap();
let count: u64 = conn
.query_row("SELECT COUNT(*) FROM contents", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn resolve_database_path_keeps_preferred_when_it_has_user_data() {
let temp = tempdir().unwrap();
let preferred_root = temp.path().join("appdata").join("proxycast");
let legacy_root = temp.path().join("home").join(".proxycast");
fs::create_dir_all(&preferred_root).unwrap();
fs::create_dir_all(&legacy_root).unwrap();
let preferred_db = preferred_root.join(DATABASE_FILE_NAME);
let preferred_conn = Connection::open(&preferred_db).unwrap();
preferred_conn
.execute(
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
[],
)
.unwrap();
preferred_conn
.execute("INSERT INTO contents (title) VALUES ('preferred')", [])
.unwrap();
let legacy_db = legacy_root.join(DATABASE_FILE_NAME);
let legacy_conn = Connection::open(&legacy_db).unwrap();
legacy_conn
.execute(
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
[],
)
.unwrap();
legacy_conn
.execute("INSERT INTO contents (title) VALUES ('legacy')", [])
.unwrap();
let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
let conn = Connection::open(resolved).unwrap();
let title: String = conn
.query_row("SELECT title FROM contents LIMIT 1", [], |row| row.get(0))
.unwrap();
assert_eq!(title, "preferred");
}
#[test]
fn resolve_database_path_skips_migration_when_marker_exists() {
let temp = tempdir().unwrap();
let preferred_root = temp.path().join("appdata").join("proxycast");
let legacy_root = temp.path().join("home").join(".proxycast");
fs::create_dir_all(&preferred_root).unwrap();
fs::create_dir_all(&legacy_root).unwrap();
// preferred 库为空(只有 schema)
let preferred_db = preferred_root.join(DATABASE_FILE_NAME);
let preferred_conn = Connection::open(&preferred_db).unwrap();
preferred_conn
.execute(
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
[],
)
.unwrap();
drop(preferred_conn);
// 旧库有数据
let legacy_db = legacy_root.join(DATABASE_FILE_NAME);
let legacy_conn = Connection::open(&legacy_db).unwrap();
legacy_conn
.execute(
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
[],
)
.unwrap();
legacy_conn
.execute("INSERT INTO contents (title) VALUES ('legacy')", [])
.unwrap();
drop(legacy_conn);
// 写入标记文件 → 模拟已迁移过
fs::write(preferred_root.join(MIGRATION_MARKER_FILE), "1700000000").unwrap();
// 即使旧库有数据、新库为空,也不应触发迁移
let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
assert_eq!(resolved, preferred_db);
let conn = Connection::open(&resolved).unwrap();
let count: u64 = conn
.query_row("SELECT COUNT(*) FROM contents", [], |row| row.get(0))
.unwrap();
// 新库仍为空,说明没有被旧库覆盖
assert_eq!(count, 0);
}
#[test]
fn resolve_database_path_writes_marker_after_successful_migration() {
let temp = tempdir().unwrap();
let preferred_root = temp.path().join("appdata").join("proxycast");
let legacy_root = temp.path().join("home").join(".proxycast");
fs::create_dir_all(&legacy_root).unwrap();
let legacy_db = legacy_root.join(DATABASE_FILE_NAME);
let conn = Connection::open(&legacy_db).unwrap();
conn.execute(
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
[],
)
.unwrap();
conn.execute("INSERT INTO contents (title) VALUES ('data')", [])
.unwrap();
drop(conn);
let marker_path = preferred_root.join(MIGRATION_MARKER_FILE);
assert!(!marker_path.exists());
let _ = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
// 迁移成功后标记文件应存在
assert!(marker_path.exists());
}
#[test]
fn resolve_database_path_writes_marker_for_fresh_install() {
let temp = tempdir().unwrap();
let preferred_root = temp.path().join("appdata").join("proxycast");
let legacy_root = temp.path().join("home").join(".proxycast");
// 不创建 legacy_root → 模拟全新安装
let marker_path = preferred_root.join(MIGRATION_MARKER_FILE);
assert!(!marker_path.exists());
let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
assert_eq!(resolved, preferred_root.join(DATABASE_FILE_NAME));
// 全新安装也应写标记
assert!(marker_path.exists());
}
}
+2 -4
View File
@@ -6,6 +6,7 @@ pub mod migration_v4;
pub mod schema;
pub mod system_providers;
use crate::app_paths;
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
@@ -26,10 +27,7 @@ pub fn lock_db(db: &DbConnection) -> Result<std::sync::MutexGuard<'_, Connection
/// 获取数据库文件路径
pub fn get_db_path() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?;
let db_dir = home.join(".proxycast");
std::fs::create_dir_all(&db_dir).map_err(|e| format!("无法创建数据库目录 {db_dir:?}: {e}"))?;
Ok(db_dir.join("proxycast.db"))
app_paths::resolve_database_path()
}
/// 初始化数据库连接
+1
View File
@@ -18,6 +18,7 @@
//! - `session_files`: 会话文件存储
pub mod app_bootstrap;
pub mod app_paths;
pub mod app_utils;
pub mod data;
pub mod logger;
+7 -4
View File
@@ -1,4 +1,5 @@
//! 日志管理模块
use crate::app_paths;
use crate::config::LoggingConfig;
use chrono::{Duration, Local, Utc};
use regex::Regex;
@@ -44,10 +45,12 @@ pub struct LogStore {
impl Default for LogStore {
fn default() -> Self {
let log_dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".proxycast")
.join("logs");
let log_dir = app_paths::resolve_logs_dir().unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".proxycast")
.join("logs")
});
let _ = fs::create_dir_all(&log_dir);
let log_file = log_dir.join("proxycast.log");
let config = LogStoreConfig::default();
@@ -100,15 +100,8 @@ pub struct RequestLogger {
impl RequestLogger {
/// 创建新的日志记录器
pub fn new(config: LogRotationConfig) -> Result<Self, LoggerError> {
let log_dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".proxycast")
.join("request_logs");
// 创建日志目录
fs::create_dir_all(&log_dir).map_err(|e| {
LoggerError::DirectoryCreation(format!("无法创建日志目录 {log_dir:?}: {e}"))
})?;
let log_dir = proxycast_core::app_paths::resolve_request_logs_dir()
.map_err(LoggerError::DirectoryCreation)?;
let logger = Self {
logs: RwLock::new(VecDeque::with_capacity(config.max_memory_logs)),
@@ -1006,10 +1006,8 @@ impl KiroProvider {
.unwrap_or(false);
if debug_enabled {
if let Ok(json_str) = serde_json::to_string_pretty(&cw_request) {
let debug_dir = dirs::home_dir()
.unwrap_or_default()
.join(".proxycast")
.join("logs");
let debug_dir = proxycast_core::app_paths::resolve_logs_dir()
.unwrap_or_else(|_| std::env::temp_dir().join("proxycast").join("logs"));
let uuid_prefix = uuid::Uuid::new_v4()
.to_string()
.split('-')
@@ -1329,9 +1329,8 @@ pub async fn call_provider_openai(
Ok(resp) => {
let resp_str = serde_json::to_string_pretty(&resp).unwrap_or_default();
if is_proxycast_debug_enabled() {
let debug_dir = dirs::home_dir()
.map(|h| h.join(".proxycast/logs"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let debug_dir = proxycast_core::app_paths::resolve_logs_dir()
.unwrap_or_else(|_| std::env::temp_dir().join("proxycast").join("logs"));
let _ = std::fs::create_dir_all(&debug_dir);
let debug_file = debug_dir.join("antigravity_image_response.json");
let _ = std::fs::write(&debug_file, &resp_str);
@@ -1354,9 +1353,8 @@ pub async fn call_provider_openai(
let openai_str = serde_json::to_string_pretty(&openai_response).unwrap_or_default();
if is_proxycast_debug_enabled() {
let debug_dir = dirs::home_dir()
.map(|h| h.join(".proxycast/logs"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let debug_dir = proxycast_core::app_paths::resolve_logs_dir()
.unwrap_or_else(|_| std::env::temp_dir().join("proxycast").join("logs"));
let _ = std::fs::create_dir_all(&debug_dir);
let openai_debug_file =
debug_dir.join("antigravity_image_openai_response.json");
@@ -2887,9 +2885,9 @@ fn parse_antigravity_accumulated_response(data: &str, model: &str) -> Result<Str
);
let debug_enabled = is_proxycast_debug_enabled();
let debug_file = dirs::home_dir()
.map(|h| h.join(".proxycast/logs/antigravity_stream_raw.txt"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/antigravity_stream_raw.txt"));
let debug_file = proxycast_core::app_paths::resolve_logs_dir()
.map(|dir| dir.join("antigravity_stream_raw.txt"))
.unwrap_or_else(|_| std::env::temp_dir().join("antigravity_stream_raw.txt"));
if debug_enabled {
if let Some(debug_dir) = debug_file.parent() {
@@ -11,10 +11,11 @@ use proxycast_agent::tools::heartbeat_tool::{
HeartbeatCycleResult, HeartbeatExecutionRecord, HeartbeatService, HeartbeatStatus,
HeartbeatTaskPreview, HeartbeatToolError,
};
use proxycast_core::app_paths;
use proxycast_core::database::dao::heartbeat::HeartbeatExecution;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tauri::AppHandle;
/// Heartbeat Service 适配器
///
@@ -35,9 +36,7 @@ impl HeartbeatServiceAdapter {
/// 获取应用数据目录
fn app_data_dir(&self) -> Result<PathBuf, HeartbeatToolError> {
self.app_handle
.path()
.app_data_dir()
app_paths::preferred_data_dir()
.map_err(|e| HeartbeatToolError::IoError(format!("获取应用数据目录失败: {}", e)))
}
+3 -5
View File
@@ -12,7 +12,7 @@ use std::fs;
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use tauri::{AppHandle, Manager};
use tauri::AppHandle;
use zip::write::FileOptions;
use zip::{CompressionMethod, ZipWriter};
@@ -522,9 +522,7 @@ pub async fn export_support_bundle(
get_log_storage_diagnostics_from_path(log_file_path.clone(), in_memory_log_count);
let persisted_log_tail = read_persisted_logs_tail_from_path(log_file_path, 200)?;
let app_data_dir = app
.path()
.app_data_dir()
let app_data_dir = proxycast_core::app_paths::preferred_data_dir()
.ok()
.or_else(guess_proxycast_app_data_dir);
let config_path = guess_proxycast_config_path();
@@ -564,7 +562,7 @@ pub async fn export_support_bundle(
}
fn guess_proxycast_app_data_dir() -> Option<PathBuf> {
dirs::data_dir().map(|dir| dir.join("proxycast"))
proxycast_core::app_paths::preferred_data_dir().ok()
}
fn guess_proxycast_config_path() -> Option<PathBuf> {
+1 -1
View File
@@ -757,7 +757,7 @@ pub fn run() {
service.set_app_handle(app_handle.clone());
if service.get_config().enabled {
let app_data_dir = match app_handle.path().app_data_dir() {
let app_data_dir = match proxycast_core::app_paths::preferred_data_dir() {
Ok(dir) => dir,
Err(e) => {
tracing::error!("[启动] 无法获取应用数据目录: {}", e);
+3 -4
View File
@@ -6,7 +6,7 @@ use crate::models::app_type::AppType;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager};
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
#[cfg(target_os = "windows")]
@@ -1119,9 +1119,8 @@ fn get_download_directory(app_handle: &AppHandle) -> Result<PathBuf, String> {
}
// 回退到应用数据目录
let app_data_dir = app_handle
.path()
.app_data_dir()
let _ = app_handle;
let app_data_dir = proxycast_core::app_paths::preferred_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let download_dir = app_data_dir.join("downloads");
+25 -38
View File
@@ -12,12 +12,12 @@ use crate::services::heartbeat_service::{
CycleResult, HeartbeatServiceState, HeartbeatStatus, HeartbeatTaskPreview,
};
use crate::AppState;
use proxycast_core::app_paths;
use proxycast_core::config::{DeliveryConfig, HeartbeatSecurityConfig, TaskSchedule};
use proxycast_core::database::dao::heartbeat::HeartbeatExecution;
use proxycast_websocket::handlers::{RpcHandler, RpcHandlerState};
use proxycast_websocket::protocol::{CronHealthResult, GatewayRpcRequest, RpcMethod};
use serde::{Deserialize, Serialize};
use tauri::Manager;
use uuid::Uuid;
// ========== 配置响应类型 ==========
@@ -135,9 +135,7 @@ pub async fn update_heartbeat_config(
// 处理启停逻辑
if config.enabled && !was_enabled {
service.set_app_handle(app.clone());
let app_data_dir = app
.path()
.app_data_dir()
let app_data_dir = app_paths::preferred_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let self_ref = hb_state.0.clone();
service.start(app_data_dir, self_ref).await?;
@@ -162,12 +160,10 @@ pub async fn get_heartbeat_status(
#[tauri::command]
pub async fn get_heartbeat_tasks(
hb_state: tauri::State<'_, HeartbeatServiceState>,
app: tauri::AppHandle,
_app: tauri::AppHandle,
) -> Result<Vec<HeartbeatTaskPreview>, String> {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let app_data_dir =
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let service = hb_state.0.read().await;
service.preview_tasks(&app_data_dir)
}
@@ -303,11 +299,12 @@ pub async fn get_task_templates() -> Result<Vec<TaskTemplate>, String> {
}
#[tauri::command]
pub async fn apply_task_template(template_id: String, app: tauri::AppHandle) -> Result<(), String> {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
pub async fn apply_task_template(
template_id: String,
_app: tauri::AppHandle,
) -> Result<(), String> {
let app_data_dir =
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let template = TaskTemplateRegistry::get_template_by_id(&template_id)
.ok_or_else(|| format!("模板不存在: {}", template_id))?;
@@ -320,17 +317,15 @@ pub async fn apply_task_template(template_id: String, app: tauri::AppHandle) ->
#[tauri::command]
pub async fn add_heartbeat_task(
hb_state: tauri::State<'_, HeartbeatServiceState>,
app: tauri::AppHandle,
_app: tauri::AppHandle,
description: String,
priority: Option<u8>,
timeout_secs: Option<u64>,
once: Option<bool>,
model: Option<String>,
) -> Result<(), String> {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let app_data_dir =
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let service = hb_state.0.read().await;
service.add_task(
&app_data_dir,
@@ -345,13 +340,11 @@ pub async fn add_heartbeat_task(
#[tauri::command]
pub async fn delete_heartbeat_task(
hb_state: tauri::State<'_, HeartbeatServiceState>,
app: tauri::AppHandle,
_app: tauri::AppHandle,
index: usize,
) -> Result<(), String> {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let app_data_dir =
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let service = hb_state.0.read().await;
service.delete_task(&app_data_dir, index)
}
@@ -359,7 +352,7 @@ pub async fn delete_heartbeat_task(
#[tauri::command]
pub async fn update_heartbeat_task(
hb_state: tauri::State<'_, HeartbeatServiceState>,
app: tauri::AppHandle,
_app: tauri::AppHandle,
index: usize,
description: String,
priority: Option<u8>,
@@ -367,10 +360,8 @@ pub async fn update_heartbeat_task(
once: Option<bool>,
model: Option<String>,
) -> Result<(), String> {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let app_data_dir =
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let service = hb_state.0.read().await;
service.update_task(
&app_data_dir,
@@ -388,7 +379,7 @@ pub async fn update_heartbeat_task(
#[tauri::command]
pub async fn generate_content_creator_tasks(
state: tauri::State<'_, AppState>,
app: tauri::AppHandle,
_app: tauri::AppHandle,
) -> Result<usize, String> {
use crate::services::heartbeat_service::templates::ContentCreatorTaskGenerator;
@@ -401,10 +392,8 @@ pub async fn generate_content_creator_tasks(
let count = tasks.len();
if count > 0 {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let app_data_dir =
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
ContentCreatorTaskGenerator::append_to_heartbeat(tasks, &app_data_dir)?;
}
@@ -418,10 +407,8 @@ pub async fn trigger_heartbeat_now(
hb_state: tauri::State<'_, HeartbeatServiceState>,
app: tauri::AppHandle,
) -> Result<CycleResult, String> {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let app_data_dir =
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let result = {
let service = hb_state.0.read().await;
+4 -6
View File
@@ -1698,17 +1698,15 @@ fn resolve_profile_data_dir_from_base(base_dir: &Path, profile_key: &str) -> Pat
}
fn resolve_profile_data_dir(app: &AppHandle, profile_key: &str) -> Result<PathBuf, String> {
let base_dir = app
.path()
.app_data_dir()
let _ = app;
let base_dir = proxycast_core::app_paths::preferred_data_dir()
.map_err(|e| format!("获取应用数据目录失败: {e}"))?;
Ok(resolve_profile_data_dir_from_base(&base_dir, profile_key))
}
fn resolve_chrome_profile_data_dir(app: &AppHandle, profile_key: &str) -> Result<PathBuf, String> {
let base_dir = app
.path()
.app_data_dir()
let _ = app;
let base_dir = proxycast_core::app_paths::preferred_data_dir()
.map_err(|e| format!("获取应用数据目录失败: {e}"))?;
Ok(base_dir
.join("chrome_profiles")
+45 -10
View File
@@ -10,6 +10,8 @@ use std::path::PathBuf;
#[cfg(target_os = "windows")]
use std::io::Write;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
use std::path::Path;
#[cfg(target_os = "windows")]
use std::process::Command;
@@ -18,6 +20,9 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
#[cfg(target_os = "windows")]
use winreg::{enums::*, RegKey};
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[derive(Debug, Clone, Serialize)]
pub struct WindowsStartupCheck {
pub key: String,
@@ -129,7 +134,7 @@ pub fn collect_windows_startup_diagnostics(app: &AppHandle) -> WindowsStartupDia
let mut errors = Vec::new();
let mut warnings = Vec::new();
let app_data_dir: Option<PathBuf> = app.path().app_data_dir().ok();
let app_data_dir: Option<PathBuf> = proxycast_core::app_paths::preferred_data_dir().ok();
let home_dir = dirs::home_dir();
let legacy_proxycast_dir = home_dir.clone().map(|home| home.join(".proxycast"));
let db_path = crate::database::get_db_path().ok();
@@ -171,23 +176,27 @@ pub fn collect_windows_startup_diagnostics(app: &AppHandle) -> WindowsStartupDia
}
match &legacy_proxycast_dir {
Some(path) => match ensure_dir_writable(path) {
Some(path) if path.exists() => match ensure_existing_dir_writable(path) {
Ok(()) => checks.push(ok_check(
"legacy_proxycast_dir",
format!("用户目录数据根可写: {}", path.display()),
format!("检测到旧版数据目录且可访问: {}", path.display()),
)),
Err(error) => {
errors.push(format!("用户目录数据根不可写: {}", path.display()));
checks.push(error_check(
warnings.push(format!("旧版数据目录不可访问: {}", path.display()));
checks.push(warn_check(
"legacy_proxycast_dir",
format!("用户目录数据根不可写: {}", path.display()),
format!("旧版数据目录不可访问: {}", path.display()),
Some(error),
));
}
},
Some(path) => checks.push(ok_check(
"legacy_proxycast_dir",
format!("未检测到旧版数据目录: {}", path.display()),
)),
None => {
errors.push("无法解析用户 Home 目录".to_string());
checks.push(error_check(
warnings.push("无法解析用户 Home 目录".to_string());
checks.push(warn_check(
"legacy_proxycast_dir",
"无法解析用户 Home 目录".to_string(),
None,
@@ -439,6 +448,25 @@ fn ensure_dir_writable(path: &Path) -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "windows")]
fn ensure_existing_dir_writable(path: &Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let probe = path.join("proxycast-write-test.tmp");
let mut file = std::fs::File::create(&probe)
.map_err(|e| format!("创建测试文件失败 {}: {e}", probe.display()))?;
file.write_all(b"proxycast")
.map_err(|e| format!("写入测试文件失败 {}: {e}", probe.display()))?;
file.sync_all()
.map_err(|e| format!("刷新测试文件失败 {}: {e}", probe.display()))?;
std::fs::remove_file(&probe)
.map_err(|e| format!("删除测试文件失败 {}: {e}", probe.display()))?;
Ok(())
}
#[cfg(target_os = "windows")]
fn check_database_file(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
@@ -447,11 +475,16 @@ fn check_database_file(path: &Path) -> Result<(), String> {
let conn = rusqlite::Connection::open(path)
.map_err(|e| format!("打开数据库失败 {}: {e}", path.display()))?;
conn.execute("PRAGMA user_version", [])
conn.query_row("PRAGMA user_version", [], |_| Ok(()))
.map_err(|e| format!("执行数据库探测失败 {}: {e}", path.display()))?;
Ok(())
}
#[cfg(target_os = "windows")]
fn apply_windows_no_window(command: &mut Command) {
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(target_os = "windows")]
fn detect_webview2_runtime_version() -> Option<String> {
const VALUE_NAME: &str = "pv";
@@ -579,7 +612,9 @@ fn detect_shell_availability() -> Option<String> {
}
}
let pwsh_check = Command::new("pwsh").args(["-v"]).output();
let mut pwsh_check = Command::new("pwsh");
apply_windows_no_window(&mut pwsh_check);
let pwsh_check = pwsh_check.args(["-v"]).output();
if pwsh_check
.map(|output| output.status.success())
.unwrap_or(false)
+23 -5
View File
@@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::{HashSet, VecDeque};
use std::ffi::OsString;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::OnceLock;
@@ -28,6 +30,8 @@ const NODE_MIN_VERSION: (u64, u64, u64) = (22, 0, 0);
const OPENCLAW_PROGRESS_LOG_LIMIT: usize = 400;
const OPENCLAW_INSTALLER_USER_AGENT: &str = "ProxyCast-OpenClaw";
const OPENCLAW_TEMP_CARGO_CHECK_DIR: &str = "/tmp/proxycast-cargo-check";
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -1518,9 +1522,8 @@ fn openclaw_proxycast_config_path() -> PathBuf {
}
fn openclaw_installer_download_dir(app: &AppHandle) -> Result<PathBuf, String> {
let app_data_dir = app
.path()
.app_data_dir()
let _ = app;
let app_data_dir = proxycast_core::app_paths::preferred_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let dir = app_data_dir.join("downloads").join("openclaw-installers");
std::fs::create_dir_all(&dir).map_err(|e| format!("创建 OpenClaw 下载目录失败: {e}"))?;
@@ -2200,6 +2203,8 @@ fn prepend_path(dir: &Path) -> Option<OsString> {
}
fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) {
apply_windows_no_window(command);
let Some(bin_dir) = Path::new(binary_path).parent() else {
return;
};
@@ -2208,9 +2213,18 @@ fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) {
}
}
fn apply_windows_no_window(_command: &mut Command) {
#[cfg(target_os = "windows")]
{
_command.creation_flags(CREATE_NO_WINDOW);
}
}
async fn find_command_in_shell(command_name: &str) -> Result<Option<String>, String> {
if cfg!(target_os = "windows") {
let output = Command::new("cmd")
let mut command = Command::new("cmd");
apply_windows_no_window(&mut command);
let output = command
.arg("/C")
.arg("where")
.arg(command_name)
@@ -2384,7 +2398,9 @@ fn sibling_node_path(command_path: &Path) -> Option<PathBuf> {
}
async fn read_binary_semver(path: &Path) -> Option<(u64, u64, u64)> {
let output = Command::new(path)
let mut command = Command::new(path);
apply_windows_no_window(&mut command);
let output = command
.arg("--version")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -2494,6 +2510,8 @@ fn spawn_shell_command(command_line: &str) -> Result<Child, String> {
cmd
};
apply_windows_no_window(&mut command);
command
.env("NO_COLOR", "1")
.env("CLICOLOR", "0")
+2 -4
View File
@@ -5,7 +5,7 @@
use std::path::PathBuf;
use tauri::{Emitter, Manager};
use tauri::Emitter;
use proxycast_terminal::emitter::TerminalEventEmit;
@@ -23,9 +23,7 @@ impl TerminalEventEmit for TauriEmitter {
}
fn app_data_dir(&self) -> Result<PathBuf, String> {
self.0
.path()
.app_data_dir()
proxycast_core::app_paths::preferred_data_dir()
.map_err(|e| format!("获取应用数据目录失败: {e}"))
}
}
+7 -7
View File
@@ -236,13 +236,13 @@ fn handle_open_log_dir<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求打开日志目录");
// 获取日志目录路径
let log_dir = if let Ok(data_dir) = app.path().app_data_dir() {
data_dir.join("logs")
} else if let Some(home) = dirs::home_dir() {
home.join(".proxycast").join("logs")
} else {
error!("[托盘] 无法确定日志目录路径");
return;
let _ = app;
let log_dir = match proxycast_core::app_paths::resolve_logs_dir() {
Ok(dir) => dir,
Err(error) => {
error!("[托盘] 无法确定日志目录路径: {}", error);
return;
}
};
// 确保目录存在
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.83.1",
"version": "0.83.2",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",