From c0aa02c198a0001f328d1341ab4f6605806756f9 Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:25:40 +0300 Subject: [PATCH] differentiate interactive backends --- lib/rclone/interactive.ts | 187 ++++++++++++++++++++++++++ lib/rclone/overrides.ts | 10 ++ src/components/RemoteCreateDrawer.tsx | 15 ++- src/components/RemoteEditDrawer.tsx | 11 +- 4 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 lib/rclone/interactive.ts diff --git a/lib/rclone/interactive.ts b/lib/rclone/interactive.ts new file mode 100644 index 0000000..662da17 --- /dev/null +++ b/lib/rclone/interactive.ts @@ -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 { + try { + const result = await invoke('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 { + 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, + opt: Record +): Promise { + 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 { + 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 +}): Promise { + 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 +} diff --git a/lib/rclone/overrides.ts b/lib/rclone/overrides.ts index e13f5e6..0087bcc 100644 --- a/lib/rclone/overrides.ts +++ b/lib/rclone/overrides.ts @@ -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', diff --git a/src/components/RemoteCreateDrawer.tsx b/src/components/RemoteCreateDrawer.tsx index 2366e09..706f7ab 100644 --- a/src/components/RemoteCreateDrawer.tsx +++ b/src/components/RemoteCreateDrawer.tsx @@ -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 }) => { 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')) { diff --git a/src/components/RemoteEditDrawer.tsx b/src/components/RemoteEditDrawer.tsx index 39387d6..4d54a1d 100644 --- a/src/components/RemoteEditDrawer.tsx +++ b/src/components/RemoteEditDrawer.tsx @@ -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 } + ), }, }, })