diff --git a/lib/menu.ts b/lib/menu.ts index 5f8db0e..be27f76 100644 --- a/lib/menu.ts +++ b/lib/menu.ts @@ -8,7 +8,15 @@ import { openPath } from '@tauri-apps/plugin-opener' import { platform } from '@tauri-apps/plugin-os' import { isDirectoryEmpty } from './fs' import notify from './notify' -import { cleanupRemote, deleteRemote, listMounts, mountRemote, unmountRemote } from './rclone/api' +import { + cleanupRemote, + deleteRemote, + listMounts, + listServes, + mountRemote, + stopServe, + unmountRemote, +} from './rclone/api' import { dialogGetMountPlugin, needsMountPlugin } from './rclone/mount' import { usePersistedStore, useStore } from './store' import { showDefaultTray, showLoadingTray } from './tray' @@ -22,6 +30,8 @@ async function parseRemotes(remotes: string[]) { console.log('[parseRemotes] listing mounts') const currentMounts = await listMounts() console.log('[parseRemotes] currentMounts', currentMounts) + const currentServes = await listServes() + console.log('[parseRemotes] currentServes', currentServes) const parsedRemotes: Record = {} @@ -320,6 +330,34 @@ async function parseRemotes(remotes: string[]) { submenuItems.push(showLocationItem) } + for (const currentServe of currentServes) { + // console.log('[parseRemotes] Adding Stop Serve (' + currentServe.Addr.split(':')[0] + ') for ', remote) + const stopServeMenuItem = await MenuItem.new({ + id: `stop-serve-${currentServe.id}`, + text: 'Stop Serve', + action: async () => { + try { + await showLoadingTray() + await stopServe(currentServe.id) + + await message(`Successfully stopped serve ${currentServe.id}`, { + title: 'Success', + }) + } catch (error) { + Sentry.captureException(error) + console.error('Stop serve operation failed:', error) + await message(`Failed to stop serve ${currentServe.id}: ${error}`, { + kind: 'error', + title: 'Stop Serve Error', + }) + } finally { + await showDefaultTray() + } + }, + }) + submenuItems.push(stopServeMenuItem) + } + parsedRemotes[remote] = submenuItems } @@ -436,6 +474,20 @@ export async function buildMenu() { menuItems.push(syncMenuItem) } + if (!persistedStoreState.disabledActions?.includes('tray-serve')) { + const serveToMenuItem = await MenuItem.new({ + id: 'serve', + text: 'Serve', + action: async () => { + await openWindow({ + name: 'Serve', + url: '/serve', + }) + }, + }) + menuItems.push(serveToMenuItem) + } + if (!persistedStoreState.disabledActions?.includes('tray-purge')) { const purgeMenuItem = await MenuItem.new({ id: 'purge', diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index 90e5864..59dbfec 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/browser' import { fetch } from '@tauri-apps/plugin-http' import { platform } from '@tauri-apps/plugin-os' +import type { FlagValue } from '../../types/rclone' import { useStore } from '../store' import { parseRcloneOptions } from './common' @@ -859,7 +860,10 @@ export async function getSftpFlags() { headers: getAuthHeader(), }).then((res) => res.json() as Promise) - const sftpFlags = r.sftp + const sftpFlags = r.sftp.map((flag: any) => ({ + ...flag, + FieldName: flag.Name, + })) return sftpFlags.sort((a: any, b: any) => a.Name.localeCompare(b.Name)) } @@ -872,7 +876,173 @@ export async function getFtpFlags() { headers: getAuthHeader(), }).then((res) => res.json() as Promise) - const ftpFlags = r.ftp + const ftpFlags = r.ftp.map((flag: any) => ({ + ...flag, + FieldName: flag.Name, + })) return ftpFlags.sort((a: any, b: any) => a.Name.localeCompare(b.Name)) } + +export async function getDlnaFlags() { + console.log('[getDlnaFlags]') + + const r = await fetch('http://localhost:5572/options/info', { + method: 'POST', + headers: getAuthHeader(), + }).then((res) => res.json() as Promise) + + const dlnaFlags = r.dlna.map((flag: any) => ({ + ...flag, + FieldName: flag.Name, + })) + + return dlnaFlags.sort((a: any, b: any) => a.Name.localeCompare(b.Name)) +} + +/* SERVE */ +export interface ServeListItem { + id: string + addr: string + params: { + fs: string + type: string + opt?: Record + vfsOpt?: Record + } +} + +export async function listServes() { + console.log('[listServes]') + + const r = await fetch('http://localhost:5572/serve/list', { + method: 'POST', + headers: getAuthHeader(), + }) + .then( + (res) => + res.json() as Promise<{ + list: ServeListItem[] + }> + ) + .catch((e) => { + Sentry.captureException(e) + console.log('error', e) + throw e + }) + + if (!Array.isArray(r?.list)) { + throw new Error('[listServes] Failed to get running serves') + } + + return r.list +} + +export async function startServe({ + // type, + // fs, + // addr, + _filter, + _config, + ...props +}: { + // type: string + // fs: string + // addr: string + // options?: Record + _filter?: Record + _config?: Record +} & Record) { + console.log('[startServe] ', props) + + const params = new URLSearchParams() + // params.set('type', type) + // params.set('fs', fs) + // params.set('user', 'usr') + // params.set('pass', 'pas') + + for (const [key, value] of Object.entries(props)) { + if (Array.isArray(value)) { + for (const v of value) params.append(key, String(v)) + } else if (value !== undefined) { + params.set(key, String(value)) + } + } + + // if (options && Object.keys(options).length > 0) { + // const parsed = parseRcloneOptions(options) + // for (const [key, value] of Object.entries(parsed)) { + // if (Array.isArray(value)) { + // for (const v of value) params.append(key, String(v)) + // } else if (value !== undefined) { + // params.set(key, String(value)) + // } + // } + // } + + if (_filter && Object.keys(_filter).length > 0) { + params.set('_filter', JSON.stringify(parseRcloneOptions(_filter))) + } + + if (_config && Object.keys(_config).length > 0) { + params.set('_config', JSON.stringify(parseRcloneOptions(_config))) + } + + const r = await fetch(`http://localhost:5572/serve/start?${params.toString()}`, { + method: 'POST', + headers: getAuthHeader(), + }) + .then((res) => res.json() as Promise<{ addr: string; id: string } | { error: string }>) + .catch((e) => { + Sentry.captureException(e) + console.log('error', e) + throw e + }) + + if ('error' in r) { + throw new Error(r.error) + } + + return r +} + +export async function stopServe(id: string) { + console.log('[stopServe] ', id) + + const params = new URLSearchParams() + params.set('id', id) + + const r = await fetch(`http://localhost:5572/serve/stop?${params.toString()}`, { + method: 'POST', + headers: getAuthHeader(), + }) + .then((res) => res.json()) + .catch((e) => { + Sentry.captureException(e) + console.log('error', e) + throw e + }) + + if ('error' in r) { + throw new Error(r.error) + } +} + +export async function stopAllServes() { + console.log('[stopAllServes]') + + const r = await fetch('http://localhost:5572/serve/stopall', { + method: 'POST', + headers: getAuthHeader(), + }) + .then((res) => res.json()) + .catch((e) => { + Sentry.captureException(e) + console.log('error', e) + throw e + }) + + if ('error' in r) { + throw new Error(r.error) + } +} diff --git a/lib/store.ts b/lib/store.ts index 78e36ef..679fe33 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -33,10 +33,6 @@ interface State { rcloneAuth: string rcloneAuthHeader: string - serveList: { pid: number; protocol: string; remote: string }[] - setServeList: (serve: { pid: number; protocol: string; remote: string }) => void - removeServeList: (pid: number) => void - remotes: string[] setRemotes: (remotes: string[]) => void addRemote: (remote: string) => void @@ -147,12 +143,6 @@ export const useStore = create()( rcloneAuth: '', rcloneAuthHeader: '', - serveList: [], - setServeList: (serve: { pid: number; protocol: string; remote: string }) => - set((state) => ({ serveList: [...state.serveList, serve] })), - removeServeList: (pid: number) => - set((state) => ({ serveList: state.serveList.filter((s) => s.pid !== pid) })), - remotes: [], setRemotes: (remotes: string[]) => set((_) => ({ remotes })), addRemote: (remote: string) => diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fd1d6fc..073534e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,7 @@ "Settings", "Jobs", "Cron", + "Serve", "Browse", "Startup", "Test" diff --git a/src/main.tsx b/src/main.tsx index 921a288..6ba1f2e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,6 +12,7 @@ import Jobs from './pages/Jobs' import Mount from './pages/Mount' import Move from './pages/Move' import Purge from './pages/Purge' +import Serve from './pages/Serve' import Settings from './pages/Settings' import Startup from './pages/Startup' import Sync from './pages/Sync' @@ -69,6 +70,10 @@ const router = createBrowserRouter([ path: '/purge', element: , }, + { + path: '/serve', + element: , + }, { path: '/mount', element: , diff --git a/src/pages/Serve.tsx b/src/pages/Serve.tsx new file mode 100644 index 0000000..01b0a5c --- /dev/null +++ b/src/pages/Serve.tsx @@ -0,0 +1,357 @@ +import { Accordion, AccordionItem, Avatar, Button, Select, SelectItem } from '@heroui/react' +import { getCurrentWindow } from '@tauri-apps/api/window' +import { message } from '@tauri-apps/plugin-dialog' +import { + AlertOctagonIcon, + FilterIcon, + FoldersIcon, + PlayIcon, + ServerCrashIcon, + WavesLadderIcon, + XIcon, +} from 'lucide-react' +import { useEffect, useState } from 'react' +import { + getCurrentGlobalFlags, + getDlnaFlags, + getFilterFlags, + getFtpFlags, + getSftpFlags, + getVfsFlags, + startServe, +} from '../../lib/rclone/api' +import { RCLONE_VFS_DEFAULTS } from '../../lib/rclone/constants' +import { usePersistedStore, useStore } from '../../lib/store' +import { triggerTrayRebuild } from '../../lib/tray' +import type { FlagValue } from '../../types/rclone' +import OptionsSection from '../components/OptionsSection' + +const SERVE_TYPES = ['dlna', 'ftp', 'sftp'] as const + +export default function Serve() { + const remotes = useStore((state) => state.remotes) + const [remote, setRemote] = useState(undefined) + const [type, setType] = useState<(typeof SERVE_TYPES)[number] | undefined>(undefined) + + const [isServing, setIsServing] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [jsonError, setJsonError] = useState<'mount' | 'vfs' | 'filter' | 'config' | null>(null) + + const [serveOptionsLocked, setServeOptionsLocked] = useState(false) + const [serveOptions, setServeOptions] = useState>({}) + const [serveOptionsJson, setServeOptionsJson] = useState('{}') + + const [vfsOptionsLocked, setVfsOptionsLocked] = useState(false) + const [_, setVfsOptions] = useState>({}) + const [vfsOptionsJson, setVfsOptionsJson] = useState('{}') + + const [filterOptionsLocked, setFilterOptionsLocked] = useState(false) + const [filterOptions, setFilterOptions] = useState>({}) + const [filterOptionsJson, setFilterOptionsJson] = useState('{}') + + // const [configOptionsLocked, setConfigOptionsLocked] = useState(false) + // const [configOptions, setConfigOptions] = useState>({}) + // const [configOptionsJson, setConfigOptionsJson] = useState('{}') + + const [currentGlobalOptions, setCurrentGlobalOptions] = useState([]) + + const getAvailableOptions = + type === 'dlna' + ? getDlnaFlags + : type === 'ftp' + ? getFtpFlags + : type === 'sftp' + ? getSftpFlags + : () => Promise.resolve([]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: when unlocking, we don't want to re-run the effect + useEffect(() => { + const storeData = usePersistedStore.getState() + + if (!remote) return + + const remoteConfig = storeData.remoteConfigList?.[remote] + + if (!vfsOptionsLocked) { + if (remoteConfig?.vfsDefaults && Object.keys(remoteConfig.vfsDefaults).length > 0) { + setVfsOptionsJson(JSON.stringify(remoteConfig.vfsDefaults, null, 2)) + } else { + setVfsOptionsJson(JSON.stringify(RCLONE_VFS_DEFAULTS, null, 2)) + } + } + + if ( + remoteConfig?.filterDefaults && + Object.keys(remoteConfig.filterDefaults).length > 0 && + !filterOptionsLocked + ) { + setFilterOptionsJson(JSON.stringify(remoteConfig.filterDefaults, null, 2)) + } + + // if (!configOptionsLocked) { + // if ( + // storeData.remoteConfigList?.[remote]?.configDefaults && + // Object.keys(storeData.remoteConfigList?.[remote]?.configDefaults).length > 0 + // ) { + // setConfigOptionsJson( + // JSON.stringify(storeData.remoteConfigList[remote].configDefaults, null, 2) + // ) + // } else { + // setConfigOptionsJson(JSON.stringify(RCLONE_CONFIG_DEFAULTS, null, 2)) + // } + // } + }, [remote]) + + useEffect(() => { + getCurrentGlobalFlags().then((flags) => setCurrentGlobalOptions(flags)) + }, []) + + useEffect(() => { + let step: 'mount' | 'vfs' | 'filter' | 'config' = 'mount' + try { + setServeOptions(JSON.parse(serveOptionsJson)) + + step = 'vfs' + setVfsOptions(JSON.parse(vfsOptionsJson)) + + step = 'filter' + setFilterOptions(JSON.parse(filterOptionsJson)) + + // step = 'config' + // setConfigOptions(JSON.parse(configOptionsJson)) + + setJsonError(null) + } catch (error) { + setJsonError(step) + console.error(`[Mount] Error parsing ${step} options:`, error) + } + }, [serveOptionsJson, vfsOptionsJson, filterOptionsJson]) + + async function handleStartServe() { + if (!remote || !type) return + + setIsLoading(true) + + try { + await startServe({ + type, + fs: `${remote}:/`, + _filter: filterOptions as any, + ...serveOptions, + }) + + setIsServing(true) + + await triggerTrayRebuild() + setIsLoading(false) + } catch (err) { + console.error('[Mount] Failed to start mount:', err) + const errorMessage = + err instanceof Error ? err.message : 'Failed to start mount operation' + await message(errorMessage, { + title: 'Error', + kind: 'error', + }) + setIsLoading(false) + } + } + + const buttonText = (() => { + if (isLoading) return 'STARTING...' + if (isServing) return 'STARTED' + if (!remote) return 'Please select a remote' + if (!type) return 'Please select a serve type' + + if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' + + if (!('addr' in serveOptions)) return 'Specify a listen address in the Serve options' + return 'START SERVE' + })() + + console.log('serveOptions', serveOptions) + + const buttonIcon = (() => { + if (isLoading || isServing) return + if (!remote || !type) return + if (jsonError) return + if (!('addr' in serveOptions)) return + return + })() + + console.log('type', type) + return ( +
+
+ + + + + + {type ? ( + } + /> + } + indicator={} + subtitle="Tap to see Serve options for the current operation" + title="Serve" + > + + + ) : null} + } /> + } + indicator={} + subtitle="Tap to see VFS options for the current operation" + title="VFS" + > + + + } /> + } + indicator={} + subtitle="Tap to toggle filtering options for this operation" + title="Filters" + > + + + {/* } /> + } + indicator={} + subtitle="Tap to toggle config options for this operation" + title="Config" + > + + */} + +
+ +
+ {isServing ? ( + <> + + + + ) : ( + + )} +
+
+ ) +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7db4b89..48a9679 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -555,6 +555,21 @@ function GeneralSection() { > Show Move option + { + if (value) { + setDisabledActions( + disabledActions?.filter((action) => action !== 'tray-serve') || + [] + ) + } else { + setDisabledActions([...(disabledActions || []), 'tray-serve']) + } + }} + > + Show Serve option + { diff --git a/types/rclone.d.ts b/types/rclone.d.ts index 36a7a14..4c180f7 100644 --- a/types/rclone.d.ts +++ b/types/rclone.d.ts @@ -24,3 +24,5 @@ export interface Backend { Options: BackendOption[] Prefix: string } + +export type FlagValue = string | number | boolean | string[]