188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
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
|
|
}
|