diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index 346ff12..97e4776 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -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 +} diff --git a/lib/rclone/init.ts b/lib/rclone/init.ts index 72618f9..fd2abf5 100644 --- a/lib/rclone/init.ts +++ b/lib/rclone/init.ts @@ -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 } diff --git a/lib/store.ts b/lib/store.ts index bd219ab..2a9face 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -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) => void } const getStorage = (store: LazyStore): StateStorage => ({ @@ -137,6 +145,27 @@ export const usePersistedStore = create()( 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) => + set((state) => ({ + configFiles: state.configFiles.map((f) => + f.id === id ? { ...f, ...configFile } : f + ), + })), }), { name: 'store', diff --git a/main.ts b/main.ts index c29fe53..df4dac9 100644 --- a/main.ts +++ b/main.ts @@ -96,10 +96,24 @@ async function startRclone() { return } catch {} - let rclone + let rclone: Awaited> | 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 + 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', diff --git a/src/components/ConfigCreateDrawer.tsx b/src/components/ConfigCreateDrawer.tsx new file mode 100644 index 0000000..0011508 --- /dev/null +++ b/src/components/ConfigCreateDrawer.tsx @@ -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>({ + label: 'New Config', + }) + const [configContent, setConfigContent] = useState(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 ( + + + {(close) => ( + <> + Import Config + +
{ + e.preventDefault() + handleCreate({ + label: config.label, + pass: config.pass, + content: configContent, + }) + }} + > + { + setConfig({ ...config, label: value }) + }} + isClearable={true} + onClear={() => { + setConfig({ ...config, label: '' }) + }} + size="lg" + /> + + {isEncrypted && ( + { + setConfig({ ...config, pass: value }) + }} + isClearable={true} + onClear={() => { + setConfig({ ...config, pass: '' }) + }} + size="lg" + /> + )} + +