Skip to content

Commit f390ca5

Browse files
committed
feat(onboarding): implement guided CLI proxy setup and update flow
Introduce a multi-step onboarding process for CLI proxy installation. Add automatic download, extraction, and configuration of proxy binaries. Implement in-app version checking and update functionality for the proxy. Extend Tauri backend with new commands for proxy management. Update file system capabilities for proxy installation and config. Integrate `motion` for UI animations and `tauri-plugin-os` for OS info. Enhance CLI proxy store to manage onboarding state, versions, and updates. Display proxy version and update controls in the settings page. Fix JSON syntax in Indonesian locale file. This streamlines the first-time user experience for CLI proxy setup and simplifies maintenance with automated updates. It ensures users run the latest proxy version, provides necessary permissions for file operations, and improves UI aesthetics.
1 parent 1a28d36 commit f390ca5

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)