diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 4b676a9..c19f83e 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,5 +1,6 @@ import { createServices, type MergeIpcService } from 'electron-ipc-decorator' import { AppService } from './services/app-service' +import { BrowserCookiesService } from './services/browser-cookies-service' import { DownloadService } from './services/download-service' import { FileSystemService } from './services/file-system-service' import { HistoryService } from './services/history-service' @@ -12,6 +13,7 @@ import { WindowService } from './services/window-service' // Create services with automatic type inference export const services = createServices([ AppService, + BrowserCookiesService, DownloadService, FileSystemService, HistoryService, diff --git a/src/main/ipc/services/browser-cookies-service.ts b/src/main/ipc/services/browser-cookies-service.ts new file mode 100644 index 0000000..6fea819 --- /dev/null +++ b/src/main/ipc/services/browser-cookies-service.ts @@ -0,0 +1,270 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator' +import { resolvePathWithHome } from '../../utils/path-helpers' + +class BrowserCookiesService extends IpcService { + static readonly groupName = 'browserCookies' + + private buildValidationResult(valid: boolean, reason?: string) { + if (valid) { + return { valid } + } + return { valid, reason } + } + + private isDirectory(target: string): boolean { + try { + return fs.statSync(target).isDirectory() + } catch { + return false + } + } + + private pickFirstDirectory(paths: string[]): string { + for (const candidate of paths) { + if (this.isDirectory(candidate)) { + return candidate + } + } + return '' + } + + private normalizeProfileInput(value: string): string { + return value.trim().replace(/^['"]|['"]$/g, '') + } + + private getBrowserProfileBaseDirs(platform: string, homeDir: string, browser: string): string[] { + if (platform === 'win32') { + if (browser === 'edge') { + return [path.join(homeDir, 'AppData', 'Local', 'Microsoft', 'Edge', 'User Data')] + } + if (browser === 'chrome') { + return [path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data')] + } + if (browser === 'chromium') { + return [path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data')] + } + if (browser === 'brave') { + return [ + path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data') + ] + } + if (browser === 'vivaldi') { + return [path.join(homeDir, 'AppData', 'Local', 'Vivaldi', 'User Data')] + } + if (browser === 'whale') { + return [path.join(homeDir, 'AppData', 'Local', 'Naver', 'Whale', 'User Data')] + } + if (browser === 'opera') { + return [path.join(homeDir, 'AppData', 'Roaming', 'Opera Software', 'Opera Stable')] + } + if (browser === 'firefox') { + return [path.join(homeDir, 'AppData', 'Roaming', 'Mozilla', 'Firefox', 'Profiles')] + } + } + + if (platform === 'darwin') { + if (browser === 'edge') { + return [path.join(homeDir, 'Library', 'Application Support', 'Microsoft Edge')] + } + if (browser === 'chrome') { + return [path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome')] + } + if (browser === 'chromium') { + return [path.join(homeDir, 'Library', 'Application Support', 'Chromium')] + } + if (browser === 'brave') { + return [ + path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser') + ] + } + if (browser === 'vivaldi') { + return [path.join(homeDir, 'Library', 'Application Support', 'Vivaldi')] + } + if (browser === 'whale') { + return [ + path.join(homeDir, 'Library', 'Application Support', 'Whale'), + path.join(homeDir, 'Library', 'Application Support', 'Naver Whale') + ] + } + if (browser === 'opera') { + return [ + path.join(homeDir, 'Library', 'Application Support', 'com.operasoftware.Opera'), + path.join(homeDir, 'Library', 'Application Support', 'Opera Software', 'Opera Stable') + ] + } + if (browser === 'firefox') { + return [path.join(homeDir, 'Library', 'Application Support', 'Firefox', 'Profiles')] + } + if (browser === 'safari') { + return [path.join(homeDir, 'Library', 'Safari')] + } + } + + if (platform === 'linux') { + if (browser === 'edge') { + return [path.join(homeDir, '.config', 'microsoft-edge')] + } + if (browser === 'chrome') { + return [path.join(homeDir, '.config', 'google-chrome')] + } + if (browser === 'chromium') { + return [path.join(homeDir, '.config', 'chromium')] + } + if (browser === 'brave') { + return [path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser')] + } + if (browser === 'vivaldi') { + return [path.join(homeDir, '.config', 'vivaldi')] + } + if (browser === 'whale') { + return [path.join(homeDir, '.config', 'naver-whale')] + } + if (browser === 'opera') { + return [path.join(homeDir, '.config', 'opera')] + } + if (browser === 'firefox') { + return [path.join(homeDir, '.mozilla', 'firefox')] + } + } + + if (platform === 'freebsd') { + if (browser === 'firefox') { + return [path.join(homeDir, '.mozilla', 'firefox')] + } + } + + return [] + } + + private getDefaultProfilePath(baseDirs: string[], browser: string): string { + const base = baseDirs[0] + if (!base) { + return '' + } + + if (browser === 'firefox' || browser === 'safari' || browser === 'opera') { + return base + } + + return path.join(base, 'Default') + } + + private findFirefoxProfilePath(profilesDir: string): string { + if (!this.isDirectory(profilesDir)) { + return '' + } + + const entries = fs + .readdirSync(profilesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)) + + const preferred = + entries.find((name) => name.endsWith('.default-release')) ?? + entries.find((name) => name.endsWith('.default')) ?? + entries[0] + + return preferred ? path.join(profilesDir, preferred) : '' + } + + @IpcMethod() + getBrowserProfilePath(_context: IpcContext, browser: string): string { + if (!browser || browser === 'none') { + return '' + } + + const homeDir = os.homedir() + const platform = os.platform() + const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser) + const fallbackPath = this.getDefaultProfilePath(baseDirs, browser) + + if (browser === 'firefox') { + const profilesDir = baseDirs[0] + const profilePath = profilesDir ? this.findFirefoxProfilePath(profilesDir) : '' + return profilePath || fallbackPath + } + + if (browser === 'safari') { + const safariPath = baseDirs[0] + if (safariPath && this.isDirectory(safariPath)) { + return safariPath + } + return fallbackPath + } + + if (baseDirs.length === 0) { + return fallbackPath + } + + let detectedPath = '' + for (const baseDir of baseDirs) { + if (!baseDir) { + continue + } + const candidates = + browser === 'opera' + ? [baseDir, path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')] + : [path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')] + detectedPath = this.pickFirstDirectory(candidates) + if (detectedPath) { + break + } + } + + return detectedPath || fallbackPath + } + + @IpcMethod() + validateBrowserProfilePath( + _context: IpcContext, + browser: string, + profilePath: string + ): { valid: boolean; reason?: string } { + if (!browser || browser === 'none') { + return this.buildValidationResult(false, 'browserUnsupported') + } + + const normalizedInput = this.normalizeProfileInput(profilePath) + if (!normalizedInput) { + return this.buildValidationResult(false, 'empty') + } + + const resolvedInput = resolvePathWithHome(normalizedInput) + if (resolvedInput && this.isDirectory(resolvedInput)) { + return this.buildValidationResult(true) + } + + const looksLikePath = + resolvedInput && + (path.isAbsolute(resolvedInput) || + resolvedInput.includes('/') || + resolvedInput.includes('\\')) + if (looksLikePath) { + return this.buildValidationResult(false, 'pathNotFound') + } + + const platform = os.platform() + const homeDir = os.homedir() + const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser) + if (baseDirs.length === 0) { + return this.buildValidationResult(false, 'browserUnsupported') + } + for (const baseDir of baseDirs) { + if (!baseDir) { + continue + } + const candidate = path.join(baseDir, normalizedInput) + if (this.isDirectory(candidate)) { + return this.buildValidationResult(true) + } + } + + return this.buildValidationResult(false, 'profileNotFound') + } +} + +export { BrowserCookiesService } diff --git a/src/renderer/src/components/video/FormatSelector.tsx b/src/renderer/src/components/video/FormatSelector.tsx index a24e816..39bc518 100644 --- a/src/renderer/src/components/video/FormatSelector.tsx +++ b/src/renderer/src/components/video/FormatSelector.tsx @@ -253,17 +253,17 @@ export function FormatSelector({ onVideoFormatChange?.(value) }} > - + - + {videoFormats.map((format) => ( - {formatVideoLabel(format)} + {formatVideoLabel(format)} ))} @@ -281,18 +281,18 @@ export function FormatSelector({ onAudioFormatChange?.(value) }} > - + - - - {t('download.noAudio')} + + + {t('download.noAudio')} {audioFormats.map((format) => ( {formatAudioLabel(format)} @@ -320,17 +320,13 @@ export function FormatSelector({ onAudioFormatChange?.(value) }} > - + - + {audioFormats.map((format) => ( - - {formatAudioLabel(format)} + + {formatAudioLabel(format)} ))} diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 70d10f0..5a8e2bb 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -372,7 +372,15 @@ "app": "App Settings", "audio": "Audio Preferences", "browserForCookies": "Select browser to use cookies from", - "browserForCookiesDescription": "Browser to extract cookies from for authentication", + "browserForCookiesDescription": "Browser to extract cookies from for authentication. We'll try to detect a profile automatically.", + "browserForCookiesProfile": "Profile name or path", + "browserForCookiesProfileDescription": "Profile path for the browser selected above. Auto-filled when possible.", + "browserForCookiesProfilePlaceholder": "Profile name or full path (optional)", + "browserForCookiesProfileInvalid": "Profile path is not valid. Choose the profile folder for the selected browser.", + "browserForCookiesProfileInvalidPath": "That folder does not exist. Pick an existing profile folder.", + "browserForCookiesProfileInvalidProfile": "Profile name not found in the default browser location.", + "browserForCookiesProfileInvalidUnsupported": "No default profile location is known for this browser on this platform.", + "browserForCookiesProfileInvalidEmpty": "Enter a profile path for the selected browser.", "cookiesFile": "Cookies file", "cookiesFileDescription": "Netscape formatted cookies file to load for authentication", "clearCookiesFile": "Clear", @@ -387,7 +395,10 @@ "chromium": "Chromium", "edge": "Edge", "firefox": "Firefox", - "safari": "Safari" + "opera": "Opera", + "safari": "Safari", + "vivaldi": "Vivaldi", + "whale": "Whale" }, "configFile": "Use configuration file", "configFileDescription": "Custom configuration file for yt-dlp", diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index b6dd9e1..845dc87 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -18,17 +18,46 @@ import { } from '@renderer/components/ui/select' import { Switch } from '@renderer/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip' import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages' import type { OneClickQualityPreset } from '@shared/types' import { useAtom, useSetAtom } from 'jotai' +import { AlertTriangle, CheckCircle2 } from 'lucide-react' import { useTheme } from 'next-themes' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ipcServices } from '../lib/ipc' import { logger } from '../lib/logger' import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings' +const normalizeProfileInput = (value: string) => value.trim().replace(/^['"]|['"]$/g, '') + +const parseBrowserCookiesSetting = (value: string | undefined) => { + if (!value || value === 'none') { + return { browser: 'none', profile: '' } + } + + const separatorIndex = value.indexOf(':') + if (separatorIndex === -1) { + return { browser: value, profile: '' } + } + + const browser = value.slice(0, separatorIndex).trim() + const profile = normalizeProfileInput(value.slice(separatorIndex + 1)) + return { browser: browser || 'none', profile } +} + +const buildBrowserCookiesSetting = (browser: string, profile: string) => { + const trimmedBrowser = browser.trim() + if (!trimmedBrowser || trimmedBrowser === 'none') { + return 'none' + } + + const trimmedProfile = normalizeProfileInput(profile) + return trimmedProfile ? `${trimmedBrowser}:${trimmedProfile}` : trimmedBrowser +} + export function Settings() { const { t, i18n: i18nInstance } = useTranslation() const { theme, setTheme } = useTheme() @@ -37,24 +66,20 @@ export function Settings() { const saveSetting = useSetAtom(saveSettingAtom) const [platform, setPlatform] = useState('') const [activeTab, setActiveTab] = useState('general') + const [browserProfileValidation, setBrowserProfileValidation] = useState<{ + valid: boolean + reason?: string + }>({ valid: false }) + const lastAutoDetectBrowser = useRef(null) useEffect(() => { - logger.info('[Settings] Component mounted, loading settings...') try { loadSettings() - // Note: settings will be logged in the next useEffect after it's loaded } catch (error) { logger.error('[Settings] Failed to load settings:', error) } }, [loadSettings]) - useEffect(() => { - logger.info('[Settings] Settings state updated', { - settingsKeys: Object.keys(settings), - settingsValues: settings - }) - }, [settings]) - useEffect(() => { const fetchPlatform = async () => { try { @@ -70,20 +95,17 @@ export function Settings() { const autoLaunchSupported = platform === 'darwin' || platform === 'win32' - const handleSettingChange = async ( - key: keyof typeof settings, - value: (typeof settings)[keyof typeof settings] - ) => { - try { - logger.info('[Settings] Changing setting', { key, value, currentValue: settings[key] }) - await saveSetting({ key, value }) - toast.success(t('notifications.settingsSaved')) - logger.info('[Settings] Setting changed successfully', { key, value }) - } catch (error) { - logger.error('[Settings] Failed to change setting', { key, value, error }) - toast.error(t('settings.saveError') || 'Failed to save setting') - } - } + const handleSettingChange = useCallback( + async (key: keyof typeof settings, value: (typeof settings)[keyof typeof settings]) => { + try { + await saveSetting({ key, value }) + } catch (error) { + logger.error('[Settings] Failed to change setting', { key, value, error }) + toast.error(t('settings.saveError') || 'Failed to save setting') + } + }, + [saveSetting, t] + ) const handleSelectPath = async () => { try { @@ -146,6 +168,97 @@ export function Settings() { const activeLanguageCode = normalizeLanguageCode(i18nInstance.language) const currentLanguage = languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0] + const parsedBrowserCookies = parseBrowserCookiesSetting(settings.browserForCookies) + const browserForCookiesValue = parsedBrowserCookies.browser + const browserCookiesProfileValue = parsedBrowserCookies.profile + const normalizedBrowserCookiesSetting = buildBrowserCookiesSetting( + browserForCookiesValue, + browserCookiesProfileValue + ) + const hasBrowserProfileValue = browserCookiesProfileValue.trim().length > 0 + const showBrowserProfileCheck = hasBrowserProfileValue && browserProfileValidation.valid + const showBrowserProfileWarning = hasBrowserProfileValue && !browserProfileValidation.valid + const getBrowserProfileWarningMessage = (reason?: string) => { + switch (reason) { + case 'pathNotFound': + return t('settings.browserForCookiesProfileInvalidPath') + case 'profileNotFound': + return t('settings.browserForCookiesProfileInvalidProfile') + case 'browserUnsupported': + return t('settings.browserForCookiesProfileInvalidUnsupported') + case 'empty': + return t('settings.browserForCookiesProfileInvalidEmpty') + default: + return t('settings.browserForCookiesProfileInvalid') + } + } + + useEffect(() => { + if (settings.browserForCookies !== normalizedBrowserCookiesSetting) { + void handleSettingChange('browserForCookies', normalizedBrowserCookiesSetting) + } + }, [handleSettingChange, normalizedBrowserCookiesSetting, settings.browserForCookies]) + + useEffect(() => { + const browserChanged = lastAutoDetectBrowser.current !== browserForCookiesValue + const shouldAutoDetect = + browserForCookiesValue !== 'none' && (browserChanged || !browserCookiesProfileValue) + + if (!shouldAutoDetect) { + lastAutoDetectBrowser.current = browserForCookiesValue + return + } + + const detectProfilePath = async () => { + try { + const detectedPath = + await ipcServices.browserCookies.getBrowserProfilePath(browserForCookiesValue) + const nextProfileValue = detectedPath || '' + if (nextProfileValue !== browserCookiesProfileValue) { + const nextValue = buildBrowserCookiesSetting(browserForCookiesValue, nextProfileValue) + await handleSettingChange('browserForCookies', nextValue) + } + } catch (error) { + logger.error('[Settings] Failed to detect browser profile path:', error) + } finally { + lastAutoDetectBrowser.current = browserForCookiesValue + } + } + + void detectProfilePath() + }, [browserForCookiesValue, browserCookiesProfileValue, handleSettingChange]) + + useEffect(() => { + if (browserForCookiesValue === 'none' || !hasBrowserProfileValue) { + setBrowserProfileValidation({ valid: false, reason: 'empty' }) + return + } + + let isActive = true + + const validateProfilePath = async () => { + try { + const result = await ipcServices.browserCookies.validateBrowserProfilePath( + browserForCookiesValue, + browserCookiesProfileValue + ) + if (isActive) { + setBrowserProfileValidation(result) + } + } catch (error) { + if (isActive) { + setBrowserProfileValidation({ valid: false, reason: 'pathNotFound' }) + } + logger.error('[Settings] Failed to validate browser profile path:', error) + } + } + + void validateProfilePath() + + return () => { + isActive = false + } + }, [browserForCookiesValue, browserCookiesProfileValue, hasBrowserProfileValue]) const handleLanguageChange = async (value: LanguageCode) => { if (activeLanguageCode === value) { @@ -154,7 +267,6 @@ export function Settings() { await saveSetting({ key: 'language', value }) await i18nInstance.changeLanguage(value) - toast.success(t('notifications.settingsSaved')) } return ( @@ -168,24 +280,7 @@ export function Settings() { { - logger.info('[Settings] Tab changed', { from: activeTab, to: value }) setActiveTab(value) - try { - if (value === 'advanced') { - logger.info('[Settings] Entering advanced tab', { - settings: settings, - settingsKeys: Object.keys(settings), - maxConcurrentDownloads: settings.maxConcurrentDownloads, - browserForCookies: settings.browserForCookies, - cookiesPath: settings.cookiesPath, - proxy: settings.proxy, - configPath: settings.configPath, - enableAnalytics: settings.enableAnalytics - }) - } - } catch (error) { - logger.error('[Settings] Error when entering advanced tab:', error) - } }} > @@ -414,7 +509,6 @@ export function Settings() { checked={settings.showMoreFormats ?? false} onCheckedChange={(value) => { try { - logger.info('[Settings] Toggling showMoreFormats', { value }) handleSettingChange('showMoreFormats', value) } catch (error) { logger.error('[Settings] Error toggling showMoreFormats:', error) @@ -438,22 +532,12 @@ export function Settings() { try { const maxConcurrent = settings.maxConcurrentDownloads ?? 5 const maxConcurrentStr = maxConcurrent.toString() - logger.info('[Settings] Rendering max concurrent downloads select', { - maxConcurrent, - maxConcurrentStr, - type: typeof maxConcurrent - }) return ( { try { - logger.info('[Settings] Proxy value changed', { - oldValue: proxyValue, - newValue: e.target.value - }) handleSettingChange('proxy', e.target.value) } catch (error) { logger.error('[Settings] Error changing proxy:', error) @@ -523,48 +602,6 @@ export function Settings() { })()} - - - - - - {t('settings.configFile')} - {t('settings.configFileDescription')} - - - {(() => { - try { - const configPathValue = settings.configPath ?? '' - logger.info('[Settings] Rendering config file input', { configPathValue }) - return ( -
- - - -
- ) - } catch (error) { - logger.error('[Settings] Error rendering config file input:', error) - return
Error loading config file setting
- } - })()} -
-
@@ -576,20 +613,13 @@ export function Settings() { {(() => { try { - const browserValue = settings.browserForCookies ?? 'none' - logger.info('[Settings] Rendering browser for cookies select', { - browserValue - }) return ( ) @@ -631,6 +670,72 @@ export function Settings() { + + + {t('settings.browserForCookiesProfile')} + + {t('settings.browserForCookiesProfileDescription')} + + + + {(() => { + try { + return ( +
+ { + try { + const newProfileValue = event.target.value + const nextValue = buildBrowserCookiesSetting( + browserForCookiesValue, + newProfileValue + ) + handleSettingChange('browserForCookies', nextValue) + } catch (error) { + logger.error( + '[Settings] Error changing browser cookies profile:', + error + ) + } + }} + disabled={browserForCookiesValue === 'none'} + className="w-full pr-10" + /> + {showBrowserProfileCheck ? ( + + ) : null} + {showBrowserProfileWarning ? ( + + + + + + + + {getBrowserProfileWarningMessage(browserProfileValidation.reason)} + + + ) : null} +
+ ) + } catch (error) { + logger.error( + '[Settings] Error rendering browser cookies profile input:', + error + ) + return
Error loading browser cookies profile setting
+ } + })()} +
+
+ + + {t('settings.cookiesFile')} @@ -640,7 +745,6 @@ export function Settings() { {(() => { try { const cookiesPathValue = settings.cookiesPath ?? '' - logger.info('[Settings] Rendering cookies file input', { cookiesPathValue }) return (
@@ -651,7 +755,6 @@ export function Settings() { variant="secondary" onClick={() => { try { - logger.info('[Settings] Clearing cookies path') void handleSettingChange('cookiesPath', '') } catch (error) { logger.error('[Settings] Error clearing cookies path:', error) @@ -687,6 +790,46 @@ export function Settings() { + + + + + + {t('settings.configFile')} + {t('settings.configFileDescription')} + + + {(() => { + try { + const configPathValue = settings.configPath ?? '' + return ( +
+ + + +
+ ) + } catch (error) { + logger.error('[Settings] Error rendering config file input:', error) + return
Error loading config file setting
+ } + })()} +
+
@@ -699,18 +842,11 @@ export function Settings() { {(() => { try { const analyticsValue = settings.enableAnalytics ?? true - logger.info('[Settings] Rendering enable analytics switch', { - analyticsValue - }) return ( { try { - logger.info('[Settings] Enable analytics changed', { - oldValue: analyticsValue, - newValue: value - }) handleSettingChange('enableAnalytics', value) } catch (error) { logger.error('[Settings] Error changing enable analytics:', error)