differentiate interactive backends

This commit is contained in:
FTCHD
2026-08-20 00:25:40 +03:00
parent 7cfb44d47a
commit c0aa02c198
4 changed files with 218 additions and 5 deletions
+187
View File
@@ -0,0 +1,187 @@
import { invoke } from '@tauri-apps/api/core'
import { message } from '@tauri-apps/plugin-dialog'
import type { ConfigStep } from 'rclone-sdk'
import type { BackendOption } from '../../types/rclone'
import { UserCancelledError } from '../errors'
import rclone from './client'
const PROMPT_TITLE = 'Configure remote'
async function nativePrompt(args: {
message: string
default?: string | null
sensitive?: boolean
}): Promise<string | null> {
try {
const result = await invoke<string | null>('prompt', {
title: PROMPT_TITLE,
message: args.message,
default: args.default ?? null,
sensitive: args.sensitive ?? false,
})
return typeof result === 'string' ? result : null
} catch (error) {
console.error('[interactive] native prompt failed', error)
return null
}
}
function firstLine(text: string): string {
return (text.split('\n')[0] ?? '').trim()
}
function defaultString(option: BackendOption): string {
if (option.Default !== undefined && option.Default !== null) return String(option.Default)
return option.DefaultStr ?? ''
}
const YES = new Set(['y', 'yes', 'true', '1'])
const NO = new Set(['n', 'no', 'false', '0'])
// Maps one config-machine question (rclone `Option`) onto the native text prompt and returns the
// `result` string to send back — or null if the user cancelled. Because the dialog is text-only:
// - bool -> ask for y/n, return "true"/"false"
// - choice (Examples) -> render a numbered list, return the chosen example's *Value* (not the
// ordinal). Exclusive lists require a valid number; non-exclusive ones also accept free text.
// - plain -> a single text field (masked when the option is a password/secret)
export async function promptForConfigOption(option: BackendOption): Promise<string | null> {
const help = (option.Help || option.Name || '').trim()
const def = defaultString(option)
if (option.Type === 'bool') {
const message = `${help}\n\n(type y or n)`
const boolDefault = def === 'true' ? 'y' : 'n'
while (true) {
const answer = await nativePrompt({ message, default: boolDefault })
if (answer === null) return null
const norm = answer.trim().toLowerCase()
if (YES.has(norm)) return 'true'
if (NO.has(norm)) return 'false'
// invalid → re-ask
}
}
const examples = option.Examples ?? []
if (examples.length > 0) {
const lines = examples.map((ex, i) => {
const label = ex.Help ? `${ex.Value}${firstLine(ex.Help)}` : ex.Value
return `${i + 1}) ${label}`
})
const message = `${help}\n\n${lines.join('\n')}\n\nEnter a number:`
const defaultIndex = examples.findIndex((ex) => ex.Value === def)
const defaultNumber = defaultIndex >= 0 ? String(defaultIndex + 1) : '1'
while (true) {
const answer = await nativePrompt({ message, default: defaultNumber })
if (answer === null) return null
const trimmed = answer.trim()
const n = Number.parseInt(trimmed, 10)
if (Number.isInteger(n) && n >= 1 && n <= examples.length) {
return examples[n - 1].Value
}
// A fixed/exclusive list must match a number; a free-form list accepts a typed value.
if (!option.Exclusive && trimmed !== '') return trimmed
// invalid → re-ask
}
}
return nativePrompt({
message: help,
default: def,
sensitive: option.IsPassword || option.Sensitive,
})
}
async function callCreate(
name: string,
type: string,
parameters: Record<string, unknown>,
opt: Record<string, unknown>
): Promise<ConfigStep> {
const data = await rclone('/config/create', {
params: {
query: {
name,
type,
parameters: JSON.stringify(parameters),
opt: JSON.stringify(opt),
},
},
})
// The SDK types this response as an empty object; the daemon actually returns the ConfigOut.
return data as unknown as ConfigStep
}
async function safeDeleteRemote(name: string): Promise<void> {
try {
await rclone('/config/delete', { params: { query: { name } } })
} catch (error) {
console.error('[interactive] failed to clean up partial remote', name, error)
}
}
// drives the RC config state machine to completion.
export async function createRemoteInteractive({
name,
type,
parameters,
}: {
name: string
type: string
parameters: Record<string, unknown>
}): Promise<string> {
try {
let step = await callCreate(name, type, parameters, {
nonInteractive: true,
obscure: true,
})
while (step.State !== '') {
if (step.Option) {
const answer = await promptForConfigOption(step.Option)
if (answer === null) {
await safeDeleteRemote(name)
throw new UserCancelledError('Remote creation cancelled')
}
step = await callCreate(
name,
type,
{},
{ nonInteractive: true, continue: true, state: step.State, result: answer }
)
continue
}
if (step.Error) {
// Soft error (e.g. a search returned no results): show it, then re-enter the state
// rclone pointed back to, which will present the next question.
await message(step.Error, { title: 'Configuration', kind: 'error' })
step = await callCreate(
name,
type,
{},
{
nonInteractive: true,
continue: true,
state: step.State,
result: step.Result ?? '',
}
)
continue
}
// Non-empty state with neither a question nor an error shouldn't happen (the daemon
// follows such transitions internally) — bail rather than spin.
console.warn('[interactive] unexpected step with no option/error', step)
break
}
} catch (error) {
if (error instanceof UserCancelledError) throw error
// Mid-flow failure (e.g. a rejected token surfaced as a 500)
// clean up the partial remote
// before surfacing the error to the caller's onError handler.
await safeDeleteRemote(name)
throw error
}
return name
}
+10
View File
@@ -1,5 +1,15 @@
export const OWN_OAUTH_TYPES = ['drive', 'google photos']
export const INTERACTIVE_CONFIG_TYPES = [
'jottacloud',
'onedrive',
'zoho',
'seafile',
'sugarsync',
'iclouddrive',
'internxt',
]
export const OVERRIDES = {
alias: {
Description: 'ALIAS',
+12 -3
View File
@@ -5,9 +5,10 @@ import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import { ChevronDown, ChevronUp, RefreshCcwIcon } from 'lucide-react'
import { type Key, startTransition, useCallback, useMemo, useState } from 'react'
import { UserCancelledError } from '../../lib/errors'
import rclone from '../../lib/rclone/client'
import { OVERRIDES, OWN_OAUTH_TYPES } from '../../lib/rclone/overrides'
import type { BackendOption } from '../../types/rclone'
import { createRemoteInteractive } from '../../lib/rclone/interactive'
import { INTERACTIVE_CONFIG_TYPES, OVERRIDES, OWN_OAUTH_TYPES } from '../../lib/rclone/overrides'
import RemoteField from './RemoteField'
export default function RemoteCreateDrawer({
@@ -51,7 +52,7 @@ export default function RemoteCreateDrawer({
const currentBackendFields = useMemo(
() =>
currentBackend
? (currentBackend.Options as BackendOption[]).filter((opt) => {
? currentBackend.Options.filter((opt) => {
if (!opt.Provider) return true
if (opt.Provider.includes(config.provider) && !opt.Provider.startsWith('!'))
return true
@@ -85,6 +86,10 @@ export default function RemoteCreateDrawer({
}: { name: string; type: string; parameters: Record<string, any> }) => {
console.log('[RemoteCreateDrawer] newRemoteConfig', name, type, parameters)
if (INTERACTIVE_CONFIG_TYPES.includes(type)) {
return createRemoteInteractive({ name, type, parameters })
}
await rclone('/config/create', {
params: {
query: {
@@ -110,6 +115,10 @@ export default function RemoteCreateDrawer({
onError: async (error) => {
console.error('Failed to create remote:', error)
if (error instanceof UserCancelledError) {
return
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
if (errorMessage.includes('address already in use')) {
+9 -2
View File
@@ -8,7 +8,7 @@ import { onErrorDialog } from '../../lib/errors'
import { useRemoteConfig } from '../../lib/hooks'
import queryClient from '../../lib/query'
import rclone from '../../lib/rclone/client'
import { OVERRIDES, OWN_OAUTH_TYPES } from '../../lib/rclone/overrides'
import { INTERACTIVE_CONFIG_TYPES, OVERRIDES, OWN_OAUTH_TYPES } from '../../lib/rclone/overrides'
import RemoteField from './RemoteField'
export default function RemoteEditDrawer({
@@ -99,12 +99,19 @@ export default function RemoteEditDrawer({
console.log('[RemoteEditDrawer] updatedRemoteConfig', updatedRemoteConfig)
if (Object.keys(updatedRemoteConfig).length > 0) {
const isInteractiveType = INTERACTIVE_CONFIG_TYPES.includes(
remoteConfig?.type ?? ''
)
await rclone('/config/update', {
params: {
query: {
name: remoteName,
parameters: JSON.stringify(updatedRemoteConfig),
opt: JSON.stringify({ obscure: true }),
opt: JSON.stringify(
isInteractiveType
? { obscure: true, nonInteractive: true }
: { obscure: true }
),
},
},
})