manage multiple configs, spawn updates
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { appLocalDataDir } from '@tauri-apps/api/path'
|
||||
import { exists } from '@tauri-apps/plugin-fs'
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { useStore } from '../store'
|
||||
@@ -561,3 +563,49 @@ export async function getMountFlags() {
|
||||
|
||||
return filteredFlags
|
||||
}
|
||||
|
||||
export async function getConfigPath({ id, validate = true }: { id: string; validate?: boolean }) {
|
||||
console.log('[getConfigPath]', id, validate)
|
||||
|
||||
const appLocalDataDirPath = await appLocalDataDir()
|
||||
console.log('[getConfigPath] appLocalDataDirPath', appLocalDataDirPath)
|
||||
|
||||
let configPath = `${appLocalDataDirPath}/configs/${id}/rclone.conf`
|
||||
|
||||
if (id == 'default') {
|
||||
const defaultPaths = await getDefaultPaths()
|
||||
|
||||
if (typeof defaultPaths?.config === 'undefined') {
|
||||
console.error('[getConfigPath] failed to fetch config path')
|
||||
throw new Error('Failed to fetch config path')
|
||||
}
|
||||
|
||||
configPath = defaultPaths.config
|
||||
}
|
||||
|
||||
const configExists = await exists(configPath)
|
||||
if (validate && !configExists) {
|
||||
console.error('[getConfigPath] config file does not exist')
|
||||
throw new Error('Config file does not exist')
|
||||
}
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
export async function getDefaultPaths() {
|
||||
console.log('[getDefaultPaths]')
|
||||
|
||||
const r = await fetch('http://localhost:5572/config/paths', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
throw new Error('Failed to make request to config/paths')
|
||||
}
|
||||
|
||||
const defaultPaths = (await r.json()) as { cache: string; config: string; temp: string }
|
||||
|
||||
console.log('[getDefaultPaths] json', JSON.stringify(defaultPaths, null, 2))
|
||||
|
||||
return defaultPaths
|
||||
}
|
||||
|
||||
+155
-16
@@ -1,36 +1,174 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { BaseDirectory, appLocalDataDir } from '@tauri-apps/api/path'
|
||||
import { tempDir } from '@tauri-apps/api/path'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { copyFile, exists, mkdir, remove } from '@tauri-apps/plugin-fs'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { copyFile, exists, mkdir, readTextFile, remove } from '@tauri-apps/plugin-fs'
|
||||
import { writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { exit } from '@tauri-apps/plugin-process'
|
||||
import { Command } from '@tauri-apps/plugin-shell'
|
||||
import { usePersistedStore } from '../store'
|
||||
import { getConfigPath, getDefaultPaths } from './api'
|
||||
|
||||
export async function initRclone(args: string[]) {
|
||||
console.log('[initRclone]')
|
||||
|
||||
export async function initRclone() {
|
||||
const system = await isSystemRcloneInstalled()
|
||||
let internal = await isInternalRcloneInstalled()
|
||||
|
||||
// rclone not available, let's download it
|
||||
if (!system && !internal) {
|
||||
await provisionRclone()
|
||||
const success = await provisionRclone()
|
||||
if (!success) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
await exit(0)
|
||||
return
|
||||
}
|
||||
internal = true
|
||||
}
|
||||
|
||||
return {
|
||||
system: system
|
||||
? async (args: string[]) => {
|
||||
console.log('running system rclone')
|
||||
return Command.create('rclone-system', args)
|
||||
const state = usePersistedStore.getState()
|
||||
let configFiles = state.configFiles
|
||||
let activeConfigFile = state.activeConfigFile
|
||||
const defaultPath = await getDefaultPath(system ? 'system' : 'internal')
|
||||
|
||||
if (configFiles.length === 0) {
|
||||
if (system) {
|
||||
let isEncrypted = false
|
||||
|
||||
// Detect if the config is encrypted
|
||||
try {
|
||||
const configContent = await readTextFile(defaultPath)
|
||||
isEncrypted = configContent.includes('RCLONE_ENCRYPT_V0:')
|
||||
} catch (error) {
|
||||
console.log('[initRclone] could not read config file, asking user:', error)
|
||||
isEncrypted = await ask(
|
||||
'Is your configuration encrypted? Press "No" if you\'re unsure or using the default config file.',
|
||||
{
|
||||
title: 'Config file found',
|
||||
kind: 'info',
|
||||
okLabel: 'Yes',
|
||||
cancelLabel: 'No',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
await ask(
|
||||
'Encrypted config files cannot be imported during the initial setup. Use a blank conf file and import the encrypted configuration later in Settings.',
|
||||
{
|
||||
title: 'Not supported yet',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
cancelLabel: '',
|
||||
}
|
||||
)
|
||||
await exit(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configFiles = configFiles.filter((config) => config.id !== 'default')
|
||||
configFiles.unshift({
|
||||
id: 'default',
|
||||
label: 'Default config',
|
||||
sync: undefined,
|
||||
isEncrypted: false,
|
||||
pass: undefined,
|
||||
})
|
||||
usePersistedStore.setState({ configFiles })
|
||||
|
||||
if (!activeConfigFile) {
|
||||
activeConfigFile = configFiles[0]
|
||||
if (!activeConfigFile) {
|
||||
throw new Error('Failed to get active config file')
|
||||
}
|
||||
|
||||
usePersistedStore.setState({ activeConfigFile })
|
||||
}
|
||||
|
||||
const extraParams =
|
||||
activeConfigFile.id === 'default'
|
||||
? undefined
|
||||
: {
|
||||
env: {
|
||||
...(activeConfigFile.isEncrypted
|
||||
? { RCLONE_CONFIG_PASS: activeConfigFile.pass }
|
||||
: {}),
|
||||
RCLONE_CONFIG_DIR: (
|
||||
await getConfigPath({ id: activeConfigFile.id!, validate: true })
|
||||
).replace(/\/rclone\.conf$/, ''),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
internal: internal
|
||||
? async (args: string[]) => {
|
||||
console.log('running internal rclone')
|
||||
return Command.create('rclone-internal', args)
|
||||
}
|
||||
: null,
|
||||
|
||||
if (system) {
|
||||
console.log('[initRclone] running system rclone')
|
||||
const instance = Command.create('rclone-system', args, extraParams)
|
||||
return { system: instance }
|
||||
}
|
||||
if (internal) {
|
||||
console.log('[initRclone] running internal rclone')
|
||||
const instance = Command.create('rclone-internal', args, extraParams)
|
||||
return { internal: instance }
|
||||
}
|
||||
|
||||
throw new Error('Failed to initialize rclone, please try again later.')
|
||||
}
|
||||
|
||||
async function getDefaultPath(type: 'system' | 'internal') {
|
||||
console.log('[getDefaultPath]', type)
|
||||
|
||||
let instance = null
|
||||
if (type === 'system') {
|
||||
console.log('[getDefaultPath] running system rclone')
|
||||
instance = Command.create('rclone-system', [
|
||||
'rcd',
|
||||
'--rc-no-auth',
|
||||
'--rc-serve',
|
||||
// '-rc-addr',
|
||||
// ':5572',
|
||||
])
|
||||
}
|
||||
if (type === 'internal') {
|
||||
console.log('[getDefaultPath] running internal rclone')
|
||||
instance = Command.create('rclone-internal', [
|
||||
'rcd',
|
||||
'--rc-no-auth',
|
||||
'--rc-serve',
|
||||
// '-rc-addr',
|
||||
// ':5572',
|
||||
])
|
||||
}
|
||||
|
||||
if (!instance) {
|
||||
console.error('[getDefaultPath] failed to create rclone instance')
|
||||
throw new Error('Failed to create rclone instance, please try again later.')
|
||||
}
|
||||
|
||||
const output = await instance.spawn()
|
||||
|
||||
console.log('[getDefaultPath] spawned rclone')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
|
||||
try {
|
||||
const defaultPaths = await getDefaultPaths()
|
||||
|
||||
if (typeof defaultPaths?.config === 'undefined') {
|
||||
throw new Error('Failed to fetch config path')
|
||||
}
|
||||
|
||||
return defaultPaths.config
|
||||
} catch (error) {
|
||||
console.error('getDefaultPath error', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Failed to get default path, please try again later.')
|
||||
} finally {
|
||||
await output.kill()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,4 +361,5 @@ export async function provisionRclone() {
|
||||
|
||||
console.log('[provisionRclone] rclone has been installed')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LazyStore } from '@tauri-apps/plugin-store'
|
||||
import { shared } from 'use-broadcast-ts'
|
||||
import { create } from 'zustand'
|
||||
import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware'
|
||||
import type { ConfigFile } from '../types/config'
|
||||
|
||||
// const { LazyStore } = window.__TAURI__.store
|
||||
const store = new LazyStore('store.json')
|
||||
@@ -59,6 +60,13 @@ interface PersistedState {
|
||||
|
||||
isFirstOpen: boolean
|
||||
setIsFirstOpen: (isFirstOpen: boolean) => void
|
||||
|
||||
configFiles: ConfigFile[]
|
||||
addConfigFile: (configFile: ConfigFile) => void
|
||||
removeConfigFile: (id: string) => void
|
||||
activeConfigFile: ConfigFile | null
|
||||
setActiveConfigFile: (configFile: string) => void
|
||||
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) => void
|
||||
}
|
||||
|
||||
const getStorage = (store: LazyStore): StateStorage => ({
|
||||
@@ -137,6 +145,27 @@ export const usePersistedStore = create<PersistedState>()(
|
||||
|
||||
isFirstOpen: true,
|
||||
setIsFirstOpen: (isFirstOpen: boolean) => set((_) => ({ isFirstOpen })),
|
||||
|
||||
configFiles: [],
|
||||
addConfigFile: (configFile: ConfigFile) =>
|
||||
set((state) => ({
|
||||
configFiles: [...state.configFiles, configFile],
|
||||
})),
|
||||
removeConfigFile: (id: string) =>
|
||||
set((state) => ({
|
||||
configFiles: state.configFiles.filter((f) => f.id !== id),
|
||||
})),
|
||||
activeConfigFile: null,
|
||||
setActiveConfigFile: (id: string) =>
|
||||
set((state) => ({
|
||||
activeConfigFile: state.configFiles.find((f) => f.id === id) || null,
|
||||
})),
|
||||
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) =>
|
||||
set((state) => ({
|
||||
configFiles: state.configFiles.map((f) =>
|
||||
f.id === id ? { ...f, ...configFile } : f
|
||||
),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'store',
|
||||
|
||||
@@ -96,10 +96,24 @@ async function startRclone() {
|
||||
return
|
||||
} catch {}
|
||||
|
||||
let rclone
|
||||
let rclone: Awaited<ReturnType<typeof initRclone>> | null = null
|
||||
|
||||
try {
|
||||
rclone = await initRclone()
|
||||
const sessionPassword = Math.random().toString(36).substring(2, 15)
|
||||
useStore.setState({ rcloneAuth: sessionPassword })
|
||||
useStore.setState({ rcloneAuthHeader: 'Basic ' + btoa(`admin:${sessionPassword}`) })
|
||||
|
||||
rclone = await initRclone([
|
||||
'rcd',
|
||||
// ...(platform() === 'macos'
|
||||
// ? ['--rc-no-auth'] // webkit doesn't allow for credentials in the url
|
||||
// : ['--rc-user', 'admin', '--rc-pass', sessionPassword]),
|
||||
'--rc-no-auth',
|
||||
'--rc-serve',
|
||||
// defaults
|
||||
// '-rc-addr',
|
||||
// ':5572',
|
||||
])
|
||||
} catch (error) {
|
||||
await ask(error.message || 'Failed to start rclone, please try again later.', {
|
||||
title: 'Error',
|
||||
@@ -110,23 +124,9 @@ async function startRclone() {
|
||||
return await exit(0)
|
||||
}
|
||||
|
||||
const sessionPassword = Math.random().toString(36).substring(2, 15)
|
||||
useStore.setState({ rcloneAuth: sessionPassword })
|
||||
useStore.setState({ rcloneAuthHeader: 'Basic ' + btoa(`admin:${sessionPassword}`) })
|
||||
const rcloneCommandFn = rclone?.system || rclone?.internal
|
||||
|
||||
const rcloneCommandFn = rclone.system || rclone.internal
|
||||
|
||||
const command = (await rcloneCommandFn([
|
||||
'rcd',
|
||||
// ...(platform() === 'macos'
|
||||
// ? ['--rc-no-auth'] // webkit doesn't allow for credentials in the url
|
||||
// : ['--rc-user', 'admin', '--rc-pass', sessionPassword]),
|
||||
'--rc-no-auth',
|
||||
'--rc-serve',
|
||||
// defaults
|
||||
// '-rc-addr',
|
||||
// ':5572',
|
||||
])) as Command<string>
|
||||
const command = rcloneCommandFn!
|
||||
|
||||
// command.stdout.on('data', (line) => {
|
||||
// console.log('stdout ' + line)
|
||||
@@ -158,7 +158,7 @@ async function startRclone() {
|
||||
getCurrentWindow().listen('close-app', async (e) => {
|
||||
console.log('[startRclone] (main) window close-app requested')
|
||||
|
||||
if (rclone.system) {
|
||||
if (rclone?.system) {
|
||||
const answer = await ask('Unmount all remotes before exiting?', {
|
||||
title: 'Exit',
|
||||
kind: 'info',
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@heroui/react'
|
||||
import { Button } from '@heroui/react'
|
||||
import { ask, open } from '@tauri-apps/plugin-dialog'
|
||||
import { mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { UploadIcon } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { getConfigPath } from '../../lib/rclone/api'
|
||||
import { usePersistedStore } from '../../lib/store'
|
||||
import type { ConfigFile } from '../../types/config'
|
||||
|
||||
export default function ConfigCreateDrawer({
|
||||
onClose,
|
||||
isOpen,
|
||||
}: {
|
||||
onClose: () => void
|
||||
isOpen: boolean
|
||||
}) {
|
||||
const [config, setConfig] = useState<Partial<ConfigFile>>({
|
||||
label: 'New Config',
|
||||
})
|
||||
const [configContent, setConfigContent] = useState<string | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const isEncrypted = useMemo(() => {
|
||||
return configContent?.includes('RCLONE_ENCRYPT_V0:')
|
||||
}, [configContent])
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async ({
|
||||
label,
|
||||
pass,
|
||||
content,
|
||||
}: { label?: string; pass?: string; content: string | null }) => {
|
||||
try {
|
||||
if (!label) {
|
||||
throw new Error('Label is required')
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Content is required')
|
||||
}
|
||||
|
||||
if (!pass && content.includes('RCLONE_ENCRYPT_V0:')) {
|
||||
throw new Error('Password is required for encrypted configs')
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
const generatedId = crypto.randomUUID()
|
||||
|
||||
const configPath = await getConfigPath({ id: generatedId, validate: false })
|
||||
|
||||
await mkdir(configPath.replace('/rclone.conf', ''), { recursive: true })
|
||||
await writeTextFile(configPath, content)
|
||||
console.log('[handleCreate] saved config to', configPath)
|
||||
|
||||
usePersistedStore.getState().addConfigFile({
|
||||
id: generatedId,
|
||||
label,
|
||||
pass,
|
||||
isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'),
|
||||
sync: undefined,
|
||||
})
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('[handleCreate] failed to save config', error)
|
||||
await ask('Failed to save config', {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
cancelLabel: '',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={isOpen}
|
||||
placement={'bottom'}
|
||||
size="full"
|
||||
onClose={onClose}
|
||||
hideCloseButton={true}
|
||||
>
|
||||
<DrawerContent>
|
||||
{(close) => (
|
||||
<>
|
||||
<DrawerHeader className="flex flex-col gap-1">Import Config</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<form
|
||||
id="config-form"
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleCreate({
|
||||
label: config.label,
|
||||
pass: config.pass,
|
||||
content: configContent,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label="Name"
|
||||
labelPlacement="outside"
|
||||
placeholder="Enter a name for your config"
|
||||
type="text"
|
||||
value={config.label}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
onValueChange={(value) => {
|
||||
setConfig({ ...config, label: value })
|
||||
}}
|
||||
isClearable={true}
|
||||
onClear={() => {
|
||||
setConfig({ ...config, label: '' })
|
||||
}}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{isEncrypted && (
|
||||
<Input
|
||||
label="Password"
|
||||
labelPlacement="outside"
|
||||
placeholder="Enter the password for your config file"
|
||||
type="password"
|
||||
value={config.pass}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
onValueChange={(value) => {
|
||||
setConfig({ ...config, pass: value })
|
||||
}}
|
||||
isClearable={true}
|
||||
onClear={() => {
|
||||
setConfig({ ...config, pass: '' })
|
||||
}}
|
||||
size="lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
className="w-full"
|
||||
label={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-medium">Config</p>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onPress={async () => {
|
||||
const selectedFile = await open({
|
||||
directory: false,
|
||||
multiple: false,
|
||||
title: 'Select a config file',
|
||||
})
|
||||
|
||||
if (!selectedFile) {
|
||||
return
|
||||
}
|
||||
|
||||
let content = await readTextFile(selectedFile)
|
||||
|
||||
if (selectedFile.endsWith('.json')) {
|
||||
const importedRemotes = JSON.parse(
|
||||
content
|
||||
) as Record<string, object>
|
||||
content = Object.entries(importedRemotes)
|
||||
.map(([name, remote]) => {
|
||||
const remoteName = `[${name}]`
|
||||
const remoteConfig = Object.entries(
|
||||
remote
|
||||
)
|
||||
.map(([key, value]) => {
|
||||
return `${key} = ${value}`
|
||||
})
|
||||
.join('\n')
|
||||
return `${remoteName}\n${remoteConfig}`
|
||||
})
|
||||
.reduce((acc, curr) => {
|
||||
return `${curr}\n\n${acc}`
|
||||
}, '')
|
||||
}
|
||||
|
||||
setConfigContent(content)
|
||||
setConfig({
|
||||
...config,
|
||||
label:
|
||||
config.label ||
|
||||
selectedFile.split('/').pop() ||
|
||||
'New Config',
|
||||
isEncrypted:
|
||||
content.includes('RCLONE_ENCRYPT_V0:'),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<UploadIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
labelPlacement="outside"
|
||||
placeholder="Paste your config here or import an existing file"
|
||||
value={configContent || ''}
|
||||
onValueChange={(value) => {
|
||||
console.log(value)
|
||||
setConfigContent(value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
//if it's tab key, add 2 spaces at the current text cursor position
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const text = e.currentTarget.value
|
||||
const cursorPosition = e.currentTarget.selectionStart
|
||||
const newText =
|
||||
text.slice(0, cursorPosition) +
|
||||
' ' +
|
||||
text.slice(cursorPosition)
|
||||
e.currentTarget.value = newText
|
||||
e.currentTarget.selectionStart = cursorPosition + 2
|
||||
e.currentTarget.selectionEnd = cursorPosition + 2
|
||||
}
|
||||
}}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
minRows={14}
|
||||
rows={14}
|
||||
maxRows={14}
|
||||
disableAutosize={true}
|
||||
size="lg"
|
||||
onClear={() => {
|
||||
setConfigContent(null)
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
/>
|
||||
</form>
|
||||
</DrawerBody>
|
||||
<DrawerFooter>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="light"
|
||||
onPress={close}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
type="submit"
|
||||
form="config-form"
|
||||
isDisabled={isSaving}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Import'}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@heroui/react'
|
||||
import { Button } from '@heroui/react'
|
||||
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { getConfigPath } from '../../lib/rclone/api'
|
||||
import { usePersistedStore } from '../../lib/store'
|
||||
|
||||
export default function ConfigEditDrawer({
|
||||
id,
|
||||
onClose,
|
||||
isOpen,
|
||||
}: {
|
||||
id?: string | null
|
||||
onClose: () => void
|
||||
isOpen: boolean
|
||||
}) {
|
||||
const configFiles = usePersistedStore((state) => state.configFiles)
|
||||
const initialConfig = useMemo(() => configFiles.find((c) => c.id === id), [configFiles, id])
|
||||
|
||||
const [configLabel, setConfigLabel] = useState<string | null>(null)
|
||||
const [configPass, setConfigPass] = useState<string | null>(null)
|
||||
const [configContent, setConfigContent] = useState<string | null>(null)
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const isEncrypted = useMemo(() => {
|
||||
return configContent?.includes('RCLONE_ENCRYPT_V0:')
|
||||
}, [configContent])
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async ({ label, pass, content }: { label?: string; pass?: string; content?: string }) => {
|
||||
if (!id) return
|
||||
|
||||
try {
|
||||
if (!label) {
|
||||
throw new Error('Label is required')
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Content is required')
|
||||
}
|
||||
|
||||
if (content.includes('RCLONE_ENCRYPT_V0:') && !pass) {
|
||||
throw new Error('Password is required for encrypted configs')
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
const configPath = await getConfigPath({ id: id, validate: true })
|
||||
await writeTextFile(configPath, content)
|
||||
|
||||
usePersistedStore.getState().updateConfigFile(id, {
|
||||
label,
|
||||
pass: pass || undefined,
|
||||
isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'),
|
||||
})
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[id, onClose]
|
||||
)
|
||||
|
||||
const initializeConfig = useCallback(async () => {
|
||||
if (!initialConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
const configPath = await getConfigPath({ id: initialConfig.id!, validate: true })
|
||||
const text = await readTextFile(configPath)
|
||||
setConfigContent(text)
|
||||
setConfigLabel(initialConfig.label)
|
||||
setConfigPass(initialConfig.pass || null)
|
||||
}, [initialConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && configContent === null && configLabel === null) {
|
||||
initializeConfig()
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
setConfigContent(null)
|
||||
setConfigLabel(null)
|
||||
setConfigPass(null)
|
||||
}
|
||||
}, [isOpen, initializeConfig, configLabel, configContent])
|
||||
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={isOpen}
|
||||
placement={'bottom'}
|
||||
size="full"
|
||||
onClose={onClose}
|
||||
hideCloseButton={true}
|
||||
>
|
||||
<DrawerContent>
|
||||
{(close) => (
|
||||
<>
|
||||
<DrawerHeader className="flex flex-col gap-1">
|
||||
Edit {configLabel}
|
||||
</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<form
|
||||
id="config-form"
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleUpdate({
|
||||
label: configLabel || undefined,
|
||||
pass: configPass || undefined,
|
||||
content: configContent || undefined,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
name="label"
|
||||
label="Name"
|
||||
labelPlacement="outside"
|
||||
placeholder="Enter a name for your config"
|
||||
type="text"
|
||||
value={configLabel || ''}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
onValueChange={(value) => {
|
||||
setConfigLabel(value)
|
||||
}}
|
||||
isClearable={true}
|
||||
onClear={() => {
|
||||
setConfigLabel(null)
|
||||
}}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{isEncrypted && (
|
||||
<Input
|
||||
name="label"
|
||||
label="Password"
|
||||
labelPlacement="outside"
|
||||
placeholder="Enter the password for your config file"
|
||||
type="password"
|
||||
value={configPass || ''}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
onValueChange={(value) => {
|
||||
setConfigPass(value)
|
||||
}}
|
||||
isClearable={true}
|
||||
onClear={() => {
|
||||
setConfigPass(null)
|
||||
}}
|
||||
size="lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
className="w-full"
|
||||
name="content"
|
||||
label={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-medium">Config</p>
|
||||
</div>
|
||||
}
|
||||
labelPlacement="outside"
|
||||
placeholder="Update your config here"
|
||||
value={configContent || ''}
|
||||
onValueChange={(value) => {
|
||||
console.log(value)
|
||||
setConfigContent(value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
//if it's tab key, add 2 spaces at the current text cursor position
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const text = e.currentTarget.value
|
||||
const cursorPosition = e.currentTarget.selectionStart
|
||||
const newText =
|
||||
text.slice(0, cursorPosition) +
|
||||
' ' +
|
||||
text.slice(cursorPosition)
|
||||
e.currentTarget.value = newText
|
||||
e.currentTarget.selectionStart = cursorPosition + 2
|
||||
e.currentTarget.selectionEnd = cursorPosition + 2
|
||||
}
|
||||
}}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
minRows={14}
|
||||
rows={14}
|
||||
maxRows={14}
|
||||
disableAutosize={true}
|
||||
size="lg"
|
||||
onClear={() => {
|
||||
setConfigContent(null)
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
/>
|
||||
</form>
|
||||
</DrawerBody>
|
||||
<DrawerFooter>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="light"
|
||||
onPress={close}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
type="submit"
|
||||
form="config-form"
|
||||
isDisabled={isSaving}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
+201
-1
@@ -1,22 +1,27 @@
|
||||
import { Button, Card, CardBody, Checkbox, Chip, Input, Tab, Tabs } from '@heroui/react'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { remove } from '@tauri-apps/plugin-fs'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
import { type Update, check } from '@tauri-apps/plugin-updater'
|
||||
import {
|
||||
CheckIcon,
|
||||
CodeIcon,
|
||||
CogIcon,
|
||||
EyeIcon,
|
||||
MedalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
Trash2Icon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { revokeLicense, validateLicense } from '../../lib/license'
|
||||
import { deleteRemote } from '../../lib/rclone/api'
|
||||
import { deleteRemote, getConfigPath } from '../../lib/rclone/api'
|
||||
import { usePersistedStore, useStore } from '../../lib/store'
|
||||
import { triggerTrayRebuild } from '../../lib/tray'
|
||||
import ConfigCreateDrawer from '../components/ConfigCreateDrawer'
|
||||
import ConfigEditDrawer from '../components/ConfigEditDrawer'
|
||||
import RemoteCreateDrawer from '../components/RemoteCreateDrawer'
|
||||
import RemoteDefaultsDrawer from '../components/RemoteDefaultsDrawer'
|
||||
import RemoteEditDrawer from '../components/RemoteEditDrawer'
|
||||
@@ -131,6 +136,19 @@ function Settings() {
|
||||
>
|
||||
<LicenseSection />
|
||||
</Tab>
|
||||
<Tab
|
||||
key="config"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<CodeIcon className="w-5 h-5" />
|
||||
<span>Config</span>
|
||||
</div>
|
||||
}
|
||||
data-focus-visible="false"
|
||||
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
|
||||
>
|
||||
<ConfigSection />
|
||||
</Tab>
|
||||
{/* <Tab
|
||||
key="hosts"
|
||||
title={
|
||||
@@ -798,6 +816,188 @@ function RemotesSection() {
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigSection() {
|
||||
const configFiles = usePersistedStore((state) => state.configFiles)
|
||||
const activeConfigFile = usePersistedStore((state) => state.activeConfigFile)
|
||||
|
||||
const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false)
|
||||
const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false)
|
||||
const [focusedConfigId, setFocusedConfigId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BaseHeader
|
||||
title="Config"
|
||||
endContent={
|
||||
<Button
|
||||
onPress={async () => {
|
||||
// setFocusedConfig({
|
||||
// id: undefined,
|
||||
// label: filePath.split('/').pop() || 'New Config',
|
||||
// isEncrypted: configText.includes('RCLONE_ENCRYPT_V0:'),
|
||||
// pass: undefined,
|
||||
// sync: undefined,
|
||||
// })
|
||||
setIsCreateDrawerOpen(true)
|
||||
|
||||
// await ask(
|
||||
// 'Config loaded successfully, you can now remove the file.',
|
||||
// {
|
||||
// title: 'Success',
|
||||
// kind: 'info',
|
||||
// okLabel: 'OK',
|
||||
// cancelLabel: '',
|
||||
// }
|
||||
// )
|
||||
// } catch (error) {
|
||||
// console.error(error)
|
||||
// await ask('Failed to load config file', {
|
||||
// title: 'Error',
|
||||
// kind: 'error',
|
||||
// okLabel: 'OK',
|
||||
// cancelLabel: '',
|
||||
// })
|
||||
// }
|
||||
}}
|
||||
isIconOnly={true}
|
||||
variant="faded"
|
||||
color="primary"
|
||||
data-focus-visible="false"
|
||||
size="sm"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{configFiles.map((configFile) => (
|
||||
<Button
|
||||
key={configFile.id}
|
||||
className="flex flex-row justify-start w-full gap-2"
|
||||
color={configFile.id === activeConfigFile?.id ? 'primary' : 'default'}
|
||||
onPress={() => {
|
||||
if (configFile.id === activeConfigFile?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const confirmed = await ask(
|
||||
'This will cancel any active jobs or transfers and restart the app',
|
||||
{
|
||||
title: `Switch to config ${configFile.label}?`,
|
||||
kind: 'info',
|
||||
okLabel: 'OK',
|
||||
cancelLabel: 'Cancel',
|
||||
}
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
usePersistedStore.getState().setActiveConfigFile(configFile.id!)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
await relaunch()
|
||||
}, 100)
|
||||
}}
|
||||
>
|
||||
<p>{configFile.label}</p>
|
||||
<div className="flex-1" />
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
setFocusedConfigId(configFile.id!)
|
||||
setIsEditDrawerOpen(true)
|
||||
}}
|
||||
isDisabled={
|
||||
configFile.id === 'default' ||
|
||||
configFile.id === activeConfigFile?.id
|
||||
}
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
setTimeout(async () => {
|
||||
const confirmed = await ask(
|
||||
`Are you sure you want to delete config ${configFile.label}?`,
|
||||
{
|
||||
title: 'Delete Config',
|
||||
kind: 'warning',
|
||||
okLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
}
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (configFile.id === 'default') {
|
||||
await ask('Default config cannot be deleted', {
|
||||
title: 'Error',
|
||||
kind: 'warning',
|
||||
okLabel: 'OK',
|
||||
cancelLabel: '',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const path = await getConfigPath({
|
||||
id: configFile.id!,
|
||||
validate: true,
|
||||
})
|
||||
|
||||
await remove(path.replace('rclone.conf', ''), {
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
if (activeConfigFile?.id === configFile.id) {
|
||||
usePersistedStore
|
||||
.getState()
|
||||
.setActiveConfigFile('default')
|
||||
}
|
||||
|
||||
usePersistedStore
|
||||
.getState()
|
||||
.removeConfigFile(configFile.id!)
|
||||
}, 100)
|
||||
}}
|
||||
isDisabled={configFile.id === 'default'}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ConfigCreateDrawer
|
||||
isOpen={isCreateDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsCreateDrawerOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfigEditDrawer
|
||||
isOpen={isEditDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsEditDrawerOpen(false)
|
||||
setFocusedConfigId(null)
|
||||
}}
|
||||
id={focusedConfigId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BaseHeader({ title, endContent }: { title: string; endContent?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-50 flex items-center justify-between p-4 h-14 bg-neutral-900/50 backdrop-blur-lg">
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export interface ConfigFile {
|
||||
id: string | undefined
|
||||
label: string
|
||||
sync: string | undefined
|
||||
isEncrypted: boolean
|
||||
pass: string | undefined
|
||||
}
|
||||
Reference in New Issue
Block a user