remote capability check

This commit is contained in:
FTCHD
2026-07-11 22:19:24 +03:00
parent 2f98e8b7da
commit 4a8669db17
13 changed files with 133 additions and 209 deletions
+31
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import type { RcloneFeatures, RcloneFsInfo } from '../types/rclone'
import { sortByName } from './flags'
import rclone from './rclone/client'
import { SERVE_TYPES } from './rclone/constants'
@@ -37,6 +38,36 @@ export function useRemoteConfig(remote: string | undefined | null) {
return useQuery(remoteConfigQueryOptions(remote))
}
// Shared query options for a remote's `operations/fsinfo` — the authoritative, per-remote backend
// capability set (correct even for wrapping backends like crypt/alias/union, which the old static
// type lists could not express). fsinfo instantiates/connects the remote, so it's cached hard:
// capabilities are ~immutable per config and only change on a remote edit (explicit invalidation)
// or an rclone upgrade (self-heals on the 24h staleTime / next cold launch). retry: 1 overrides the
// app-wide 3-retry default so an unreachable remote costs one attempt, then error-caches.
export function fsInfoQueryOptions(remote: string | undefined | null) {
return {
queryKey: ['remote', remote, 'fsinfo'] as const,
queryFn: () =>
rclone('/operations/fsinfo', { params: { query: { fs: `${remote}:` } } }),
enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
staleTime: 1000 * 60 * 60 * 24,
retry: 1,
}
}
export function useFsInfo(remote: string | undefined | null) {
return useQuery(fsInfoQueryOptions(remote))
}
// undefined while loading/errored → false. Callers that need an optimistic default (e.g. create
// folder, which historically assumed "supported" until proven otherwise) handle that explicitly.
export function hasFeature(
fsInfo: RcloneFsInfo | undefined,
feature: keyof RcloneFeatures
): boolean {
return !!fsInfo?.Features?.[feature]
}
export function useFlags() {
const allFlagsQuery = useQuery({
queryKey: ['options', 'all'],
+4 -139
View File
@@ -28,142 +28,7 @@ export const RCLONE_RELEASES_SHOWN = 20
export const SERVE_TYPES = ['dlna', 'ftp', 'sftp', 'http', 'nfs', 'restic', 's3', 'webdav'] as const
export const SUPPORTS_CLEANUP = [
's3',
'b2',
'box',
'filefabric',
'filen',
'drive',
'internetarchive',
'jottacloud',
'mailru',
'mega',
'onedrive',
'oos',
'pcloud',
'pikpak',
'putio',
'protondrive',
'qingstor',
'seafile',
'yandex',
] as const
export const SUPPORTS_PURGE = [
'netstorage',
'box',
'sharefile',
'dropbox',
'filefabric',
'filescom',
'filen',
'gofile',
'gcs',
'drive',
'hdfs',
'hifile',
'iclouddrive',
'imagekit',
'jottacloud',
'koofr',
'mailru',
'mega',
'azureblob',
'onedrive',
'opendrive',
'swift',
'pikpak',
'pcloud',
'pixeldrain',
'premiumizeme',
'putio',
'protondrive',
'quatrix',
'seafile',
'sugarsync',
'storj',
'webdav',
'yandex',
'zoho',
] as const
export const SUPPORTS_ABOUT = [
'box',
'dropbox',
'gofile',
'drive',
'filen',
'hdfs',
'internetarchive',
'jottacloud',
'koofr',
'mailru',
'mega',
'azurefiles',
'onedrive',
'opendrive',
'swift',
'pcloud',
'pikpak',
'pixeldrain',
'premiumizeme',
'putio',
'protondrive',
'quatrix',
'seafile',
'sftp',
'webdav',
'yandex',
'zoho',
'local',
] as const
export const SUPPORTS_LINK = [
'b2',
'box',
'drive',
'dropbox',
'fichier',
'filescom',
'gofile',
'imagekit',
'internetarchive',
'jottacloud',
'koofr',
'mailru',
'mega',
'onedrive',
'pikpak',
'pixeldrain',
'premiumizeme',
'pcloud',
's3',
'seafile',
'storj',
'sugarsync',
'yandex',
] as const
export function supportsPublicLink(backendType?: string | null): boolean {
if (!backendType) return false
return SUPPORTS_LINK.includes(backendType.toLowerCase() as any)
}
export const CANNOT_PERSIST_EMPTY_FOLDERS = [
's3',
'gcs',
'azureblob',
'b2',
'swift',
'oracleobjectstorage',
'oos',
'qingstor',
'storj',
'memory',
]
export function supportsPersistentEmptyFolders(backendType?: string | null) {
if (!backendType) return true
return !CANNOT_PERSIST_EMPTY_FOLDERS.includes(backendType.toLowerCase())
}
// Backend capabilities are no longer hardcoded here. They are read per-remote from the rclone RC
// `operations/fsinfo` endpoint (see `fsInfoQueryOptions` / `hasFeature` in lib/hooks.ts), which is
// authoritative and correct for wrapping backends (crypt/alias/union) that static type lists could
// not express.
+3
View File
@@ -111,6 +111,9 @@ export default function RemoteEditDrawer({
...updatedRemoteConfig,
})
)
// Capabilities can change with the config (e.g. s3 provider, webdav vendor, a wrapped
// backend's target), so drop the cached fsinfo probe and let consumers re-fetch.
queryClient.invalidateQueries({ queryKey: ['remote', remoteName, 'fsinfo'] })
onClose()
},
onError: onErrorDialog('Could not update remote', 'Unknown error occurred', {
+2 -2
View File
@@ -341,8 +341,8 @@ export default function ScheduleEditDrawer({
</Tabs>
<span className="text-tiny text-default-400">
{runMode === 'user'
? 'Runs only while you are logged in, inside your session OS keychain passwords and session-mounted drives work; fires while logged out are skipped. On macOS it runs as Rclone UI, so protected folders work once you grant the app access.'
: 'Runs even while logged out, but outside your login session — no OS keychain or session-mounted drives, and protected folders on macOS need Full Disk Access for cron.'}
? 'Runs only while you are logged in, inside your session. OS keychain passwords and session-mounted drives work; fires while logged out are skipped. On macOS it runs as Rclone UI, so protected folders work once you grant the app access.'
: 'Runs even while logged out, but outside your login session. No OS keychain or session-mounted drives, and protected folders on macOS need Full Disk Access for cron.'}
</span>
</div>
<Switch
+5 -5
View File
@@ -11,8 +11,7 @@ import {
useRef,
useState,
} from 'react'
import { remoteConfigQueryOptions } from '../../../lib/hooks'
import { supportsPublicLink } from '../../../lib/rclone/constants'
import { fsInfoQueryOptions, hasFeature } from '../../../lib/hooks'
import { useHostStore } from '../../../store/host.ts'
import FileList from './FileList'
import PanelToolbar, { type ToolbarButtons } from './PanelToolbar'
@@ -89,12 +88,13 @@ const FilePanel = forwardRef<
isActive,
})
const remoteConfigQuery = useQuery({
...remoteConfigQueryOptions(nav.selectedRemote),
const fsInfoQuery = useQuery({
...fsInfoQueryOptions(nav.selectedRemote),
enabled: nav.isRemote,
})
const canShare = supportsPublicLink(remoteConfigQuery.data?.type)
// false while loading — matches the previous default (hide the share affordance until confirmed).
const canShare = hasFeature(fsInfoQuery.data, 'PublicLink')
const { canCreateFolder, createFolder } = useCreateFolder(
nav.selectedRemote,
+6 -11
View File
@@ -3,26 +3,21 @@ import { message } from '@tauri-apps/plugin-dialog'
import { useCallback, useMemo } from 'react'
import { reportError } from '../../../lib/errors'
import { getFsInfo } from '../../../lib/format'
import { useRemoteConfig } from '../../../lib/hooks'
import { hasFeature, useFsInfo } from '../../../lib/hooks'
import rclone from '../../../lib/rclone/client'
import { supportsPersistentEmptyFolders } from '../../../lib/rclone/constants'
import type { RemoteString } from './types'
import { RE_TRAILING_SEPARATORS } from './utils'
export default function useCreateFolder(remote: RemoteString, cwd: string, refresh: () => void) {
const remoteConfigQuery = useRemoteConfig(remote)
const backendType = useMemo(() => {
if (!remote || remote === 'UI_FAVORITES') return null
if (remote === 'UI_LOCAL_FS') return 'local'
return remoteConfigQuery.data?.type ?? null
}, [remote, remoteConfigQuery.data])
const fsInfoQuery = useFsInfo(remote)
const canCreateFolder = useMemo(() => {
if (!remote || remote === 'UI_FAVORITES') return false
if (remote === 'UI_LOCAL_FS') return true
return supportsPersistentEmptyFolders(backendType)
}, [remote, backendType])
// Optimistic while probing: the previous static check defaulted to "supported" for unknown
// types, so keep that until fsinfo actually says the backend can't persist empty folders.
return fsInfoQuery.data ? hasFeature(fsInfoQuery.data, 'CanHaveEmptyDirectories') : true
}, [remote, fsInfoQuery.data])
const createFolder = useCallback(async () => {
if (!remote || remote === 'UI_FAVORITES') return
+6 -8
View File
@@ -6,10 +6,10 @@ import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { getRemoteName } from '../../lib/format'
import { useFlags, useRemoteConfig } from '../../lib/hooks'
import { hasFeature, useFlags, useFsInfo } from '../../lib/hooks'
import { notify } from '../../lib/notifications'
import { startDelete, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS, SUPPORTS_PURGE } from '../../lib/rclone/constants'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
@@ -79,14 +79,12 @@ export default function Delete() {
const sourceRemoteName = useMemo(() => getRemoteName(sourceFs), [sourceFs])
const sourceRemoteConfigQuery = useRemoteConfig(sourceRemoteName)
const sourceFsInfoQuery = useFsInfo(sourceRemoteName)
// false while loading — matches the previous default (offer the plain delete until purge is confirmed).
const supportsPurge = useMemo(
() =>
sourceRemoteConfigQuery.data
? SUPPORTS_PURGE.includes(sourceRemoteConfigQuery.data.type)
: false,
[sourceRemoteConfigQuery.data]
() => hasFeature(sourceFsInfoQuery.data, 'Purge'),
[sourceFsInfoQuery.data]
)
const buildArgs = () => ({
+9 -31
View File
@@ -9,7 +9,7 @@ import {
Input,
Spinner,
} from '@heroui/react'
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import {
@@ -25,9 +25,8 @@ import { type ReactNode, startTransition, useEffect, useMemo, useState } from 'r
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../../lib/errors'
import { formatBytes } from '../../../lib/format'
import { remoteConfigQueryOptions } from '../../../lib/hooks'
import { hasFeature, remoteConfigQueryOptions, useFsInfo } from '../../../lib/hooks'
import rclone from '../../../lib/rclone/client'
import { SUPPORTS_ABOUT } from '../../../lib/rclone/constants'
import { usePersistedStore } from '../../../store/persisted'
import RemoteAutoMountDrawer from '../../components/RemoteAutoMountDrawer'
import RemoteCreateDrawer from '../../components/RemoteCreateDrawer'
@@ -53,33 +52,10 @@ export default function RemotesSection() {
const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data])
const remoteConfigQueries = useQueries({
queries: remotes.map((remote) => ({
...remoteConfigQueryOptions(remote),
staleTime: 1000 * 60,
})),
})
const sortedRemotes = useMemo(() => {
// useQueries preserves input order, so remoteConfigQueries[i] corresponds to remotes[i].
const typeByRemote = new Map<string, string | null>()
remotes.forEach((remote, i) => {
typeByRemote.set(remote, remoteConfigQueries[i]?.data?.type ?? null)
})
return [...remotes].sort((a, b) => {
const aType = typeByRemote.get(a)
const bType = typeByRemote.get(b)
const aSupportsAbout = aType ? SUPPORTS_ABOUT.includes(aType) : false
const bSupportsAbout = bType ? SUPPORTS_ABOUT.includes(bType) : false
if (aSupportsAbout && !bSupportsAbout) return -1
if (!aSupportsAbout && bSupportsAbout) return 1
return a.localeCompare(b)
})
}, [remotes, remoteConfigQueries])
const sortedRemotes = useMemo(
() => [...remotes].sort((a, b) => a.localeCompare(b)),
[remotes]
)
const [searchQuery, setSearchQuery] = useState('')
@@ -333,7 +309,9 @@ function RemoteCard({
const type = useMemo(() => remoteConfigData?.type ?? null, [remoteConfigData?.type])
const provider = useMemo(() => remoteConfigData?.provider ?? null, [remoteConfigData?.provider])
const supportsAbout = useMemo(() => !!type && SUPPORTS_ABOUT.includes(type), [type])
const fsInfoQuery = useFsInfo(remote)
const supportsAbout = hasFeature(fsInfoQuery.data, 'About')
const { data: remoteAboutData } = useQuery({
queryKey: ['remotes', remote, 'about'],
+31 -3
View File
@@ -1,14 +1,16 @@
import { Divider, Kbd, ScrollShadow, cn } from '@heroui/react'
import { useQuery } from '@tanstack/react-query'
import { useQueries, useQuery } from '@tanstack/react-query'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { platform } from '@tauri-apps/plugin-os'
import type { KeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useDebounce } from 'use-debounce'
import { fsInfoQueryOptions } from '../../lib/hooks'
import { fetchMountList, fetchServeList } from '../../lib/rclone/api'
import rclone from '../../lib/rclone/client'
import { openWindow } from '../../lib/window'
import { type ResolvedToolbarResult, runToolbarEngine } from '../../toolbar/engine'
import type { RcloneFeatures } from '../../types/rclone'
const toolbarWindow = getCurrentWebviewWindow()
const isWindows = platform() === 'windows'
@@ -111,6 +113,19 @@ export default function Toolbar() {
const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data])
const remoteTypes = useMemo(() => remoteTypesQuery.data ?? {}, [remoteTypesQuery.data])
// Eager per-remote capability probes (operations/fsinfo), cached hard. Feeds the synchronous
// engine so cleanup/purge can gate on the authoritative feature set. Unresolved/unreachable
// remotes are simply absent from the map → those actions fall to their generic item.
const fsInfoQueries = useQueries({ queries: remotes.map((remote) => fsInfoQueryOptions(remote)) })
const capabilitiesByRemote = useMemo(() => {
const map: Record<string, RcloneFeatures> = {}
remotes.forEach((remote, i) => {
const features = fsInfoQueries[i]?.data?.Features
if (features) map[remote] = features
})
return map
}, [remotes, fsInfoQueries])
const [searchString, setSearchString] = useState('')
const [searchStringDebounced] = useDebounce(searchString, 40)
@@ -127,11 +142,24 @@ export default function Toolbar() {
console.log(`${serveList?.length} serves`)
console.log(`${vfsList?.length} vfses`)
const { results } = runToolbarEngine(searchStringDebounced, remotes, remoteTypes)
const { results } = runToolbarEngine(
searchStringDebounced,
remotes,
remoteTypes,
capabilitiesByRemote
)
startTransition(() => {
setEngineResults(results)
})
}, [mountList, serveList, vfsList, searchStringDebounced, remotes, remoteTypes])
}, [
mountList,
serveList,
vfsList,
searchStringDebounced,
remotes,
remoteTypes,
capabilitiesByRemote,
])
useEffect(() => {
let unlisten: (() => void) | undefined
+3 -7
View File
@@ -8,7 +8,7 @@ import { notify } from '../lib/notifications'
import queryClient from '../lib/query'
import type { fetchMountList, fetchServeList } from '../lib/rclone/api'
import rclone from '../lib/rclone/client'
import { SERVE_TYPES, SUPPORTS_CLEANUP, SUPPORTS_PURGE } from '../lib/rclone/constants'
import { SERVE_TYPES } from '../lib/rclone/constants'
import { openFullWindow } from '../lib/window'
import { selectCurrentHost, usePersistedStore } from '../store/persisted'
import { COMMAND_CONFIG, COMMAND_DESCRIPTIONS, COMMAND_KEYWORDS } from './constants'
@@ -656,9 +656,7 @@ const actions: ToolbarActionDefinition[] = [
return []
}
const supportedRemotePaths = paths.filter(
(path) => path.remoteType && SUPPORTS_CLEANUP.includes(path.remoteType)
)
const supportedRemotePaths = paths.filter((path) => !!path.features?.CleanUp)
if (supportedRemotePaths.length === 0) {
return [createBaseResult('Cleanup', COMMAND_DESCRIPTIONS.cleanup, {}, 36)]
@@ -878,9 +876,7 @@ const actions: ToolbarActionDefinition[] = [
return []
}
const supportedPaths = paths.filter(
(path) => path.remoteType && SUPPORTS_PURGE.includes(path.remoteType)
)
const supportedPaths = paths.filter((path) => !!path.features?.Purge)
if (supportedPaths.length === 0) {
return [createBaseResult('Purge', COMMAND_DESCRIPTIONS.purge, {}, 36)]
+7 -3
View File
@@ -1,4 +1,5 @@
import { sep } from '@tauri-apps/api/path'
import type { RcloneFeatures } from '../types/rclone'
import { getToolbarAction, getToolbarActions } from './actions'
import type {
ToolbarActionArgs,
@@ -22,13 +23,14 @@ export interface ResolvedToolbarResult {
export function runToolbarEngine(
query: string,
remotes: string[],
remoteTypes?: Record<string, string>
remoteTypes?: Record<string, string>,
capabilitiesByRemote?: Record<string, RcloneFeatures>
) {
const actions = getToolbarActions()
const trimmed = query.trim()
const parsedPaths = extractPaths(trimmed, remotes, remoteTypes)
const parsedPaths = extractPaths(trimmed, remotes, remoteTypes, capabilitiesByRemote)
const cleanedQuery = trimmed.replace(parsedPaths.map((path) => path.full).join(' '), '').trim()
@@ -120,7 +122,8 @@ const NON_WHITESPACE_TOKEN_REGEX = /^(\S+)/
function extractPaths(
input: string,
remotes: string[],
remoteTypes?: Record<string, string>
remoteTypes?: Record<string, string>,
capabilitiesByRemote?: Record<string, RcloneFeatures>
): ToolbarActionPath[] {
const seen = new Set<string>()
const results: ToolbarActionPath[] = []
@@ -178,6 +181,7 @@ function extractPaths(
isLocal,
remoteName,
remoteType,
features: remoteName ? capabilitiesByRemote?.[remoteName] : undefined,
})
}
}
+5
View File
@@ -1,3 +1,5 @@
import type { RcloneFeatures } from '../types/rclone'
export type ToolbarCommandId =
| 'copy'
| 'move'
@@ -43,6 +45,9 @@ export interface ToolbarActionPath {
isLocal: boolean
remoteName?: string
remoteType?: string
// Authoritative backend capability set from `operations/fsinfo`; undefined until the probe
// resolves (or for local paths), in which case capability-gated actions fall to their generic item.
features?: RcloneFeatures
}
export interface ToolbarActionContext {
+21
View File
@@ -26,3 +26,24 @@ export interface Backend {
}
export type FlagValue = string | number | boolean | string[] | null
// Response shape of the rclone RC `operations/fsinfo` endpoint. The SDK types `Features` only as
// an open `{ [k: string]: boolean }` map; this names the keys we actually gate on. Keys are the
// PascalCase names rclone emits from `Features.Enabled()` (fs/features.go).
export interface RcloneFeatures {
About?: boolean
Purge?: boolean
CleanUp?: boolean
PublicLink?: boolean
CanHaveEmptyDirectories?: boolean
[key: string]: boolean | undefined
}
export interface RcloneFsInfo {
Name?: string
Root?: string
String?: string
Precision?: number
Hashes?: string[]
Features?: RcloneFeatures
}