diff --git a/lib/rclone/init.ts b/lib/rclone/init.ts index 2e659fe..385eecc 100644 --- a/lib/rclone/init.ts +++ b/lib/rclone/init.ts @@ -1,8 +1,8 @@ import * as Sentry from '@sentry/browser' import { invoke } from '@tauri-apps/api/core' -import { BaseDirectory, appLocalDataDir, sep } from '@tauri-apps/api/path' +import { BaseDirectory, appLocalDataDir, appLogDir, sep } from '@tauri-apps/api/path' import { tempDir } from '@tauri-apps/api/path' -import { ask, message } from '@tauri-apps/plugin-dialog' +import { 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' @@ -49,43 +49,6 @@ export async function initRclone(args: string[]) { } } - 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', @@ -113,6 +76,8 @@ export async function initRclone(args: string[]) { '' ) + console.log('[initRclone] configFolderPath', configFolderPath) + if (activeConfigFile.sync) { if (!(await exists(configFolderPath + sep() + 'rclone.conf'))) { await message('The config file could not be found. Switching to the default config.', { @@ -120,6 +85,7 @@ export async function initRclone(args: string[]) { kind: 'error', okLabel: 'OK', }) + activeConfigFile = configFiles[0] configFolderPath = (await getConfigPath({ id: 'default', validate: true })).replace( /\/rclone\.conf$/, '' @@ -128,19 +94,71 @@ export async function initRclone(args: string[]) { } } - const extraParams = - activeConfigFile.id === 'default' - ? undefined - : { - env: { - ...(activeConfigFile.isEncrypted - ? activeConfigFile.passCommand - ? { RCLONE_CONFIG_PASS_COMMAND: activeConfigFile.passCommand } - : { RCLONE_CONFIG_PASS: activeConfigFile.pass! } - : {}), - RCLONE_CONFIG_DIR: configFolderPath, - }, - } + let password: string | null = activeConfigFile.pass || activeConfigFile.passCommand || null + try { + const configContent = await readTextFile(`${configFolderPath}${sep()}rclone.conf`) + const isEncrypted = configContent.includes('RCLONE_ENCRYPT_V0:') + if (isEncrypted && !password) { + password = await promptForConfigPassword(activeConfigFile.label) + console.log('[initRclone] password', password) + + if (!password) { + await message('Password is required for encrypted configurations.', { + title: 'Password Required', + kind: 'error', + okLabel: 'OK', + }) + await exit(0) + return + } + + if (!activeConfigFile.isEncrypted) { + const updatedConfigFile = { ...activeConfigFile, isEncrypted: true } + const updatedConfigFiles = configFiles.map((config) => + config.id === activeConfigFile!.id ? updatedConfigFile : config + ) + usePersistedStore.setState({ + configFiles: updatedConfigFiles, + activeConfigFile: updatedConfigFile, + }) + + // Update activeConfigFile reference for the rest of the function + activeConfigFile = updatedConfigFile + } + } + } catch (error) { + console.log('[initRclone] could not read config file', error) + const appLogDirPath = await appLogDir() + await message( + 'Could not read config file, please file an issue on GitHub.\n\nLogs: ' + appLogDirPath, + { + title: 'Error', + kind: 'error', + okLabel: 'OK', + } + ) + await exit(0) + return + } + + const extraParams: { env: Record } = { + env: {}, + } + + if (activeConfigFile.isEncrypted) { + extraParams.env.RCLONE_ASK_PASSWORD = 'false' + if (activeConfigFile.passCommand) { + extraParams.env.RCLONE_CONFIG_PASS_COMMAND = activeConfigFile.passCommand + } else { + extraParams.env.RCLONE_CONFIG_PASS = activeConfigFile.pass || password! + } + } + + if (activeConfigFile.id !== 'default') { + extraParams.env.RCLONE_CONFIG_DIR = configFolderPath + } + + console.log('[initRclone] extraParams', extraParams) if (system) { console.log('[initRclone] running system rclone') @@ -301,3 +319,26 @@ export async function provisionRclone() { return true } + +/** + * Prompts the user for a password for an encrypted configuration + * @param configLabel - The label of the configuration file + * @returns Promise - The password entered by the user, or null if cancelled + */ +async function promptForConfigPassword(configLabel: string): Promise { + try { + const result = (await invoke('prompt_password', { + title: 'Rclone UI', + message: `Please enter the password for the encrypted configuration "${configLabel}".`, + })) as string | null + + if (typeof result === 'string') { + return result.trim() + } + + return null + } catch (error) { + console.error('[promptForConfigPassword] Error prompting for password:', error) + return null + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce24764..f57b4c2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -69,6 +69,149 @@ fn get_uid() -> String { return machine_uid::get().unwrap(); } +#[tauri::command] +async fn prompt_password(title: String, message: String) -> Result, String> { + #[cfg(target_os = "macos")] + { + use std::process::Command; + + let script = format!( + r#"display dialog "{}" with title "{}" default answer "" with hidden answer"#, + message.replace("\"", "\\\""), + title.replace("\"", "\\\"") + ); + + let output = Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + // Parse AppleScript result: "text returned:password, button returned:OK" + if let Some(password_part) = result.split("text returned:").nth(1) { + if let Some(password) = password_part.split(", button returned:").next() { + return Ok(Some(password.to_string())); + } + } + } + + Ok(None) + } + + #[cfg(target_os = "windows")] + { + use std::process::Command; + + // Use PowerShell to create a credential dialog on Windows + let powershell_script = format!( + r#" + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + + $form = New-Object System.Windows.Forms.Form + $form.Text = '{}' + $form.Size = New-Object System.Drawing.Size(350, 200) + $form.StartPosition = 'CenterScreen' + $form.FormBorderStyle = 'FixedDialog' + $form.MaximizeBox = $false + $form.MinimizeBox = $false + $form.TopMost = $true + + $label = New-Object System.Windows.Forms.Label + $label.Location = New-Object System.Drawing.Point(10, 20) + $label.Size = New-Object System.Drawing.Size(320, 40) + $label.Text = '{}' + $form.Controls.Add($label) + + $textBox = New-Object System.Windows.Forms.TextBox + $textBox.Location = New-Object System.Drawing.Point(10, 70) + $textBox.Size = New-Object System.Drawing.Size(320, 20) + $textBox.UseSystemPasswordChar = $true + $form.Controls.Add($textBox) + + $okButton = New-Object System.Windows.Forms.Button + $okButton.Location = New-Object System.Drawing.Point(175, 110) + $okButton.Size = New-Object System.Drawing.Size(75, 23) + $okButton.Text = 'OK' + $okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK + $form.AcceptButton = $okButton + $form.Controls.Add($okButton) + + $cancelButton = New-Object System.Windows.Forms.Button + $cancelButton.Location = New-Object System.Drawing.Point(255, 110) + $cancelButton.Size = New-Object System.Drawing.Size(75, 23) + $cancelButton.Text = 'Cancel' + $cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel + $form.CancelButton = $cancelButton + $form.Controls.Add($cancelButton) + + $form.Add_Shown({{$textBox.Select()}}) + $result = $form.ShowDialog() + + if ($result -eq [System.Windows.Forms.DialogResult]::OK) {{ + $textBox.Text + }} + "#, + title.replace("'", "''"), + message.replace("'", "''") + ); + + let output = Command::new("powershell") + .args(&["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &powershell_script]) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !result.is_empty() { + return Ok(Some(result)); + } + } + + Ok(None) + } + + #[cfg(target_os = "linux")] + { + // Try different methods for Linux + + // First try zenity (most common) + if let Ok(output) = std::process::Command::new("zenity") + .args(&["--password", "--title", &title, "--text", &message]) + .output() + { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !result.is_empty() { + return Ok(Some(result)); + } + } + } + + // Fallback to kdialog (KDE) + if let Ok(output) = std::process::Command::new("kdialog") + .args(&["--password", &message, "--title", &title]) + .output() + { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !result.is_empty() { + return Ok(Some(result)); + } + } + } + + Err("No suitable password dialog found. Please install zenity or kdialog.".to_string()) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + Err("Password input not supported on this platform".to_string()) + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let client = sentry::init(( @@ -106,7 +249,7 @@ pub fn run() { .plugin(tauri_plugin_log::Builder::new().build()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid]) + .invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid, prompt_password]) .setup(|_app| Ok(())) // .setup(|app| { // if cfg!(debug_assertions) { diff --git a/src/components/ConfigCreateDrawer.tsx b/src/components/ConfigCreateDrawer.tsx index a5d6896..adff922 100644 --- a/src/components/ConfigCreateDrawer.tsx +++ b/src/components/ConfigCreateDrawer.tsx @@ -5,6 +5,7 @@ import { DrawerFooter, DrawerHeader, Input, + Switch, Textarea, } from '@heroui/react' import { Button } from '@heroui/react' @@ -28,6 +29,8 @@ export default function ConfigCreateDrawer({ label: 'New Config', }) const [configContent, setConfigContent] = useState(null) + + const [isPasswordCommand, setIsPasswordCommand] = useState(false) const [isSaving, setIsSaving] = useState(false) const isEncrypted = useMemo(() => { @@ -38,8 +41,18 @@ export default function ConfigCreateDrawer({ async ({ label, pass, + passCommand, content, - }: { label?: string; pass?: string; content: string | null }) => { + isPasswordCommand, + isEncrypted, + }: { + label?: string + pass?: string + passCommand?: string + content: string | null + isPasswordCommand: boolean + isEncrypted: boolean + }) => { try { if (!label) { throw new Error('Label is required') @@ -49,8 +62,8 @@ export default function ConfigCreateDrawer({ throw new Error('Content is required') } - if (!pass && content.includes('RCLONE_ENCRYPT_V0:')) { - throw new Error('Password is required for encrypted configs') + if (isEncrypted && isPasswordCommand && !passCommand) { + throw new Error('Password command is required for encrypted configs') } setIsSaving(true) @@ -68,10 +81,10 @@ export default function ConfigCreateDrawer({ usePersistedStore.getState().addConfigFile({ id: generatedId, label, - pass, - isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'), + pass: isPasswordCommand ? undefined : pass, + passCommand: isPasswordCommand ? passCommand : undefined, + isEncrypted: isEncrypted, sync: undefined, - passCommand: undefined, }) onClose() @@ -113,7 +126,11 @@ export default function ConfigCreateDrawer({ handleCreate({ label: config.label, pass: config.pass, + passCommand: config.passCommand, content: configContent, + isPasswordCommand: isPasswordCommand, + isEncrypted: + configContent?.includes('RCLONE_ENCRYPT_V0:') || false, }) }} > @@ -138,20 +155,49 @@ export default function ConfigCreateDrawer({ {isEncrypted && ( +

Password

+ + setIsPasswordCommand(!isPasswordCommand) + } + color="primary" + > + Command + + + } labelPlacement="outside" - placeholder="Enter the password for your config file" - type="password" - value={config.pass} - autoComplete="off" + placeholder={ + isPasswordCommand + ? 'Enter the password command for your config file' + : 'Leave blank to be prompted on every startup' + } + type={isPasswordCommand ? 'text' : 'password'} + value={isPasswordCommand ? config.passCommand : config.pass} autoCapitalize="off" + autoComplete="off" autoCorrect="off" + spellCheck="false" onValueChange={(value) => { - setConfig({ ...config, pass: value }) + setConfig({ + ...config, + ...(isPasswordCommand + ? { passCommand: value } + : { pass: value }), + }) }} isClearable={true} onClear={() => { - setConfig({ ...config, pass: '' }) + setConfig({ + ...config, + ...(isPasswordCommand + ? { passCommand: '' } + : { pass: '' }), + }) }} size="lg" /> diff --git a/src/components/ConfigEditDrawer.tsx b/src/components/ConfigEditDrawer.tsx index f2ecf6c..3515cde 100644 --- a/src/components/ConfigEditDrawer.tsx +++ b/src/components/ConfigEditDrawer.tsx @@ -5,6 +5,7 @@ import { DrawerFooter, DrawerHeader, Input, + Switch, Textarea, } from '@heroui/react' import { Button } from '@heroui/react' @@ -28,8 +29,10 @@ export default function ConfigEditDrawer({ const [configLabel, setConfigLabel] = useState(null) const [configPass, setConfigPass] = useState(null) + const [configPassCommand, setConfigPassCommand] = useState(null) const [configContent, setConfigContent] = useState(null) + const [isPasswordCommand, setIsPasswordCommand] = useState(false) const [isSaving, setIsSaving] = useState(false) const isEncrypted = useMemo(() => { @@ -37,7 +40,21 @@ export default function ConfigEditDrawer({ }, [configContent]) const handleUpdate = useCallback( - async ({ label, pass, content }: { label?: string; pass?: string; content?: string }) => { + async ({ + label, + pass, + content, + passCommand, + isPasswordCommand, + isEncrypted, + }: { + label?: string + pass?: string + content?: string + passCommand?: string + isPasswordCommand?: boolean + isEncrypted?: boolean + }) => { if (!id) return try { @@ -49,8 +66,8 @@ export default function ConfigEditDrawer({ throw new Error('Content is required') } - if (content.includes('RCLONE_ENCRYPT_V0:') && !pass) { - throw new Error('Password is required for encrypted configs') + if (isEncrypted && isPasswordCommand && !passCommand) { + throw new Error('Password command is required for encrypted configs') } setIsSaving(true) @@ -60,8 +77,9 @@ export default function ConfigEditDrawer({ usePersistedStore.getState().updateConfigFile(id, { label, - pass: pass || undefined, - isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'), + pass: isPasswordCommand ? undefined : pass, + passCommand: isPasswordCommand ? passCommand : undefined, + isEncrypted: isEncrypted, }) onClose() @@ -92,6 +110,8 @@ export default function ConfigEditDrawer({ setConfigContent(text) setConfigLabel(initialConfig.label) setConfigPass(initialConfig.pass || null) + setConfigPassCommand(initialConfig.passCommand || null) + setIsPasswordCommand(initialConfig.passCommand !== null) }, [initialConfig]) useEffect(() => { @@ -103,6 +123,8 @@ export default function ConfigEditDrawer({ setConfigContent(null) setConfigLabel(null) setConfigPass(null) + setConfigPassCommand(null) + setIsPasswordCommand(false) } }, [isOpen, initializeConfig, configLabel, configContent]) @@ -134,6 +156,10 @@ export default function ConfigEditDrawer({ label: configLabel || undefined, pass: configPass || undefined, content: configContent || undefined, + passCommand: configPassCommand || undefined, + isPasswordCommand: isPasswordCommand, + isEncrypted: + configContent?.includes('RCLONE_ENCRYPT_V0:') || false, }) }} > @@ -159,21 +185,50 @@ export default function ConfigEditDrawer({ {isEncrypted && ( +

Password

+ + setIsPasswordCommand(!isPasswordCommand) + } + color="primary" + > + Command + + + } labelPlacement="outside" - placeholder="Enter the password for your config file" - type="password" - value={configPass || ''} - autoComplete="off" + placeholder={ + isPasswordCommand + ? 'Enter the password command for your config file' + : 'Leave blank to be prompted on every startup' + } + type={isPasswordCommand ? 'text' : 'password'} + value={ + (isPasswordCommand ? configPassCommand : configPass) || + '' + } autoCapitalize="off" + autoComplete="off" autoCorrect="off" + spellCheck="false" onValueChange={(value) => { - setConfigPass(value) + if (isPasswordCommand) { + setConfigPassCommand(value) + } else { + setConfigPass(value) + } }} isClearable={true} onClear={() => { - setConfigPass(null) + if (isPasswordCommand) { + setConfigPassCommand(null) + } else { + setConfigPass(null) + } }} size="lg" /> diff --git a/src/components/ConfigSyncDrawer.tsx b/src/components/ConfigSyncDrawer.tsx index f228011..3673c58 100644 --- a/src/components/ConfigSyncDrawer.tsx +++ b/src/components/ConfigSyncDrawer.tsx @@ -38,12 +38,14 @@ export default function ConfigSyncDrawer({ isEncrypted, passCommand, sync, + isPasswordCommand, }: { label?: string sync?: string isEncrypted?: boolean pass?: string passCommand?: string + isPasswordCommand?: boolean }) => { try { if (!label) { @@ -54,10 +56,8 @@ export default function ConfigSyncDrawer({ throw new Error('Path is required') } - if (isEncrypted) { - if (!pass && !passCommand) { - throw new Error('Password is required for encrypted configs') - } + if (isEncrypted && isPasswordCommand && !passCommand) { + throw new Error('Password command is required for encrypted configs') } setIsSaving(true) @@ -68,8 +68,8 @@ export default function ConfigSyncDrawer({ id: generatedId, label, isEncrypted: isEncrypted || false, - pass, - passCommand, + pass: isPasswordCommand ? undefined : pass, + passCommand: isPasswordCommand ? passCommand : undefined, sync, }) @@ -111,12 +111,11 @@ export default function ConfigSyncDrawer({ e.preventDefault() handleCreate({ label: config.label, - pass: isPasswordCommand ? undefined : config.pass, + pass: config.pass, isEncrypted: config.isEncrypted, - passCommand: isPasswordCommand - ? config.passCommand - : undefined, + passCommand: config.passCommand, sync: config.sync, + isPasswordCommand: isPasswordCommand, }) }} > @@ -172,7 +171,7 @@ export default function ConfigSyncDrawer({ placeholder={ isPasswordCommand ? 'Enter the password command for your config file' - : 'Enter the password for your config file' + : 'Leave blank to be prompted on every startup' } type={isPasswordCommand ? 'text' : 'password'} value={isPasswordCommand ? config.passCommand : config.pass}