Prepare settings panel (#136)
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { appLocalDataDir, sep } from '@tauri-apps/api/path'
|
||||
import { exists } from '@tauri-apps/plugin-fs'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
|
||||
/**
|
||||
* Checks if cloudflared is installed in the app's local data directory
|
||||
*/
|
||||
export async function isCloudflaredInstalled(): Promise<boolean> {
|
||||
const currentPlatform = platform()
|
||||
const binaryName = currentPlatform === 'windows' ? 'cloudflared.exe' : 'cloudflared'
|
||||
|
||||
const appLocalDataDirPath = await appLocalDataDir()
|
||||
const cloudflaredPath = `${appLocalDataDirPath}${sep()}${binaryName}`
|
||||
|
||||
return await exists(cloudflaredPath)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { appLocalDataDir, sep, tempDir } from '@tauri-apps/api/path'
|
||||
import { BaseDirectory } from '@tauri-apps/api/path'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { copyFile, exists, mkdir, remove, writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
|
||||
/**
|
||||
* Downloads and provisions cloudflared for the current platform
|
||||
* @throws {Error} If architecture detection fails or installation is unsuccessful
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function provisionCloudflared(): Promise<boolean> {
|
||||
console.log('[provisionCloudflared]')
|
||||
|
||||
const currentPlatform = platform()
|
||||
console.log('[provisionCloudflared] currentPlatform', currentPlatform)
|
||||
|
||||
const currentOs = currentPlatform === 'macos' ? 'darwin' : currentPlatform
|
||||
console.log('[provisionCloudflared] currentOs', currentOs)
|
||||
|
||||
let tempDirPath = await tempDir()
|
||||
if (tempDirPath.endsWith(sep())) {
|
||||
tempDirPath = tempDirPath.slice(0, -1)
|
||||
}
|
||||
console.log('[provisionCloudflared] tempDirPath', tempDirPath)
|
||||
|
||||
const arch = (await invoke('get_arch')) as 'arm64' | 'amd64' | '386' | 'unknown'
|
||||
console.log('[provisionCloudflared] arch', arch)
|
||||
|
||||
if (arch === 'unknown') {
|
||||
console.error('[provisionCloudflared] failed to get architecture')
|
||||
await message('Failed to get current arch, please try again later.')
|
||||
return false
|
||||
}
|
||||
|
||||
// Cloudflared binary names by platform
|
||||
let downloadUrl: string
|
||||
let binaryName: string
|
||||
let needsExtraction = false
|
||||
|
||||
if (currentOs === 'darwin') {
|
||||
// macOS uses .tgz archives
|
||||
binaryName = 'cloudflared'
|
||||
const archName = arch === 'arm64' ? 'arm64' : 'amd64'
|
||||
downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${archName}.tgz`
|
||||
needsExtraction = true
|
||||
} else if (currentOs === 'windows') {
|
||||
// Windows uses .exe
|
||||
binaryName = 'cloudflared.exe'
|
||||
const archName = arch === 'amd64' ? 'amd64' : '386'
|
||||
downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-${archName}.exe`
|
||||
} else {
|
||||
// Linux uses direct binary
|
||||
binaryName = 'cloudflared'
|
||||
const archName = arch === 'arm64' ? 'arm64' : 'amd64'
|
||||
downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${archName}`
|
||||
}
|
||||
|
||||
console.log('[provisionCloudflared] downloadUrl', downloadUrl)
|
||||
|
||||
try {
|
||||
const downloadedFile = await fetch(downloadUrl).then((res) => res.arrayBuffer())
|
||||
console.log('[provisionCloudflared] downloadedFile')
|
||||
|
||||
let tempDirExists = false
|
||||
try {
|
||||
tempDirExists = await exists('cloudflared', {
|
||||
baseDir: BaseDirectory.Temp,
|
||||
})
|
||||
console.log('[provisionCloudflared] tempDirExists', tempDirExists)
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error(
|
||||
'[provisionCloudflared] failed to check if cloudflared temp dir exists',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
if (tempDirExists) {
|
||||
try {
|
||||
await remove('cloudflared', {
|
||||
recursive: true,
|
||||
baseDir: BaseDirectory.Temp,
|
||||
})
|
||||
console.log('[provisionCloudflared] removed cloudflared temp dir')
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionCloudflared] failed to remove cloudflared temp dir', error)
|
||||
await message('Failed to provision cloudflared.')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir('cloudflared', {
|
||||
baseDir: BaseDirectory.Temp,
|
||||
})
|
||||
console.log('[provisionCloudflared] created cloudflared temp dir')
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionCloudflared] failed to create cloudflared temp dir', error)
|
||||
await message('Failed to provision cloudflared.')
|
||||
return false
|
||||
}
|
||||
|
||||
const fileName = needsExtraction ? 'cloudflared.tgz' : binaryName
|
||||
const filePath = [tempDirPath, 'cloudflared', fileName].join(sep())
|
||||
console.log('[provisionCloudflared] filePath', filePath)
|
||||
|
||||
try {
|
||||
await writeFile(filePath, new Uint8Array(downloadedFile))
|
||||
console.log('[provisionCloudflared] wrote file')
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionCloudflared] failed to write file', error)
|
||||
await message('Failed to provision cloudflared.')
|
||||
return false
|
||||
}
|
||||
|
||||
let binaryPath = filePath
|
||||
|
||||
// Extract if needed (macOS .tgz)
|
||||
if (needsExtraction) {
|
||||
try {
|
||||
await invoke('extract_tgz', {
|
||||
tgzPath: filePath,
|
||||
outputFolder: `${tempDirPath}${sep()}cloudflared${sep()}extracted`,
|
||||
})
|
||||
console.log('[provisionCloudflared] successfully extracted file')
|
||||
binaryPath = [tempDirPath, 'cloudflared', 'extracted', 'cloudflared'].join(sep())
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionCloudflared] failed to extract file', error)
|
||||
await message('Failed to provision cloudflared.')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[provisionCloudflared] binaryPath', binaryPath)
|
||||
|
||||
try {
|
||||
const binaryExists = await exists(binaryPath)
|
||||
console.log('[provisionCloudflared] binaryExists', binaryExists)
|
||||
if (!binaryExists) {
|
||||
throw new Error('Could not find cloudflared binary')
|
||||
}
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error(
|
||||
'[provisionCloudflared] failed to check if cloudflared binary exists',
|
||||
error
|
||||
)
|
||||
await message('Failed to provision cloudflared.')
|
||||
return false
|
||||
}
|
||||
|
||||
const appLocalDataDirPath = await appLocalDataDir()
|
||||
console.log('[provisionCloudflared] appLocalDataDirPath', appLocalDataDirPath)
|
||||
|
||||
const appLocalDataDirPathExists = await exists(appLocalDataDirPath)
|
||||
console.log('[provisionCloudflared] appLocalDataDirPathExists', appLocalDataDirPathExists)
|
||||
|
||||
if (!appLocalDataDirPathExists) {
|
||||
await mkdir(appLocalDataDirPath, {
|
||||
recursive: true,
|
||||
})
|
||||
console.log('[provisionCloudflared] appLocalDataDirPath created')
|
||||
}
|
||||
|
||||
await copyFile(binaryPath, `${appLocalDataDirPath}${sep()}${binaryName}`)
|
||||
console.log('[provisionCloudflared] copied cloudflared binary')
|
||||
|
||||
console.log('[provisionCloudflared] cloudflared has been installed')
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionCloudflared] failed to provision cloudflared', error)
|
||||
await message('Failed to download cloudflared. Please check your internet connection.')
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -280,6 +280,17 @@ async function registerRcloneWindowListeners() {
|
||||
}
|
||||
}
|
||||
|
||||
const cloudflaredTunnel = useStore.getState().cloudflaredTunnel
|
||||
if (cloudflaredTunnel) {
|
||||
try {
|
||||
console.log('[close-app] stopping cloudflared tunnel')
|
||||
await invoke('stop_cloudflared_tunnel', { pid: cloudflaredTunnel.pid })
|
||||
useStore.setState({ cloudflaredTunnel: null })
|
||||
} catch (error) {
|
||||
console.error('[close-app] failed to stop cloudflared tunnel', error)
|
||||
}
|
||||
}
|
||||
|
||||
const child = currentRcloneChild
|
||||
|
||||
if (child) {
|
||||
@@ -319,6 +330,17 @@ async function registerRcloneWindowListeners() {
|
||||
}
|
||||
}
|
||||
|
||||
const cloudflaredTunnel = useStore.getState().cloudflaredTunnel
|
||||
if (cloudflaredTunnel) {
|
||||
try {
|
||||
console.log('[close-app] stopping cloudflared tunnel')
|
||||
await invoke('stop_cloudflared_tunnel', { pid: cloudflaredTunnel.pid })
|
||||
useStore.setState({ cloudflaredTunnel: null })
|
||||
} catch (error) {
|
||||
console.error('[close-app] failed to stop cloudflared tunnel', error)
|
||||
}
|
||||
}
|
||||
|
||||
const child = currentRcloneChild
|
||||
|
||||
if (child) {
|
||||
|
||||
Generated
+24
-2
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "rclone-ui",
|
||||
"version": "3.2.2",
|
||||
"version": "3.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rclone-ui",
|
||||
"version": "3.2.2",
|
||||
"version": "3.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@heroui/react": "^2.7.11",
|
||||
@@ -39,6 +40,7 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"lucide-react": "^0.552.0",
|
||||
"p-retry": "^7.1.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"rclone-sdk": "^1.72.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -55,6 +57,7 @@
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/qrcode.react": "^1.0.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
@@ -5855,6 +5858,16 @@
|
||||
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/qrcode.react": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode.react/-/qrcode.react-1.0.5.tgz",
|
||||
"integrity": "sha512-BghPtnlwvrvq8QkGa1H25YnN+5OIgCKFuQruncGWLGJYOzeSKiix/4+B9BtfKF2wf5ja8yfyWYA3OXju995G8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
|
||||
@@ -7425,6 +7438,15 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
+12
-1
@@ -12,7 +12,16 @@
|
||||
"url": "https://github.com/rclone-ui/rclone-ui"
|
||||
},
|
||||
"homepage": "https://rcloneui.com",
|
||||
"keywords": ["rclone", "gui", "drive", "s3", "cloud", "storage", "sync", "backup"],
|
||||
"keywords": [
|
||||
"rclone",
|
||||
"gui",
|
||||
"drive",
|
||||
"s3",
|
||||
"cloud",
|
||||
"storage",
|
||||
"sync",
|
||||
"backup"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"dev": "node scripts/buildExternal.js && vite",
|
||||
@@ -63,6 +72,7 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"lucide-react": "^0.552.0",
|
||||
"p-retry": "^7.1.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"rclone-sdk": "^1.72.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -79,6 +89,7 @@
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/qrcode.react": "^1.0.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
@@ -249,6 +249,7 @@ version = "3.2.2"
|
||||
dependencies = [
|
||||
"cocoa",
|
||||
"fix-path-env",
|
||||
"flate2",
|
||||
"log",
|
||||
"machine-uid",
|
||||
"objc",
|
||||
@@ -257,6 +258,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
|
||||
Generated
+2
@@ -249,6 +249,7 @@ version = "3.2.2"
|
||||
dependencies = [
|
||||
"cocoa",
|
||||
"fix-path-env",
|
||||
"flate2",
|
||||
"log",
|
||||
"machine-uid",
|
||||
"objc",
|
||||
@@ -257,6 +258,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
|
||||
@@ -48,6 +48,8 @@ reqwest = { version = "0.11", features = ["json"] }
|
||||
sysinfo = "0.37"
|
||||
tinyfiledialogs = "3.9.1"
|
||||
tauri-plugin-deep-link = "2"
|
||||
flate2 = "1.1.4"
|
||||
tar = "0.4.44"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.26"
|
||||
|
||||
+164
-1
@@ -431,6 +431,166 @@ async fn prompt(
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -669,7 +829,10 @@ pub fn run() {
|
||||
open_window,
|
||||
open_small_window,
|
||||
lock_windows,
|
||||
unlock_windows
|
||||
unlock_windows,
|
||||
start_cloudflared_tunnel,
|
||||
stop_cloudflared_tunnel,
|
||||
extract_tgz
|
||||
])
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||
import { Button, Input } from '@heroui/react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useState } from 'react'
|
||||
import { isCloudflaredInstalled } from '../../../lib/cloudflared/common'
|
||||
import { provisionCloudflared } from '../../../lib/cloudflared/init'
|
||||
import { useStore } from '../../../store/memory'
|
||||
import BaseSection from './BaseSection'
|
||||
|
||||
export default function MobileSection() {
|
||||
const cloudflaredTunnel = useStore((state) => state.cloudflaredTunnel)
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const [animationParent] = useAutoAnimate()
|
||||
|
||||
const handleCopyUrl = async (url: string) => {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const provisionCloudflaredMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const success = await provisionCloudflared()
|
||||
if (!success) {
|
||||
throw new Error('Failed to download cloudflared')
|
||||
}
|
||||
},
|
||||
onError: async (e) => {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||
setError(errorMsg)
|
||||
await message(`Failed to download cloudflared: ${errorMsg}`, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const startTunnelMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setError(null)
|
||||
// Check if cloudflared is installed
|
||||
const installed = await isCloudflaredInstalled()
|
||||
|
||||
if (!installed) {
|
||||
await provisionCloudflaredMutation.mutateAsync()
|
||||
}
|
||||
|
||||
// Create a 15-second timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Tunnel startup timed out after 15 seconds'))
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
// Race between the tunnel starting and the timeout
|
||||
const result = await Promise.race([
|
||||
invoke<[number, string]>('start_cloudflared_tunnel'),
|
||||
timeoutPromise,
|
||||
])
|
||||
|
||||
const [pid, url] = result
|
||||
useStore.setState({ cloudflaredTunnel: { pid, url } })
|
||||
},
|
||||
onError: async (e) => {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||
setError(errorMsg)
|
||||
await message(`Failed to start tunnel: ${errorMsg}`, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const stopTunnelMutation = useMutation({
|
||||
mutationFn: async (pid: number) => {
|
||||
await invoke('stop_cloudflared_tunnel', { pid })
|
||||
useStore.setState({ cloudflaredTunnel: null })
|
||||
},
|
||||
onError: async (e) => {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||
await message(`Failed to stop tunnel: ${errorMsg}`, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<BaseSection header={{ title: 'Mobile' }}>
|
||||
<div className="flex flex-row justify-center w-full gap-8 px-8">
|
||||
<div className="flex flex-col items-end flex-1 gap-2">
|
||||
<h3 className="font-medium">Mobile Session</h3>
|
||||
<p className="text-xs text-neutral-500 text-end">
|
||||
Start a new tunnel to manage rclone from your phone
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-3/5 gap-4">
|
||||
{!cloudflaredTunnel && (
|
||||
<>
|
||||
<Button
|
||||
size="lg"
|
||||
color="primary"
|
||||
onPress={() => startTunnelMutation.mutate()}
|
||||
isLoading={
|
||||
startTunnelMutation.isPending ||
|
||||
provisionCloudflaredMutation.isPending
|
||||
}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{provisionCloudflaredMutation.isPending ||
|
||||
startTunnelMutation.isPending
|
||||
? 'Initializing...'
|
||||
: 'Tap to enable'}
|
||||
</Button>
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{cloudflaredTunnel && (
|
||||
<Button
|
||||
size="lg"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => stopTunnelMutation.mutate(cloudflaredTunnel.pid)}
|
||||
isLoading={stopTunnelMutation.isPending}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={animationParent}>
|
||||
{cloudflaredTunnel && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-medium text-neutral-400">Scan the QR Code</p>
|
||||
<div className="p-4 bg-white rounded-lg">
|
||||
<QRCodeSVG
|
||||
value={JSON.stringify({ url: cloudflaredTunnel.url })}
|
||||
size={200}
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
isReadOnly={true}
|
||||
value={cloudflaredTunnel.url}
|
||||
size="lg"
|
||||
className="max-w-lg mt-7"
|
||||
endContent={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyUrl(cloudflaredTunnel.url)}
|
||||
className="transition-colors text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon size={18} className="text-success" />
|
||||
) : (
|
||||
<CopyIcon size={18} />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!cloudflaredTunnel && (
|
||||
<div className="flex flex-col items-center gap-2 overflow-hidden max-h-[525px]">
|
||||
<img
|
||||
src={'/mobile.png'}
|
||||
alt="Mobile"
|
||||
className="w-full px-10 !cursor-pointer"
|
||||
// onClick={() => openUrl('https://google.com')}
|
||||
/>
|
||||
|
||||
{/* <div className="absolute left-0 right-0 flex flex-col items-center bottom-5">
|
||||
<p className="p-2 px-3.5 text-medium text-primary-800 bg-content2 border-1 border-divider rounded-small">
|
||||
Download on the{' '}
|
||||
<span
|
||||
className="font-medium !cursor-pointer hover:text-primary-foreground"
|
||||
onClick={() => openUrl('https://apple.com')}
|
||||
>
|
||||
App Store
|
||||
</span>{' '}
|
||||
and{' '}
|
||||
<span
|
||||
className="font-medium !cursor-pointer hover:text-primary-foreground"
|
||||
onClick={() => openUrl('https://google.com')}
|
||||
>
|
||||
Google Play
|
||||
</span>
|
||||
</p>
|
||||
</div> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseSection>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MedalIcon,
|
||||
SatelliteDishIcon,
|
||||
ServerIcon,
|
||||
TabletSmartphoneIcon,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
@@ -26,6 +27,7 @@ import ConfigSection from './ConfigSection'
|
||||
import GeneralSection from './GeneralSection'
|
||||
import HostsSection from './HostsSection'
|
||||
import LicenseSection from './LicenseSection'
|
||||
import MobileSection from './MobileSection'
|
||||
import ProxySection from './ProxySection'
|
||||
import RemotesSection from './RemotesSection'
|
||||
import ToolbarSection from './ToolbarSection'
|
||||
@@ -255,6 +257,33 @@ export default function Settings() {
|
||||
>
|
||||
<ProxySection />
|
||||
</Tab>
|
||||
<Tab
|
||||
key="mobile"
|
||||
title={
|
||||
<Tooltip
|
||||
content={
|
||||
currentHost?.id !== 'local'
|
||||
? 'Mobile access is only available when using your local machine, not a remote host'
|
||||
: undefined
|
||||
}
|
||||
isDisabled={currentHost?.id === 'local'}
|
||||
placement="right"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
className="max-w-48"
|
||||
offset={90}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TabletSmartphoneIcon className="w-5 h-5" />
|
||||
<span>Mobile</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
data-focus-visible="false"
|
||||
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
|
||||
>
|
||||
<MobileSection />
|
||||
</Tab>
|
||||
<Tab
|
||||
key="license"
|
||||
title={
|
||||
|
||||
@@ -21,6 +21,11 @@ interface State {
|
||||
app: 'light' | 'dark' | 'system'
|
||||
tray: 'light' | 'dark' | 'system'
|
||||
}
|
||||
|
||||
cloudflaredTunnel: {
|
||||
pid: number
|
||||
url: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export const useStore = create<State>()(
|
||||
@@ -37,6 +42,8 @@ export const useStore = create<State>()(
|
||||
app: 'dark',
|
||||
tray: 'system',
|
||||
},
|
||||
|
||||
cloudflaredTunnel: null,
|
||||
}),
|
||||
{ name: 'shared-store' }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user