981 lines
32 KiB
Rust
981 lines
32 KiB
Rust
use machine_uid;
|
|
use sentry;
|
|
use std::fs::{self, File};
|
|
use std::path::Path;
|
|
use sysinfo::System;
|
|
use tauri::{AppHandle, Emitter, Manager};
|
|
use tauri_plugin_sentry;
|
|
use tinyfiledialogs as tfd;
|
|
use zip::ZipArchive;
|
|
|
|
#[path = "../common/shortcut.rs"]
|
|
mod shortcut;
|
|
|
|
#[path = "../common/window.rs"]
|
|
mod window;
|
|
|
|
use shortcut::{
|
|
ensure_toolbar_window, set_toolbar_shortcut, show_toolbar_window, DEFAULT_TOOLBAR_SHORTCUT,
|
|
};
|
|
|
|
use window::{lock_windows, open_full_window, open_small_window, open_window, unlock_windows};
|
|
|
|
#[tauri::command]
|
|
fn update_toolbar_shortcut(app_handle: AppHandle, shortcut: Option<String>) -> Result<(), String> {
|
|
set_toolbar_shortcut(&app_handle, shortcut.as_deref())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn show_toolbar(app_handle: AppHandle) -> Result<(), String> {
|
|
show_toolbar_window(&app_handle).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn is_flatpak() -> bool {
|
|
std::path::Path::new("/.flatpak-info").exists() || std::env::var_os("FLATPAK_ID").is_some()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn is_linux_mint() -> bool {
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
false
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let paths: &[&str] = if is_flatpak() {
|
|
&["/run/host/os-release", "/etc/os-release", "/usr/lib/os-release"]
|
|
} else {
|
|
&["/etc/os-release", "/usr/lib/os-release"]
|
|
};
|
|
|
|
for path in paths {
|
|
if let Ok(contents) = std::fs::read_to_string(path) {
|
|
return contents.lines().any(|line| {
|
|
let line = line.trim();
|
|
line == "ID=linuxmint" || line == "ID=\"linuxmint\""
|
|
});
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn has_flatpak_permissions() -> bool {
|
|
// Native app: no Flatpak permission needed.
|
|
if !is_flatpak() {
|
|
return true;
|
|
}
|
|
|
|
let Ok(contents) = std::fs::read_to_string("/.flatpak-info") else {
|
|
return false;
|
|
};
|
|
|
|
let mut in_context = false;
|
|
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
|
|
if line.starts_with('[') && line.ends_with(']') {
|
|
in_context = line == "[Context]";
|
|
continue;
|
|
}
|
|
|
|
if !in_context {
|
|
continue;
|
|
}
|
|
|
|
let Some((key, value)) = line.split_once('=') else {
|
|
continue;
|
|
};
|
|
|
|
if key.trim() != "filesystems" {
|
|
continue;
|
|
}
|
|
|
|
for raw in value.split(';') {
|
|
let item = raw.trim();
|
|
|
|
if item.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Explicit negative override, e.g. !host
|
|
if item == "!host" || item.starts_with("!host:") {
|
|
return false;
|
|
}
|
|
|
|
// Writable host access
|
|
if item == "host" || item == "host:rw" || item == "host:create" {
|
|
return true;
|
|
}
|
|
|
|
// Read-only host access is not enough for full rclone filesystem usage
|
|
if item == "host:ro" {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn unzip_file(zip_path: &str, output_folder: &str) -> Result<(), String> {
|
|
// Open the zip file
|
|
let file = File::open(zip_path).map_err(|e| e.to_string())?;
|
|
|
|
// Create output directory if it doesn't exist
|
|
fs::create_dir_all(output_folder).map_err(|e| e.to_string())?;
|
|
|
|
// Create ZIP archive reader
|
|
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
|
|
|
|
// Extract everything
|
|
for i in 0..archive.len() {
|
|
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
|
|
let outpath = Path::new(output_folder).join(file.name());
|
|
|
|
if file.name().ends_with('/') || file.name().ends_with('\\') {
|
|
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
|
} else {
|
|
if let Some(p) = outpath.parent() {
|
|
fs::create_dir_all(p).map_err(|e| e.to_string())?;
|
|
}
|
|
let mut outfile = File::create(&outpath).map_err(|e| e.to_string())?;
|
|
std::io::copy(&mut file, &mut outfile).map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
// Get and set permissions (Unix only)
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if let Some(mode) = file.unix_mode() {
|
|
fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn stop_pid(pid: u32, timeout_ms: Option<u64>) -> Result<(), String> {
|
|
let timeout = timeout_ms.unwrap_or(5000);
|
|
|
|
#[cfg(any(
|
|
target_os = "macos",
|
|
target_os = "linux",
|
|
target_os = "freebsd",
|
|
target_os = "openbsd",
|
|
target_os = "netbsd"
|
|
))]
|
|
{
|
|
use std::time::{Duration, Instant};
|
|
|
|
let pid_str = pid.to_string();
|
|
|
|
// Try graceful termination first
|
|
let _ = std::process::Command::new("kill")
|
|
.args(&["-TERM", &pid_str])
|
|
.status();
|
|
|
|
let deadline = Instant::now() + Duration::from_millis(timeout);
|
|
while Instant::now() < deadline {
|
|
// Check if process still exists: kill -0 <pid>
|
|
let alive = std::process::Command::new("kill")
|
|
.args(&["-0", &pid_str])
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false);
|
|
if !alive {
|
|
return Ok(());
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
|
|
// Force kill
|
|
let _ = std::process::Command::new("kill")
|
|
.args(&["-KILL", &pid_str])
|
|
.status();
|
|
|
|
// Final check (best effort)
|
|
let alive = std::process::Command::new("kill")
|
|
.args(&["-0", &pid_str])
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false);
|
|
if alive {
|
|
return Err("Failed to terminate process".to_string());
|
|
}
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
use std::time::{Duration, Instant};
|
|
|
|
let pid_str = pid.to_string();
|
|
|
|
let _ = std::process::Command::new("taskkill")
|
|
.args(&["/PID", &pid_str, "/F", "/T"])
|
|
.status();
|
|
|
|
let output = std::process::Command::new("tasklist")
|
|
.args(&["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
|
.output()
|
|
.map_err(|e| e.to_string())?;
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
if !stdout.trim().is_empty()
|
|
&& stdout.contains(&pid_str)
|
|
&& !stdout.contains("No tasks are running")
|
|
{
|
|
return Err("Failed to terminate process".to_string());
|
|
}
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
#[cfg(not(any(
|
|
target_os = "macos",
|
|
target_os = "linux",
|
|
target_os = "freebsd",
|
|
target_os = "openbsd",
|
|
target_os = "netbsd",
|
|
target_os = "windows"
|
|
)))]
|
|
{
|
|
Err("Unsupported platform".to_string())
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_arch() -> String {
|
|
let arch = std::env::consts::ARCH;
|
|
|
|
match arch {
|
|
"aarch64" => "arm64".to_string(),
|
|
"x86_64" => "amd64".to_string(),
|
|
"i386" => "386".to_string(),
|
|
_ => "unknown".to_string(),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_uid() -> String {
|
|
return machine_uid::get().unwrap();
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn is_rclone_running(port: Option<u16>) -> bool {
|
|
if let Some(port) = port {
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream};
|
|
use std::time::Duration;
|
|
|
|
let timeout = Duration::from_millis(200);
|
|
let addrs = [
|
|
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
|
|
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), port),
|
|
];
|
|
|
|
for addr in addrs.iter() {
|
|
if let Ok(stream) = TcpStream::connect_timeout(addr, timeout) {
|
|
drop(stream);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
let system = System::new_all();
|
|
for (_pid, process) in system.processes() {
|
|
let name = process.name();
|
|
let lower = name.to_ascii_lowercase();
|
|
if lower == "rclone" || lower == "rclone.exe" {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn stop_rclone_processes(timeout_ms: Option<u64>) -> Result<u32, String> {
|
|
let timeout = timeout_ms.unwrap_or(5000);
|
|
|
|
let system = System::new_all();
|
|
|
|
// Collect PIDs first to avoid holding references across await points
|
|
let mut pids: Vec<u32> = Vec::new();
|
|
for (pid, process) in system.processes() {
|
|
let name_lower = process.name().to_ascii_lowercase();
|
|
if name_lower == "rclone" || name_lower == "rclone.exe" {
|
|
pids.push(pid.as_u32());
|
|
}
|
|
}
|
|
|
|
let mut stopped: u32 = 0;
|
|
for pid in pids {
|
|
match stop_pid(pid, Some(timeout)).await {
|
|
Ok(()) => stopped += 1,
|
|
Err(_e) => {}
|
|
}
|
|
}
|
|
|
|
Ok(stopped)
|
|
}
|
|
|
|
async fn prompt_password(title: String, message: String) -> Result<Option<String>, String> {
|
|
prompt_text(title, message, None, Some(true)).await
|
|
}
|
|
|
|
async fn prompt_text(
|
|
title: String,
|
|
message: String,
|
|
default: Option<String>,
|
|
sensitive: Option<bool>,
|
|
) -> Result<Option<String>, String> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use std::process::Command;
|
|
|
|
let default_value = default.unwrap_or_default();
|
|
let is_sensitive = sensitive.unwrap_or(false);
|
|
let script = if is_sensitive {
|
|
format!(
|
|
r#"display dialog "{}" with title "{}" default answer "{}" with hidden answer"#,
|
|
message.replace("\"", "\\\""),
|
|
title.replace("\"", "\\\""),
|
|
default_value.replace("\"", "\\\""),
|
|
)
|
|
} else {
|
|
format!(
|
|
r#"display dialog "{}" with title "{}" default answer "{}""#,
|
|
message.replace("\"", "\\\""),
|
|
title.replace("\"", "\\\""),
|
|
default_value.replace("\"", "\\\""),
|
|
)
|
|
};
|
|
|
|
let output = Command::new("osascript")
|
|
.arg("-e")
|
|
.arg(&script)
|
|
.output()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let pid = std::process::id();
|
|
let _ = Command::new("osascript")
|
|
.arg("-e")
|
|
.arg(format!(
|
|
"tell application \"System Events\" to set frontmost of (first process whose unix id is {}) to true",
|
|
pid
|
|
))
|
|
.output();
|
|
|
|
if output.status.success() {
|
|
let result = String::from_utf8_lossy(&output.stdout);
|
|
// Parse AppleScript result: "text returned:VALUE, button returned:OK"
|
|
if let Some(text_part) = result.split("text returned:").nth(1) {
|
|
if let Some(value) = text_part.split(", button returned:").next() {
|
|
return Ok(Some(value.trim().to_string()));
|
|
}
|
|
}
|
|
}
|
|
|
|
return Ok(None);
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
use std::process::Command;
|
|
|
|
let default_value = default.unwrap_or_default();
|
|
let is_sensitive = sensitive.unwrap_or(false);
|
|
|
|
// Use PowerShell to create a simple text input dialog
|
|
let ps_default = default_value.replace('\'', "''");
|
|
let ps_title = title.replace('\'', "''");
|
|
let ps_message = message.replace('\'', "''");
|
|
let ps_password_flag = if is_sensitive { "$true" } else { "$false" };
|
|
|
|
let powershell_script = format!(
|
|
r#"
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
Add-Type -AssemblyName System.Drawing
|
|
|
|
$form = New-Object System.Windows.Forms.Form
|
|
$form.Text = '{title}'
|
|
$form.Size = New-Object System.Drawing.Size(350, 180)
|
|
$form.StartPosition = 'CenterScreen'
|
|
$form.FormBorderStyle = 'FixedDialog'
|
|
$form.MaximizeBox = $false
|
|
$form.MinimizeBox = $false
|
|
$form.TopMost = $true
|
|
|
|
$label = New-Object System.Windows.Forms.Label
|
|
$label.Location = New-Object System.Drawing.Point(10, 15)
|
|
$label.Size = New-Object System.Drawing.Size(320, 40)
|
|
$label.Text = '{message}'
|
|
$form.Controls.Add($label)
|
|
|
|
$textBox = New-Object System.Windows.Forms.TextBox
|
|
$textBox.Location = New-Object System.Drawing.Point(10, 60)
|
|
$textBox.Size = New-Object System.Drawing.Size(320, 20)
|
|
$textBox.Text = '{default}'
|
|
$textBox.UseSystemPasswordChar = {password}
|
|
$form.Controls.Add($textBox)
|
|
|
|
$okButton = New-Object System.Windows.Forms.Button
|
|
$okButton.Location = New-Object System.Drawing.Point(175, 100)
|
|
$okButton.Size = New-Object System.Drawing.Size(75, 23)
|
|
$okButton.Text = 'OK'
|
|
$okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
|
$form.AcceptButton = $okButton
|
|
$form.Controls.Add($okButton)
|
|
|
|
$cancelButton = New-Object System.Windows.Forms.Button
|
|
$cancelButton.Location = New-Object System.Drawing.Point(255, 100)
|
|
$cancelButton.Size = New-Object System.Drawing.Size(75, 23)
|
|
$cancelButton.Text = 'Cancel'
|
|
$cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
|
$form.CancelButton = $cancelButton
|
|
$form.Controls.Add($cancelButton)
|
|
|
|
$form.Add_Shown({{$textBox.Select()}})
|
|
$result = $form.ShowDialog()
|
|
|
|
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {{
|
|
$textBox.Text
|
|
}}
|
|
"#,
|
|
title = ps_title,
|
|
message = ps_message,
|
|
default = ps_default,
|
|
password = ps_password_flag
|
|
);
|
|
|
|
let output = Command::new("powershell")
|
|
.args(&[
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-Command",
|
|
&powershell_script,
|
|
])
|
|
.output()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
if output.status.success() {
|
|
let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !result.is_empty() || !default_value.is_empty() {
|
|
return Ok(Some(result));
|
|
}
|
|
}
|
|
|
|
return Ok(None);
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
{
|
|
Err("Text input not supported on this platform".to_string())
|
|
}
|
|
}
|
|
|
|
async fn tiny_prompt_text(
|
|
title: String,
|
|
message: String,
|
|
default: Option<String>,
|
|
sensitive: Option<bool>,
|
|
) -> Result<Option<String>, String> {
|
|
let is_sensitive = sensitive.unwrap_or(false);
|
|
let default_value = default.unwrap_or_default();
|
|
let title_clone = title.clone();
|
|
let message_clone = message.clone();
|
|
|
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
|
if is_sensitive {
|
|
tfd::password_box(&title_clone, &message_clone)
|
|
} else {
|
|
tfd::input_box(&title_clone, &message_clone, &default_value)
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn prompt(
|
|
title: String,
|
|
message: String,
|
|
default: Option<String>,
|
|
sensitive: Option<bool>,
|
|
) -> Result<Option<String>, String> {
|
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
{
|
|
prompt_text(title, message, default, sensitive).await
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
{
|
|
tiny_prompt_text(title, message, default, sensitive).await
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn start_cloudflared_tunnel(app: tauri::AppHandle) -> Result<(u32, String), String> {
|
|
use std::io::{BufRead, BufReader};
|
|
use std::process::{Command as SysCommand, Stdio};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
// Get the binary path
|
|
let app_local_data_dir = app
|
|
.path()
|
|
.app_local_data_dir()
|
|
.map_err(|e| format!("Failed to get app local data directory: {}", e))?;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
let binary_name = "cloudflared.exe";
|
|
#[cfg(not(target_os = "windows"))]
|
|
let binary_name = "cloudflared";
|
|
|
|
let cloudflared_path = app_local_data_dir.join(binary_name);
|
|
|
|
if !cloudflared_path.exists() {
|
|
return Err("Cloudflared binary not found".to_string());
|
|
}
|
|
|
|
// Start cloudflared tunnel
|
|
let mut child = SysCommand::new(&cloudflared_path)
|
|
.args(&["tunnel", "--url", "http://localhost:5572"])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to start cloudflared: {}", e))?;
|
|
|
|
let pid = child.id();
|
|
let tunnel_url = Arc::new(Mutex::new(String::new()));
|
|
let tunnel_url_clone = Arc::clone(&tunnel_url);
|
|
|
|
// Read stdout to extract tunnel URL
|
|
if let Some(stderr) = child.stderr.take() {
|
|
thread::spawn(move || {
|
|
let reader = BufReader::new(stderr);
|
|
for line in reader.lines().flatten() {
|
|
if line.contains("trycloudflare.com") {
|
|
// Extract the URL from the line
|
|
if let Some(start) = line.find("https://") {
|
|
if let Some(end) = line[start..].find(char::is_whitespace) {
|
|
let url = &line[start..start + end];
|
|
let mut tunnel_url = tunnel_url_clone.lock().unwrap();
|
|
*tunnel_url = url.to_string();
|
|
} else {
|
|
let url = &line[start..];
|
|
let mut tunnel_url = tunnel_url_clone.lock().unwrap();
|
|
*tunnel_url = url.to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Wait for tunnel URL (max 15 seconds)
|
|
for _ in 0..150 {
|
|
thread::sleep(Duration::from_millis(100));
|
|
let url = tunnel_url.lock().unwrap();
|
|
if !url.is_empty() {
|
|
return Ok((pid, url.clone()));
|
|
}
|
|
}
|
|
|
|
// If we didn't get a URL, kill the process and return error
|
|
let _ = stop_pid(pid, Some(2000)).await;
|
|
Err("Failed to get tunnel URL from cloudflared".to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn stop_cloudflared_tunnel(pid: u32) -> Result<(), String> {
|
|
use std::time::Duration;
|
|
|
|
// Cloudflared takes ~5s to gracefully shut down, so give it enough time
|
|
match stop_pid(pid, Some(6000)).await {
|
|
Ok(()) => Ok(()),
|
|
Err(e) => {
|
|
// Wait a bit for the process to fully terminate
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
|
|
// Even if we get an error, the process might have stopped
|
|
// Check one more time if the process is actually gone
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
{
|
|
let alive = std::process::Command::new("kill")
|
|
.args(&["-0", &pid.to_string()])
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false);
|
|
|
|
if !alive {
|
|
// Process is gone, consider it a success
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let output = std::process::Command::new("tasklist")
|
|
.args(&["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
|
.output();
|
|
|
|
if let Ok(output) = output {
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
if stdout.trim().is_empty()
|
|
|| stdout.contains("No tasks are running")
|
|
|| !stdout.contains(&pid.to_string())
|
|
{
|
|
// Process is gone, consider it a success
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
// As a last resort, check if a process with this PID is still a cloudflared process
|
|
let system = System::new_all();
|
|
let mut cloudflared_still_running = false;
|
|
for (p, process) in system.processes() {
|
|
if p.as_u32() == pid {
|
|
let name = process.name().to_string_lossy().to_lowercase();
|
|
if name.contains("cloudflared") {
|
|
cloudflared_still_running = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if !cloudflared_still_running {
|
|
// PID either gone or reused by another process; treat as successfully stopped
|
|
return Ok(());
|
|
}
|
|
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn extract_tgz(tgz_path: &str, output_folder: &str) -> Result<(), String> {
|
|
use flate2::read::GzDecoder;
|
|
use std::fs::File;
|
|
use tar::Archive;
|
|
|
|
let file = File::open(tgz_path).map_err(|e| e.to_string())?;
|
|
let tar = GzDecoder::new(file);
|
|
let mut archive = Archive::new(tar);
|
|
|
|
fs::create_dir_all(output_folder).map_err(|e| e.to_string())?;
|
|
|
|
archive.set_preserve_permissions(true);
|
|
archive.unpack(output_folder).map_err(|e| e.to_string())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn test_proxy_connection(proxy_url: String) -> Result<String, String> {
|
|
use std::time::Duration;
|
|
|
|
// Validate
|
|
let proxy_url = proxy_url.trim();
|
|
if proxy_url.is_empty() {
|
|
return Err("Proxy URL cannot be empty".to_string());
|
|
}
|
|
|
|
// Build client with proxy
|
|
let proxy = reqwest::Proxy::all(proxy_url).map_err(|e| format!("Invalid proxy URL: {}", e))?;
|
|
let client = reqwest::Client::builder()
|
|
.proxy(proxy)
|
|
.timeout(Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
|
|
|
// Multiple fallback endpoints
|
|
let candidates = [
|
|
"https://httpbin.org/ip",
|
|
"https://www.cloudflare.com/cdn-cgi/trace",
|
|
"https://ifconfig.me/ip",
|
|
"https://1.1.1.1/cdn-cgi/trace",
|
|
];
|
|
|
|
let mut last_error: Option<String> = None;
|
|
for url in candidates.iter() {
|
|
match client.get(*url).send().await {
|
|
Ok(resp) => {
|
|
if resp.status().is_success() {
|
|
match resp.text().await {
|
|
Ok(body) => {
|
|
return Ok(format!(
|
|
"Connected via proxy. Endpoint: {}. Snippet: {}",
|
|
url,
|
|
body.chars().take(200).collect::<String>()
|
|
))
|
|
}
|
|
Err(e) => {
|
|
last_error =
|
|
Some(format!("Failed to read response from {}: {}", url, e));
|
|
continue;
|
|
}
|
|
}
|
|
} else {
|
|
last_error = Some(format!("{} responded with status {}", url, resp.status()));
|
|
continue;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
last_error = Some(format!("Request to {} failed: {}", url, e));
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(last_error.unwrap_or_else(|| "All proxy tests failed".to_string()))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn update_system_rclone() -> Result<i32, String> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use std::process::Command as SysCommand;
|
|
|
|
fn quote_posix(value: &str) -> String {
|
|
let escaped = value.replace("'", "'\\''");
|
|
format!("'{}'", escaped)
|
|
}
|
|
|
|
let mut cmdline =
|
|
String::from("PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH; ");
|
|
cmdline.push_str("e_posix("rclone"));
|
|
cmdline.push(' ');
|
|
cmdline.push_str("e_posix("selfupdate"));
|
|
|
|
// Escape for embedding inside an AppleScript string literal
|
|
let applescript_cmd = cmdline.replace('\\', "\\\\").replace('"', "\\\"");
|
|
let prompt = "Rclone UI needs permission to run rclone selfupdate.";
|
|
let script = format!(
|
|
"do shell script \"{}\" with administrator privileges with prompt \"{}\"",
|
|
applescript_cmd,
|
|
prompt.replace('"', "\\\"")
|
|
);
|
|
|
|
let status = SysCommand::new("osascript")
|
|
.arg("-e")
|
|
.arg(script)
|
|
.status()
|
|
.map_err(|e| e.to_string())?;
|
|
return Ok(status.code().unwrap_or(0));
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
use std::process::Command as SysCommand;
|
|
|
|
let path_env =
|
|
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin";
|
|
|
|
// Try PolicyKit first (graphical auth prompt on most desktops)
|
|
let mut pkexec_args: Vec<String> = Vec::new();
|
|
pkexec_args.push("--description".to_string());
|
|
pkexec_args.push("Rclone UI needs to run rclone selfupdate".to_string());
|
|
pkexec_args.push("env".to_string());
|
|
pkexec_args.push(path_env.to_string());
|
|
pkexec_args.push("rclone".to_string());
|
|
pkexec_args.push("selfupdate".to_string());
|
|
|
|
match SysCommand::new("pkexec").args(&pkexec_args).status() {
|
|
Ok(status) => return Ok(status.code().unwrap_or(0)),
|
|
Err(_e) => {
|
|
// Fallback to sudo with custom prompt (works if the user has NOPASSWD or cached credentials)
|
|
let mut sudo_env = std::collections::HashMap::new();
|
|
sudo_env.insert("SUDO_PROMPT", "Rclone UI needs permission to run rclone selfupdate. Please enter your password: ");
|
|
|
|
let mut sudo_args: Vec<String> = Vec::new();
|
|
sudo_args.push("-n".to_string());
|
|
sudo_args.push("env".to_string());
|
|
sudo_args.push(path_env.to_string());
|
|
sudo_args.push("rclone".to_string());
|
|
sudo_args.push("selfupdate".to_string());
|
|
|
|
let status = SysCommand::new("sudo")
|
|
.envs(&sudo_env)
|
|
.args(&sudo_args)
|
|
.status()
|
|
.map_err(|e| e.to_string())?;
|
|
return Ok(status.code().unwrap_or(0));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
use std::process::Command as SysCommand;
|
|
|
|
fn quote_ps(value: &str) -> String {
|
|
// PowerShell single-quote escaping: ' -> ''
|
|
format!("'{}'", value.replace('\'', "''"))
|
|
}
|
|
|
|
let file_path = quote_ps("rclone");
|
|
let arg_list = String::from("@('selfupdate')");
|
|
|
|
let ps_script = format!(
|
|
"$p = Start-Process -Verb RunAs -WindowStyle Hidden -PassThru -FilePath {file} -ArgumentList {args}; \n\
|
|
$p.WaitForExit();\n\
|
|
exit $p.ExitCode",
|
|
file = file_path,
|
|
args = arg_list
|
|
);
|
|
|
|
let status = SysCommand::new("powershell")
|
|
.args([
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-Command",
|
|
&ps_script,
|
|
])
|
|
.status()
|
|
.map_err(|e| e.to_string())?;
|
|
return Ok(status.code().unwrap_or(0));
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
|
{
|
|
Err("Unsupported platform".to_string())
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
let client = sentry::init((
|
|
"https://7c7c55918ff850112780d2b2b29121a6@o4508503751983104.ingest.de.sentry.io/4508739164110928",
|
|
sentry::ClientOptions {
|
|
release: sentry::release_name!(),
|
|
..Default::default()
|
|
},
|
|
));
|
|
|
|
let _guard = tauri_plugin_sentry::minidump::init(&client);
|
|
|
|
let mut builder = tauri::Builder::default();
|
|
|
|
if !is_flatpak() {
|
|
builder = builder
|
|
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
|
let _ = shortcut::show_toolbar_window(app);
|
|
}));
|
|
}
|
|
|
|
let mut app = builder
|
|
.plugin(tauri_plugin_deep_link::init())
|
|
.plugin(tauri_plugin_sentry::init_with_no_injection(&client))
|
|
.plugin(tauri_plugin_clipboard_manager::init())
|
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
.plugin(tauri_plugin_os::init())
|
|
.plugin(tauri_plugin_notification::init())
|
|
.plugin(tauri_plugin_process::init())
|
|
.plugin(tauri_plugin_autostart::init(
|
|
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
|
Some(vec![]),
|
|
))
|
|
.plugin(tauri_plugin_store::Builder::new().build())
|
|
.plugin(tauri_plugin_http::init())
|
|
.plugin(tauri_plugin_fs::init())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_log::Builder::new().build())
|
|
.plugin(tauri_plugin_shell::init())
|
|
.plugin(tauri_plugin_opener::init())
|
|
.plugin(tauri_plugin_prevent_default::debug())
|
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
|
.invoke_handler(tauri::generate_handler![
|
|
unzip_file,
|
|
get_arch,
|
|
get_uid,
|
|
is_rclone_running,
|
|
stop_rclone_processes,
|
|
prompt,
|
|
stop_pid,
|
|
update_toolbar_shortcut,
|
|
show_toolbar,
|
|
update_system_rclone,
|
|
test_proxy_connection,
|
|
is_flatpak,
|
|
is_linux_mint,
|
|
has_flatpak_permissions,
|
|
open_full_window,
|
|
open_window,
|
|
open_small_window,
|
|
lock_windows,
|
|
unlock_windows,
|
|
start_cloudflared_tunnel,
|
|
stop_cloudflared_tunnel,
|
|
extract_tgz
|
|
])
|
|
.setup(|app| {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
// Flatpak/Flathub sandbox typically cannot write to system desktop/mime locations.
|
|
// Deep-link registration is best-effort; never fail app startup.
|
|
if is_flatpak() {
|
|
log::info!("skipping deep-link registration in Flatpak/Flathub");
|
|
} else {
|
|
use tauri_plugin_deep_link::DeepLinkExt;
|
|
if let Err(err) = app.deep_link().register_all() {
|
|
log::warn!("deep-link registration failed (continuing): {}", err);
|
|
}
|
|
}
|
|
|
|
let cache_dir = app.path().cache_dir()?;
|
|
let package_info = app.package_info();
|
|
let app_name = package_info.name.as_str();
|
|
let app_cache = cache_dir.join(app_name);
|
|
if app_cache.exists() {
|
|
let _ = fs::remove_dir_all(&app_cache);
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
use tauri_plugin_deep_link::DeepLinkExt;
|
|
if let Err(err) = app.deep_link().register_all() {
|
|
log::warn!("deep-link registration failed (continuing): {}", err);
|
|
}
|
|
}
|
|
|
|
if let Err(err) = ensure_toolbar_window(&app.handle()) {
|
|
log::warn!("failed to prepare toolbar window: {}", err);
|
|
}
|
|
if let Err(err) = set_toolbar_shortcut(&app.handle(), Some(DEFAULT_TOOLBAR_SHORTCUT)) {
|
|
log::error!("failed to update default toolbar shortcut: {}", err);
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
.build(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
|
|
#[cfg(target_os = "macos")]
|
|
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
|
app.run(|_app, _event| {})
|
|
}
|