Prepare settings panel (#136)

This commit is contained in:
FTCHD
2026-01-23 22:51:42 +07:00
committed by GitHub
parent ea43cb5f5f
commit 1e503c82ac
13 changed files with 672 additions and 4 deletions
+16
View File
@@ -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)
}
+185
View File
@@ -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
}
}
+22
View File
@@ -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) {
+24 -2
View File
@@ -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
View File
@@ -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",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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")]
+207
View File
@@ -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>
)
}
+29
View File
@@ -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={
+7
View File
@@ -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' }
) )