Skip to content

Commit e66fc89

Browse files
authored
Merge pull request #19 from 0xtbug/dev
feat(onboarding): implement guided CLI proxy setup and update flow
2 parents b85fdca + f390ca5 commit e66fc89

12 files changed

Lines changed: 1014 additions & 18 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@tauri-apps/api": "^2",
2222
"@tauri-apps/plugin-dialog": "^2.4.2",
2323
"@tauri-apps/plugin-fs": "^2.4.5",
24+
"@tauri-apps/plugin-os": "^2.3.2",
2425
"@tauri-apps/plugin-process": "^2.3.1",
2526
"@tauri-apps/plugin-shell": "^2.3.3",
2627
"@tauri-apps/plugin-updater": "^2.9.0",
@@ -29,6 +30,7 @@
2930
"clsx": "^2.1.1",
3031
"i18next": "^25.7.1",
3132
"lucide-react": "^0.562.0",
33+
"motion": "^12.34.3",
3234
"radix-ui": "^1.4.3",
3335
"react": "^19.1.0",
3436
"react-dom": "^19.1.0",

src-tauri/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,8 @@ reqwest = { version = "0.12", features = ["json", "multipart"] }
2626
tokio = { version = "1", features = ["full"] }
2727
thiserror = "1"
2828
opener = "0.7"
29+
zip = "8.1.0"
30+
tar = "0.4.44"
31+
flate2 = "1.1.9"
32+
dirs = "6.0.0"
33+
tauri-plugin-os = "2.3.2"

src-tauri/capabilities/default.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111
"updater:default",
1212
"process:allow-restart",
1313
"fs:default",
14-
"fs:allow-read-text-file",
15-
"fs:allow-write-text-file"
14+
{
15+
"identifier": "fs:allow-read-text-file",
16+
"allow": [{ "path": "$HOME/.zerolimit/**" }, { "path": "$HOME/.cli-proxy-api/**" }, { "path": "**" }]
17+
},
18+
{
19+
"identifier": "fs:allow-write-text-file",
20+
"allow": [{ "path": "$HOME/.zerolimit/**" }, { "path": "$HOME/.cli-proxy-api/**" }, { "path": "**" }]
21+
}
1622
]
1723
}

src-tauri/src/commands/download.rs

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
use std::fs::{self, File};
2+
use std::io::{self, Cursor};
3+
use std::path::PathBuf;
4+
use tauri::{command, AppHandle};
5+
use reqwest;
6+
7+
use crate::error::{CommandError, CommandResult};
8+
9+
#[command]
10+
pub async fn download_and_extract_proxy(_app: AppHandle, url: String, target_dir: Option<String>) -> CommandResult<String> {
11+
let proxy_dir = if let Some(ref dir) = target_dir {
12+
PathBuf::from(dir)
13+
} else {
14+
let mut d = dirs::home_dir()
15+
.ok_or_else(|| CommandError::General("Could not determine user home directory".to_string()))?;
16+
d.push(".zerolimit");
17+
d.push("cli_proxy");
18+
d
19+
};
20+
21+
let config_path = proxy_dir.join("config.yaml");
22+
let config_backup = std::env::temp_dir().join("zerolimit_config_backup.yaml");
23+
let had_config = if config_path.exists() {
24+
fs::copy(&config_path, &config_backup)
25+
.map(|_| true)
26+
.unwrap_or_else(|e| {
27+
println!("Warning: Could not back up config.yaml: {}", e);
28+
false
29+
})
30+
} else {
31+
false
32+
};
33+
34+
if target_dir.is_some() {
35+
if let Ok(entries) = fs::read_dir(&proxy_dir) {
36+
for entry in entries.flatten() {
37+
let path = entry.path();
38+
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_lowercase();
39+
if name.contains("cliproxy") || name.contains("cli-proxy") || name == "config.example.yaml" {
40+
let _ = fs::remove_file(&path);
41+
}
42+
}
43+
}
44+
} else {
45+
if proxy_dir.exists() {
46+
if let Err(e) = fs::remove_dir_all(&proxy_dir) {
47+
println!("Warning: Could not clear old proxy directory: {}", e);
48+
}
49+
}
50+
}
51+
fs::create_dir_all(&proxy_dir)
52+
.map_err(|e| CommandError::General(format!("Failed to create proxy dir: {}", e)))?;
53+
54+
println!("Downloading proxy from: {}", url);
55+
56+
let client = reqwest::Client::new();
57+
let response = client.get(&url)
58+
.header("User-Agent", "CLIProxyAPI")
59+
.send()
60+
.await
61+
.map_err(|e| CommandError::General(format!("Failed to fetch URL: {}", e)))?;
62+
63+
if !response.status().is_success() {
64+
return Err(CommandError::General(format!("Received non-success status code: {}", response.status())));
65+
}
66+
67+
let bytes = response.bytes().await
68+
.map_err(|e| CommandError::General(format!("Failed to read response bytes: {}", e)))?;
69+
70+
println!("Downloaded {} bytes. Extracting...", bytes.len());
71+
72+
let is_zip = url.to_lowercase().ends_with(".zip");
73+
let is_tar_gz = url.to_lowercase().ends_with(".tar.gz") || url.to_lowercase().ends_with(".tgz");
74+
75+
if is_zip {
76+
let cursor = Cursor::new(bytes);
77+
let mut archive = zip::ZipArchive::new(cursor)
78+
.map_err(|e| CommandError::General(format!("Failed to read zip archive: {}", e)))?;
79+
80+
for i in 0..archive.len() {
81+
let mut file = archive.by_index(i)
82+
.map_err(|e| CommandError::General(format!("Failed to access zip entry: {}", e)))?;
83+
let outpath = match file.enclosed_name() {
84+
Some(path) => proxy_dir.join(path),
85+
None => continue,
86+
};
87+
88+
if (*file.name()).ends_with('/') {
89+
fs::create_dir_all(&outpath)
90+
.map_err(|e| CommandError::General(format!("Failed to create zip dir: {}", e)))?;
91+
} else {
92+
if let Some(p) = outpath.parent() {
93+
if !p.exists() {
94+
fs::create_dir_all(p)
95+
.map_err(|e| CommandError::General(format!("Failed to create zip parent dir: {}", e)))?;
96+
}
97+
}
98+
let mut outfile = File::create(&outpath)
99+
.map_err(|e| CommandError::General(format!("Failed to create extracted file {:?}: {}", outpath, e)))?;
100+
io::copy(&mut file, &mut outfile)
101+
.map_err(|e| CommandError::General(format!("Failed to write extracted file: {}", e)))?;
102+
}
103+
104+
#[cfg(unix)]
105+
{
106+
use std::os::unix::fs::PermissionsExt;
107+
if let Some(mode) = file.unix_mode() {
108+
let permissions = fs::Permissions::from_mode(mode);
109+
let _ = fs::set_permissions(&outpath, permissions);
110+
} else {
111+
let file_name = outpath.file_name().and_then(|n| n.to_str()).unwrap_or("");
112+
if file_name.contains("CLIProxyAPI") || file_name.contains("cliproxy") {
113+
let permissions = fs::Permissions::from_mode(0o755);
114+
let _ = fs::set_permissions(&outpath, permissions);
115+
}
116+
}
117+
}
118+
}
119+
} else if is_tar_gz {
120+
let cursor = Cursor::new(bytes);
121+
let tar = flate2::read::GzDecoder::new(cursor);
122+
let mut archive = tar::Archive::new(tar);
123+
124+
archive.unpack(&proxy_dir)
125+
.map_err(|e| CommandError::General(format!("Failed to unpack tarball: {}", e)))?;
126+
} else {
127+
return Err(CommandError::General(format!("Unsupported file extension in URL: {}", url)));
128+
}
129+
130+
println!("Extraction complete. Looking for executable...");
131+
132+
if had_config {
133+
let restore_target = proxy_dir.join("config.yaml");
134+
match fs::copy(&config_backup, &restore_target) {
135+
Ok(_) => {
136+
println!("Restored config.yaml from backup.");
137+
let _ = fs::remove_file(&config_backup);
138+
}
139+
Err(e) => println!("Warning: Could not restore config.yaml: {}", e),
140+
}
141+
}
142+
143+
let mut exe_path: Option<PathBuf> = None;
144+
145+
if let Ok(entries) = fs::read_dir(&proxy_dir) {
146+
for entry in entries.flatten() {
147+
let path = entry.path();
148+
if path.is_file() {
149+
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_lowercase();
150+
if file_name.replace("-", "").starts_with("cliproxy") {
151+
#[cfg(windows)]
152+
if file_name.ends_with(".exe") {
153+
exe_path = Some(path.clone());
154+
}
155+
#[cfg(not(windows))]
156+
if !file_name.ends_with(".exe") && !file_name.ends_with(".dll") && !file_name.ends_with(".dylib") {
157+
exe_path = Some(path.clone());
158+
}
159+
} else if file_name == "config.example.yaml" {
160+
let mut new_config_path = path.clone();
161+
new_config_path.set_file_name("config.yaml");
162+
if let Err(e) = fs::rename(&path, &new_config_path) {
163+
println!("Notice: Could not rename config.example.yaml: {}", e);
164+
} else {
165+
println!("Successfully renamed config.example.yaml to config.yaml");
166+
}
167+
}
168+
}
169+
}
170+
}
171+
172+
if exe_path.is_none() {
173+
let mut stack = vec![proxy_dir.clone()];
174+
while let Some(dir) = stack.pop() {
175+
if let Ok(entries) = fs::read_dir(&dir) {
176+
for entry in entries.flatten() {
177+
let path = entry.path();
178+
if path.is_dir() {
179+
stack.push(path);
180+
} else if path.is_file() {
181+
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_lowercase();
182+
if file_name.replace("-", "").starts_with("cliproxy") {
183+
#[cfg(windows)]
184+
if file_name.ends_with(".exe") {
185+
exe_path = Some(path.clone());
186+
}
187+
#[cfg(not(windows))]
188+
if !file_name.ends_with(".exe") && !file_name.ends_with(".dll") && !file_name.ends_with(".dylib") {
189+
exe_path = Some(path.clone());
190+
}
191+
} else if file_name == "config.example.yaml" {
192+
let mut new_config_path = path.clone();
193+
new_config_path.set_file_name("config.yaml");
194+
if let Err(e) = fs::rename(&path, &new_config_path) {
195+
println!("Notice: Could not rename config.example.yaml: {}", e);
196+
} else {
197+
println!("Successfully renamed config.example.yaml to config.yaml");
198+
}
199+
}
200+
}
201+
}
202+
}
203+
if exe_path.is_some() { break; }
204+
}
205+
}
206+
207+
match exe_path {
208+
Some(path) => {
209+
let path_str = path.to_string_lossy().to_string();
210+
println!("Found executable at: {}", path_str);
211+
212+
#[cfg(unix)]
213+
{
214+
use std::os::unix::fs::PermissionsExt;
215+
let permissions = fs::Permissions::from_mode(0o755);
216+
let _ = fs::set_permissions(&path, permissions);
217+
}
218+
219+
Ok(path_str)
220+
},
221+
None => Err(CommandError::General("Could not locate CLIProxyAPI executable after extraction.".to_string()))
222+
}
223+
}

src-tauri/src/commands/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
55
mod cli_proxy;
66
mod utils;
7+
mod download;
8+
mod version;
79

810
pub use cli_proxy::*;
911
pub use utils::*;
12+
pub use download::*;
13+
pub use version::*;

src-tauri/src/commands/version.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use tauri::command;
2+
use reqwest;
3+
use serde::Serialize;
4+
5+
use crate::error::{CommandError, CommandResult};
6+
7+
#[derive(Serialize)]
8+
pub struct ProxyVersionInfo {
9+
pub current_version: Option<String>,
10+
pub build_date: Option<String>,
11+
pub latest_version: Option<String>,
12+
}
13+
14+
#[command]
15+
pub async fn check_proxy_version(api_base: String, management_key: String) -> CommandResult<ProxyVersionInfo> {
16+
let base_url = api_base
17+
.trim_end_matches('/')
18+
.to_string();
19+
20+
let url = format!("{}/v0/management/latest-version", base_url);
21+
// println!("[Rust] Checking proxy version at: {}", url);
22+
23+
let client = reqwest::Client::new();
24+
let response = client.get(&url)
25+
.header("Authorization", format!("Bearer {}", management_key))
26+
.header("Accept", "application/json")
27+
.timeout(std::time::Duration::from_secs(10))
28+
.send()
29+
.await
30+
.map_err(|e| CommandError::General(format!("Request failed: {}", e)))?;
31+
32+
if !response.status().is_success() {
33+
return Err(CommandError::General(format!("API returned status {}", response.status())));
34+
}
35+
36+
let current_version = response.headers()
37+
.get("x-cpa-version")
38+
.or_else(|| response.headers().get("x-server-version"))
39+
.and_then(|v| v.to_str().ok())
40+
.map(|s| s.to_string());
41+
42+
let build_date = response.headers()
43+
.get("x-cpa-build-date")
44+
.or_else(|| response.headers().get("x-server-build-date"))
45+
.and_then(|v| v.to_str().ok())
46+
.map(|s| s.to_string());
47+
48+
let body: serde_json::Value = response.json().await
49+
.map_err(|e| CommandError::General(format!("Failed to parse response: {}", e)))?;
50+
51+
let latest_version = body.get("latest-version")
52+
.or_else(|| body.get("latest_version"))
53+
.or_else(|| body.get("latest"))
54+
.and_then(|v| v.as_str())
55+
.map(|s| s.to_string());
56+
57+
// println!("[Rust] Current: {:?}, Latest: {:?}, Build: {:?}", current_version, latest_version, build_date);
58+
59+
Ok(ProxyVersionInfo {
60+
current_version,
61+
build_date,
62+
latest_version,
63+
})
64+
}

src-tauri/src/lib.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ mod tray;
99

1010
use commands::*;
1111

12-
/// Cleanup function to stop proxy on app exit
1312
fn cleanup_on_exit() {
14-
// Kill any running CLI proxy process
1513
if let Ok(mut guard) = state::CLI_PROXY_PROCESS.lock() {
1614
if let Some(ref mut child) = *guard {
1715
#[cfg(windows)]
@@ -37,10 +35,8 @@ fn cleanup_on_exit() {
3735
*guard = None;
3836
}
3937

40-
// Fallback: kill by name if available (catches detached processes/launchers)
4138
if let Ok(mut name_guard) = state::CLI_PROXY_NAME.lock() {
4239
if let Some(ref name) = *name_guard {
43-
// Safety: Only kill if name looks like our proxy to avoid collateral damage
4440
if name.to_lowercase().contains("cliproxy") {
4541
#[cfg(windows)]
4642
{
@@ -75,6 +71,7 @@ pub fn run() {
7571
.plugin(tauri_plugin_dialog::init())
7672
.plugin(tauri_plugin_fs::init())
7773
.plugin(tauri_plugin_process::init())
74+
.plugin(tauri_plugin_os::init())
7875
.setup(|app| {
7976
#[cfg(desktop)]
8077
{
@@ -98,6 +95,8 @@ pub fn run() {
9895
start_cli_proxy,
9996
stop_cli_proxy,
10097
is_cli_proxy_running,
98+
download_and_extract_proxy,
99+
check_proxy_version,
101100
])
102101
.build(tauri::generate_context!())
103102
.expect("error while building tauri application")

0 commit comments

Comments
 (0)