Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 92 additions & 34 deletions src-tauri/src/commands/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@ use reqwest;

use crate::error::{CommandError, CommandResult};

#[command]
pub fn find_alternate_proxy_exe(current_exe_path: String, target_version: String) -> CommandResult<Option<String>> {
let current = PathBuf::from(&current_exe_path);
let parent = current.parent()
.ok_or_else(|| CommandError::General("Cannot determine parent directory".to_string()))?;

let is_target_plus = target_version == "plus";

if let Ok(entries) = fs::read_dir(parent) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() { continue; }
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_lowercase();
let normalized = name.replace("-", "");

if !normalized.starts_with("cliproxy") { continue; }

#[cfg(windows)]
if !name.ends_with(".exe") { continue; }
#[cfg(not(windows))]
if name.ends_with(".dll") || name.ends_with(".dylib") { continue; }

if path == current { continue; }

let has_plus = name.contains("plus");
if is_target_plus && has_plus {
return Ok(Some(path.to_string_lossy().to_string()));
}
if !is_target_plus && !has_plus {
return Ok(Some(path.to_string_lossy().to_string()));
}
}
}

Ok(None)
}

#[command]
pub async fn download_and_extract_proxy(_app: AppHandle, url: String, target_dir: Option<String>) -> CommandResult<String> {
let proxy_dir = if let Some(ref dir) = target_dir {
Expand All @@ -18,35 +55,25 @@ pub async fn download_and_extract_proxy(_app: AppHandle, url: String, target_dir
d
};

let config_path = proxy_dir.join("config.yaml");
let config_backup = std::env::temp_dir().join("zerolimit_config_backup.yaml");
let had_config = if config_path.exists() {
fs::copy(&config_path, &config_backup)
.map(|_| true)
.unwrap_or_else(|e| {
println!("Warning: Could not back up config.yaml: {}", e);
false
})
} else {
false
let config_extensions = ["yaml", "yml", "json", "toml", "env"];
let is_config_file = |name: &str| -> bool {
let lower = name.to_lowercase();
config_extensions.iter().any(|ext| lower.ends_with(ext))
};

if target_dir.is_some() {
if proxy_dir.exists() {
if let Ok(entries) = fs::read_dir(&proxy_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() { continue; }
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_lowercase();
if name.contains("cliproxy") || name.contains("cli-proxy") || name == "config.example.yaml" {
if (name.contains("cliproxy") || name.contains("cli-proxy"))
&& !is_config_file(&name)
{
let _ = fs::remove_file(&path);
}
}
}
} else {
if proxy_dir.exists() {
if let Err(e) = fs::remove_dir_all(&proxy_dir) {
println!("Warning: Could not clear old proxy directory: {}", e);
}
}
}
fs::create_dir_all(&proxy_dir)
.map_err(|e| CommandError::General(format!("Failed to create proxy dir: {}", e)))?;
Expand Down Expand Up @@ -89,6 +116,12 @@ pub async fn download_and_extract_proxy(_app: AppHandle, url: String, target_dir
fs::create_dir_all(&outpath)
.map_err(|e| CommandError::General(format!("Failed to create zip dir: {}", e)))?;
} else {
let file_name = outpath.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if is_config_file(file_name) && outpath.exists() {
println!("Skipping existing config file: {:?}", outpath);
continue;
}

if let Some(p) = outpath.parent() {
if !p.exists() {
fs::create_dir_all(p)
Expand Down Expand Up @@ -117,29 +150,50 @@ pub async fn download_and_extract_proxy(_app: AppHandle, url: String, target_dir
}
}
} else if is_tar_gz {
let temp_extract = std::env::temp_dir().join("zerolimit_proxy_extract");
if temp_extract.exists() {
let _ = fs::remove_dir_all(&temp_extract);
}
fs::create_dir_all(&temp_extract)
.map_err(|e| CommandError::General(format!("Failed to create temp extract dir: {}", e)))?;

let cursor = Cursor::new(bytes);
let tar = flate2::read::GzDecoder::new(cursor);
let mut archive = tar::Archive::new(tar);

archive.unpack(&proxy_dir)
archive.unpack(&temp_extract)
.map_err(|e| CommandError::General(format!("Failed to unpack tarball: {}", e)))?;
} else {
return Err(CommandError::General(format!("Unsupported file extension in URL: {}", url)));
}

println!("Extraction complete. Looking for executable...");
fn copy_dir_selective(src: &std::path::Path, dst: &std::path::Path, is_config: &dyn Fn(&str) -> bool) -> io::Result<()> {
if !dst.exists() {
fs::create_dir_all(dst)?;
}
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let file_name = src_path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
let dst_path = dst.join(file_name);

if had_config {
let restore_target = proxy_dir.join("config.yaml");
match fs::copy(&config_backup, &restore_target) {
Ok(_) => {
println!("Restored config.yaml from backup.");
let _ = fs::remove_file(&config_backup);
if src_path.is_dir() {
copy_dir_selective(&src_path, &dst_path, is_config)?;
} else {
if is_config(file_name) && dst_path.exists() {
println!("Skipping existing config file: {:?}", dst_path);
continue;
}
fs::copy(&src_path, &dst_path)?;
}
}
Err(e) => println!("Warning: Could not restore config.yaml: {}", e),
Ok(())
}
copy_dir_selective(&temp_extract, &proxy_dir, &is_config_file)
.map_err(|e| CommandError::General(format!("Failed to copy extracted files: {}", e)))?;
let _ = fs::remove_dir_all(&temp_extract);
} else {
return Err(CommandError::General(format!("Unsupported file extension in URL: {}", url)));
}

println!("Extraction complete. Looking for executable...");

let mut exe_path: Option<PathBuf> = None;

if let Ok(entries) = fs::read_dir(&proxy_dir) {
Expand All @@ -159,7 +213,9 @@ pub async fn download_and_extract_proxy(_app: AppHandle, url: String, target_dir
} else if file_name == "config.example.yaml" {
let mut new_config_path = path.clone();
new_config_path.set_file_name("config.yaml");
if let Err(e) = fs::rename(&path, &new_config_path) {
if new_config_path.exists() {
println!("Skipping config.example.yaml rename: config.yaml already exists");
} else if let Err(e) = fs::rename(&path, &new_config_path) {
println!("Notice: Could not rename config.example.yaml: {}", e);
} else {
println!("Successfully renamed config.example.yaml to config.yaml");
Expand Down Expand Up @@ -191,7 +247,9 @@ pub async fn download_and_extract_proxy(_app: AppHandle, url: String, target_dir
} else if file_name == "config.example.yaml" {
let mut new_config_path = path.clone();
new_config_path.set_file_name("config.yaml");
if let Err(e) = fs::rename(&path, &new_config_path) {
if new_config_path.exists() {
println!("Skipping config.example.yaml rename: config.yaml already exists");
} else if let Err(e) = fs::rename(&path, &new_config_path) {
println!("Notice: Could not rename config.example.yaml: {}", e);
} else {
println!("Successfully renamed config.example.yaml to config.yaml");
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ pub fn run() {
stop_cli_proxy,
is_cli_proxy_running,
download_and_extract_proxy,
find_alternate_proxy_exe,
check_proxy_version,
])
.build(tauri::generate_context!())
Expand Down
41 changes: 39 additions & 2 deletions src/features/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useConfigStore } from '@/features/settings/config.store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Button } from '@/shared/components/ui/button';
import { Label } from '@/shared/components/ui/label';
import { Sun, Moon, Monitor, LogOut, Globe, Server, FolderOpen, Play, Square, CheckCircle2, BarChart3, Loader2, FileText, RefreshCw, Download } from 'lucide-react';
import { Sun, Moon, Monitor, LogOut, Globe, Server, FolderOpen, Play, Square, CheckCircle2, BarChart3, Loader2, FileText, RefreshCw, Download, ArrowLeftRight } from 'lucide-react';
import { toast } from 'sonner';

export function SettingsPage() {
Expand All @@ -25,7 +25,7 @@ export function SettingsPage() {
exePath, autoStart, runInBackground, isServerRunning,
setAutoStart, setRunInBackground, browseForExe, startServer, stopServer,
cliProxyLatestVersion, latestRemoteVersion, updateAvailable,
isCheckingUpdate, isUpdating, checkForProxyUpdate, updateProxy,
isCheckingUpdate, isUpdating, isSwitchingVersion, checkForProxyUpdate, updateProxy, switchVersion,
currentInstalledVersion, serverBuildDate,
} = useCliProxyStore();
const { config, fetchConfig, updateUsageStatistics, updatingUsageStats, updateLoggingToFile, updatingLogging } = useConfigStore();
Expand Down Expand Up @@ -258,6 +258,43 @@ export function SettingsPage() {
)}
</div>
</div>

{/* Version Switch */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="space-y-0.5">
<Label>Switch Version</Label>
<p className="text-xs text-muted-foreground">
{exePath?.toLowerCase().includes('plus')
? 'Currently using Plus version'
: 'Currently using Standard version'
}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const targetLabel = exePath?.toLowerCase().includes('plus') ? 'Standard' : 'Plus';
toast.info(`Switching to ${targetLabel} version...`);
await switchVersion();
toast.success(`Switched to ${targetLabel} version!`);
} catch (err: any) {
toast.error(`Switch failed: ${err?.message || 'Unknown error'}`);
}
}}
disabled={isSwitchingVersion || isUpdating}
>
{isSwitchingVersion ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Switching...</>
) : (
<>
<ArrowLeftRight className="mr-2 h-4 w-4" />
{exePath?.toLowerCase().includes('plus') ? 'Switch to Standard' : 'Switch to Plus'}
</>
)}
</Button>
</div>
</div>
)}
</CardContent>
Expand Down
84 changes: 84 additions & 0 deletions src/features/settings/cliProxy.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface CliProxyState {
serverBuildDate: string | null;
isCheckingUpdate: boolean;
isUpdating: boolean;
isSwitchingVersion: boolean;
updateAvailable: boolean;
latestRemoteVersion: string | null;
setExePath: (path: string | null) => void;
Expand All @@ -58,6 +59,7 @@ interface CliProxyState {
checkApiHealth: (apiBase?: string) => Promise<boolean>;
checkForProxyUpdate: () => Promise<boolean>;
updateProxy: () => Promise<boolean>;
switchVersion: () => Promise<boolean>;
}

export const useCliProxyStore = create<CliProxyState>()(
Expand All @@ -77,6 +79,7 @@ export const useCliProxyStore = create<CliProxyState>()(
serverBuildDate: null,
isCheckingUpdate: false,
isUpdating: false,
isSwitchingVersion: false,
updateAvailable: false,
latestRemoteVersion: null,

Expand Down Expand Up @@ -297,6 +300,87 @@ export const useCliProxyStore = create<CliProxyState>()(
set({ isUpdating: false });
}
},

switchVersion: async () => {
const { exePath, isServerRunning } = get();
if (!exePath) throw new Error('No executable path configured.');

set({ isSwitchingVersion: true });
try {
const currentIsPlus = exePath.toLowerCase().includes('plus');
const targetVersion = currentIsPlus ? 'standard' : 'plus';

if (isServerRunning) {
await get().stopServer();
}

// Force-kill any remaining CLI proxy process by exe name
const exeName = exePath.split(/[\\/]/).pop() || '';
if (exeName) {
try {
const { Command } = await import('@tauri-apps/plugin-shell');
await Command.create('taskkill', ['/F', '/T', '/IM', exeName]).execute();
} catch { /* ignore if already dead */ }
}
await new Promise(r => setTimeout(r, 2000));
const existingExe = await invoke<string | null>('find_alternate_proxy_exe', {
currentExePath: exePath,
targetVersion,
});

if (existingExe) {
set({
exePath: existingExe,
cliProxyVersion: targetVersion === 'plus' ? 'plus' : 'standard',
});
await get().startServer();
return true;
}

const repo = targetVersion === 'plus' ? 'CLIProxyAPIPlus' : 'CLIProxyAPI';
const res = await fetch(`https://api.github.com/repos/router-for-me/${repo}/releases/latest`, {
headers: { 'User-Agent': 'CLIProxyAPI' },
});
if (!res.ok) throw new Error('Failed to fetch release from GitHub');
const data = await res.json();

const { type } = await import('@tauri-apps/plugin-os');
const osType = await type();
let osName = 'windows';
if (osType === 'macos') osName = 'darwin';
if (osType === 'linux') osName = 'linux';
const searchString = `${osName}_`;
const asset = data.assets?.find((a: any) =>
a.name.toLowerCase().includes(searchString) &&
(a.name.endsWith('.zip') || a.name.endsWith('.tar.gz') || a.name.endsWith('.tgz'))
);
if (!asset?.browser_download_url) throw new Error('No compatible asset found for your OS');

const sep = exePath.includes('\\') ? '\\' : '/';
const lastSep = exePath.lastIndexOf(sep);
const targetDir = lastSep > 0 ? exePath.substring(0, lastSep) : null;

const newExePath = await invoke<string>('download_and_extract_proxy', {
url: asset.browser_download_url,
targetDir,
});

if (newExePath) {
set({
exePath: newExePath,
cliProxyVersion: targetVersion === 'plus' ? 'plus' : 'standard',
});
}

await get().startServer();
return true;
} catch (err) {
console.error('Version switch failed:', err);
throw err;
} finally {
set({ isSwitchingVersion: false });
}
},
}),
{
name: STORAGE_KEY_CLI_PROXY,
Expand Down