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
|
const child = currentRcloneChild
|
||||||
|
|
||||||
if (child) {
|
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
|
const child = currentRcloneChild
|
||||||
|
|
||||||
if (child) {
|
if (child) {
|
||||||
|
|||||||
Generated
+24
-2
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "rclone-ui",
|
"name": "rclone-ui",
|
||||||
"version": "3.2.2",
|
"version": "3.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rclone-ui",
|
"name": "rclone-ui",
|
||||||
"version": "3.2.2",
|
"version": "3.2.0",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formkit/auto-animate": "^0.9.0",
|
"@formkit/auto-animate": "^0.9.0",
|
||||||
"@heroui/react": "^2.7.11",
|
"@heroui/react": "^2.7.11",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
"p-retry": "^7.1.0",
|
"p-retry": "^7.1.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"rclone-sdk": "^1.72.0",
|
"rclone-sdk": "^1.72.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -55,6 +57,7 @@
|
|||||||
"@total-typescript/ts-reset": "^0.6.1",
|
"@total-typescript/ts-reset": "^0.6.1",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/qrcode.react": "^1.0.5",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
@@ -5855,6 +5858,16 @@
|
|||||||
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
|
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
|
||||||
"devOptional": true
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.18",
|
"version": "18.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
|
||||||
@@ -7425,6 +7438,15 @@
|
|||||||
"react-is": "^16.13.1"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"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"
|
"url": "https://github.com/rclone-ui/rclone-ui"
|
||||||
},
|
},
|
||||||
"homepage": "https://rcloneui.com",
|
"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",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/buildExternal.js && vite",
|
"dev": "node scripts/buildExternal.js && vite",
|
||||||
@@ -63,6 +72,7 @@
|
|||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
"p-retry": "^7.1.0",
|
"p-retry": "^7.1.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"rclone-sdk": "^1.72.0",
|
"rclone-sdk": "^1.72.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -79,6 +89,7 @@
|
|||||||
"@total-typescript/ts-reset": "^0.6.1",
|
"@total-typescript/ts-reset": "^0.6.1",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/qrcode.react": "^1.0.5",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@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 = [
|
dependencies = [
|
||||||
"cocoa",
|
"cocoa",
|
||||||
"fix-path-env",
|
"fix-path-env",
|
||||||
|
"flate2",
|
||||||
"log",
|
"log",
|
||||||
"machine-uid",
|
"machine-uid",
|
||||||
"objc",
|
"objc",
|
||||||
@@ -257,6 +258,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
|
"tar",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-autostart",
|
"tauri-plugin-autostart",
|
||||||
|
|||||||
Generated
+2
@@ -249,6 +249,7 @@ version = "3.2.2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cocoa",
|
"cocoa",
|
||||||
"fix-path-env",
|
"fix-path-env",
|
||||||
|
"flate2",
|
||||||
"log",
|
"log",
|
||||||
"machine-uid",
|
"machine-uid",
|
||||||
"objc",
|
"objc",
|
||||||
@@ -257,6 +258,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
|
"tar",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-autostart",
|
"tauri-plugin-autostart",
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ reqwest = { version = "0.11", features = ["json"] }
|
|||||||
sysinfo = "0.37"
|
sysinfo = "0.37"
|
||||||
tinyfiledialogs = "3.9.1"
|
tinyfiledialogs = "3.9.1"
|
||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-deep-link = "2"
|
||||||
|
flate2 = "1.1.4"
|
||||||
|
tar = "0.4.44"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
cocoa = "0.26"
|
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]
|
#[tauri::command]
|
||||||
async fn test_proxy_connection(proxy_url: String) -> Result<String, String> {
|
async fn test_proxy_connection(proxy_url: String) -> Result<String, String> {
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -669,7 +829,10 @@ pub fn run() {
|
|||||||
open_window,
|
open_window,
|
||||||
open_small_window,
|
open_small_window,
|
||||||
lock_windows,
|
lock_windows,
|
||||||
unlock_windows
|
unlock_windows,
|
||||||
|
start_cloudflared_tunnel,
|
||||||
|
stop_cloudflared_tunnel,
|
||||||
|
extract_tgz
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(target_os = "linux")]
|
#[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,
|
MedalIcon,
|
||||||
SatelliteDishIcon,
|
SatelliteDishIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
TabletSmartphoneIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
@@ -26,6 +27,7 @@ import ConfigSection from './ConfigSection'
|
|||||||
import GeneralSection from './GeneralSection'
|
import GeneralSection from './GeneralSection'
|
||||||
import HostsSection from './HostsSection'
|
import HostsSection from './HostsSection'
|
||||||
import LicenseSection from './LicenseSection'
|
import LicenseSection from './LicenseSection'
|
||||||
|
import MobileSection from './MobileSection'
|
||||||
import ProxySection from './ProxySection'
|
import ProxySection from './ProxySection'
|
||||||
import RemotesSection from './RemotesSection'
|
import RemotesSection from './RemotesSection'
|
||||||
import ToolbarSection from './ToolbarSection'
|
import ToolbarSection from './ToolbarSection'
|
||||||
@@ -255,6 +257,33 @@ export default function Settings() {
|
|||||||
>
|
>
|
||||||
<ProxySection />
|
<ProxySection />
|
||||||
</Tab>
|
</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
|
<Tab
|
||||||
key="license"
|
key="license"
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ interface State {
|
|||||||
app: 'light' | 'dark' | 'system'
|
app: 'light' | 'dark' | 'system'
|
||||||
tray: 'light' | 'dark' | 'system'
|
tray: 'light' | 'dark' | 'system'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cloudflaredTunnel: {
|
||||||
|
pid: number
|
||||||
|
url: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStore = create<State>()(
|
export const useStore = create<State>()(
|
||||||
@@ -37,6 +42,8 @@ export const useStore = create<State>()(
|
|||||||
app: 'dark',
|
app: 'dark',
|
||||||
tray: 'system',
|
tray: 'system',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cloudflaredTunnel: null,
|
||||||
}),
|
}),
|
||||||
{ name: 'shared-store' }
|
{ name: 'shared-store' }
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user