diff --git a/lib/cloudflared/common.ts b/lib/cloudflared/common.ts new file mode 100644 index 0000000..8bc0418 --- /dev/null +++ b/lib/cloudflared/common.ts @@ -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 { + const currentPlatform = platform() + const binaryName = currentPlatform === 'windows' ? 'cloudflared.exe' : 'cloudflared' + + const appLocalDataDirPath = await appLocalDataDir() + const cloudflaredPath = `${appLocalDataDirPath}${sep()}${binaryName}` + + return await exists(cloudflaredPath) +} diff --git a/lib/cloudflared/init.ts b/lib/cloudflared/init.ts new file mode 100644 index 0000000..994e415 --- /dev/null +++ b/lib/cloudflared/init.ts @@ -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} + */ +export async function provisionCloudflared(): Promise { + 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 + } +} diff --git a/main.ts b/main.ts index cc69ddc..9861735 100644 --- a/main.ts +++ b/main.ts @@ -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) { diff --git a/package-lock.json b/package-lock.json index 24b5350..fe71944 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 52d9240..f2cdb5e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/mobile.png b/public/mobile.png new file mode 100644 index 0000000..f7e1240 Binary files /dev/null and b/public/mobile.png differ diff --git a/src-tauri/Cargo.flatpak.lock b/src-tauri/Cargo.flatpak.lock index 1edf04e..aa457d1 100644 --- a/src-tauri/Cargo.flatpak.lock +++ b/src-tauri/Cargo.flatpak.lock @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 49bb513..5b21cc1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index eee7c89..8374816 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b3dc32f..d48b783 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { 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")] diff --git a/src/pages/Settings/MobileSection.tsx b/src/pages/Settings/MobileSection.tsx new file mode 100644 index 0000000..a13730a --- /dev/null +++ b/src/pages/Settings/MobileSection.tsx @@ -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(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((_, 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 ( + +
+
+

Mobile Session

+

+ Start a new tunnel to manage rclone from your phone +

+
+ +
+ {!cloudflaredTunnel && ( + <> + + {error &&

{error}

} + + )} + + {cloudflaredTunnel && ( + + )} +
+
+ +
+ {cloudflaredTunnel && ( +
+

Scan the QR Code

+
+ +
+ handleCopyUrl(cloudflaredTunnel.url)} + className="transition-colors text-neutral-400 hover:text-neutral-200" + > + {copied ? ( + + ) : ( + + )} + + } + /> +
+ )} + + {!cloudflaredTunnel && ( +
+ Mobile openUrl('https://google.com')} + /> + + {/*
+

+ Download on the{' '} + openUrl('https://apple.com')} + > + App Store + {' '} + and{' '} + openUrl('https://google.com')} + > + Google Play + +

+
*/} +
+ )} +
+
+ ) +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index d633aa8..2d34d4e 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -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() { > + +
+ + Mobile +
+ + } + data-focus-visible="false" + className="w-full max-h-screen p-0 overflow-scroll overscroll-none" + > + +
()( @@ -37,6 +42,8 @@ export const useStore = create()( app: 'dark', tray: 'system', }, + + cloudflaredTunnel: null, }), { name: 'shared-store' } )