From 58a4d2da36626a22acb91268baeea64c63c99572 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:18:11 +0800 Subject: [PATCH] Handle download deep links (#41) * Add download deep link support * Queue deep links until renderer ready --- src/main/index.ts | 98 +++++++++++++++ src/renderer/src/App.tsx | 19 +++ src/renderer/src/pages/Home.tsx | 209 +++++++++++++++++--------------- 3 files changed, 231 insertions(+), 95 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index e193014..b2b3b98 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,6 +39,78 @@ protocol.registerSchemesAsPrivileged([ let mainWindow: BrowserWindow | null = null let isQuitting = false +const pendingDeepLinkUrls: string[] = [] +let isRendererReady = false + +const parseDownloadDeepLink = (rawUrl: string): string | null => { + try { + const parsed = new URL(rawUrl) + if (parsed.protocol !== `${APP_PROTOCOL}:`) { + return null + } + + const host = parsed.hostname + const path = parsed.pathname.replace(/^\/+/, '') + const isDownloadLink = host === 'download' || path.startsWith('download') + if (!isDownloadLink) { + return null + } + + const targetUrl = parsed.searchParams.get('url') + if (!targetUrl || !targetUrl.trim()) { + return null + } + + return targetUrl.trim() + } catch (error) { + log.warn('Failed to parse deep link:', error) + return null + } +} + +const deliverDeepLink = (videoUrl: string): void => { + if (!mainWindow || !isRendererReady) { + pendingDeepLinkUrls.push(videoUrl) + return + } + + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + if (!mainWindow.isVisible()) { + mainWindow.show() + } + mainWindow.focus() + mainWindow.webContents.send('download:deeplink', videoUrl) +} + +const flushPendingDeepLinks = (): void => { + if (!mainWindow || !isRendererReady || pendingDeepLinkUrls.length === 0) { + return + } + + const pending = pendingDeepLinkUrls.splice(0, pendingDeepLinkUrls.length) + for (const url of pending) { + mainWindow.webContents.send('download:deeplink', url) + } +} + +const handleDeepLinkUrl = (rawUrl: string): void => { + const videoUrl = parseDownloadDeepLink(rawUrl) + if (!videoUrl) { + log.warn('Ignored unsupported deep link:', rawUrl) + return + } + deliverDeepLink(videoUrl) +} + +const handleDeepLinkArgv = (argv: string[]): void => { + for (const arg of argv) { + if (arg.startsWith(`${APP_PROTOCOL}://`)) { + handleDeepLinkUrl(arg) + } + } +} subscriptionManager.on('subscriptions:updated', (subscriptions) => { mainWindow?.webContents.send('subscriptions:updated', subscriptions) @@ -104,6 +176,8 @@ export function createWindow(): void { mainWindow.webContents.on('did-finish-load', () => { mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll()) + isRendererReady = true + flushPendingDeepLinks() }) // Setup download engine event forwarding to renderer @@ -257,6 +331,28 @@ function initAutoUpdater(): void { } } +const gotSingleInstanceLock = app.requestSingleInstanceLock() + +if (!gotSingleInstanceLock) { + app.quit() +} else { + app.on('second-instance', (_event, argv) => { + handleDeepLinkArgv(argv) + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.show() + mainWindow.focus() + } + }) +} + +app.on('open-url', (event, url) => { + event.preventDefault() + handleDeepLinkUrl(url) +}) + // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. @@ -310,6 +406,8 @@ app.whenReady().then(async () => { subscriptionScheduler.start() + handleDeepLinkArgv(process.argv) + app.on('activate', () => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1344185..5dc26af 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -54,6 +54,7 @@ function AppContent() { const { t } = useTranslation() const updateDownloadInProgressRef = useRef(false) const analyticsScriptRef = useRef(null) + const [deepLinkUrl, setDeepLinkUrl] = useState(null) const navigate = useNavigate() const location = useLocation() const currentPage = pathToPage(location.pathname) @@ -74,6 +75,22 @@ function AppContent() { loadSettings() }, [loadSettings]) + useEffect(() => { + const handleDeepLink = (rawUrl: unknown) => { + const url = typeof rawUrl === 'string' ? rawUrl.trim() : '' + if (!url) { + return + } + setDeepLinkUrl(url) + handlePageChange('home') + } + + ipcEvents.on('download:deeplink', handleDeepLink) + return () => { + ipcEvents.removeListener('download:deeplink', handleDeepLink) + } + }, [handlePageChange]) + useEffect(() => { loadSubscriptions() @@ -249,6 +266,8 @@ function AppContent() { path="/" element={ setDeepLinkUrl(null)} onOpenSupportedSites={handleOpenSupportedSites} onOpenSettings={() => handlePageChange('settings')} /> diff --git a/src/renderer/src/pages/Home.tsx b/src/renderer/src/pages/Home.tsx index a18eaad..3e9faab 100644 --- a/src/renderer/src/pages/Home.tsx +++ b/src/renderer/src/pages/Home.tsx @@ -124,11 +124,18 @@ const buildAudioFormatPreference = (settings: AppSettings): string => { } interface HomeProps { + deepLinkUrl?: string | null + onConsumeDeepLink?: () => void onOpenSupportedSites?: () => void onOpenSettings?: () => void } -export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) { +export function Home({ + deepLinkUrl, + onConsumeDeepLink, + onOpenSupportedSites, + onOpenSettings +}: HomeProps) { const { t } = useTranslation() const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom) const [loading] = useAtom(videoInfoLoadingAtom) @@ -292,6 +299,103 @@ export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) { await fetchVideoInfo(url.trim()) }, [url, fetchVideoInfo, t]) + const startOneClickDownload = useCallback( + async (targetUrl: string, options?: { clearInput?: boolean; setInputValue?: boolean }) => { + const trimmedUrl = targetUrl.trim() + if (!trimmedUrl) { + toast.error(t('errors.emptyUrl')) + return + } + + if (options?.setInputValue) { + setUrl(trimmedUrl) + } + + const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}` + + const downloadItem = { + id, + url: trimmedUrl, + title: t('download.fetchingVideoInfo'), + type: settings.oneClickDownloadType, + status: 'pending' as const, + progress: { percent: 0 }, + createdAt: Date.now() + } + + const format = + settings.oneClickDownloadType === 'video' + ? buildVideoFormatPreference(settings) + : buildAudioFormatPreference(settings) + + addDownload(downloadItem) + + try { + await ipcServices.download.startDownload(id, { + url: trimmedUrl, + type: settings.oneClickDownloadType, + format + }) + + try { + const videoInfo = await ipcServices.download.getVideoInfo(trimmedUrl) + + updateDownload({ + id, + changes: { + title: videoInfo.title, + thumbnail: videoInfo.thumbnail, + duration: videoInfo.duration, + description: videoInfo.description, + channel: videoInfo.extractor_key, + uploader: videoInfo.extractor_key, + createdAt: Date.now(), + startedAt: Date.now() + } + }) + + await ipcServices.download.updateDownloadInfo(id, { + title: videoInfo.title, + thumbnail: videoInfo.thumbnail, + duration: videoInfo.duration, + description: videoInfo.description, + channel: videoInfo.extractor_key, + uploader: videoInfo.extractor_key, + createdAt: Date.now(), + startedAt: Date.now() + }) + + toast.success(t('download.videoInfoUpdated')) + } catch (infoError) { + console.warn('Failed to fetch video info for one-click download:', infoError) + updateDownload({ + id, + changes: { + title: t('download.infoUnavailable'), + createdAt: Date.now(), + startedAt: Date.now() + } + }) + + await ipcServices.download.updateDownloadInfo(id, { + title: t('download.infoUnavailable'), + createdAt: Date.now(), + startedAt: Date.now() + }) + } + + toast.success(t('download.oneClickDownloadStarted')) + if (options?.clearInput) { + setUrl('') + } + } catch (error) { + console.error('Failed to start one-click download:', error) + toast.error(t('notifications.downloadFailed')) + } + }, + [settings, addDownload, updateDownload, t, setUrl] + ) + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { @@ -302,103 +406,18 @@ export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) { ) const handleOneClickDownload = useCallback(async () => { - if (!url.trim()) { - toast.error(t('errors.emptyUrl')) + await startOneClickDownload(url, { clearInput: true }) + }, [startOneClickDownload, url]) + + useEffect(() => { + if (!deepLinkUrl) { return } - const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}` - - // Create initial download item with placeholder info - const trimmedUrl = url.trim() - - const downloadItem = { - id, - url: trimmedUrl, - title: t('download.fetchingVideoInfo'), - type: settings.oneClickDownloadType, - status: 'pending' as const, - progress: { percent: 0 }, - createdAt: Date.now() - } - - const options = { - url: trimmedUrl, - type: settings.oneClickDownloadType, - format: - settings.oneClickDownloadType === 'video' - ? buildVideoFormatPreference(settings) - : buildAudioFormatPreference(settings) - } - - addDownload(downloadItem) - - try { - // Start download immediately - await ipcServices.download.startDownload(id, options) - - // Fetch video info in parallel to update the download item - try { - const videoInfo = await ipcServices.download.getVideoInfo(url.trim()) - - // Update the download item in the renderer state - updateDownload({ - id, - changes: { - title: videoInfo.title, - thumbnail: videoInfo.thumbnail, - duration: videoInfo.duration, - description: videoInfo.description, - // Extract additional metadata if available - channel: videoInfo.extractor_key, - uploader: videoInfo.extractor_key, - createdAt: Date.now(), - startedAt: Date.now() - } - }) - - // Also update the download info in the main process queue - await ipcServices.download.updateDownloadInfo(id, { - title: videoInfo.title, - thumbnail: videoInfo.thumbnail, - duration: videoInfo.duration, - description: videoInfo.description, - channel: videoInfo.extractor_key, - uploader: videoInfo.extractor_key, - createdAt: Date.now(), - startedAt: Date.now() - }) - - // Show a subtle notification that video info was updated - toast.success(t('download.videoInfoUpdated')) - } catch (infoError) { - console.warn('Failed to fetch video info for one-click download:', infoError) - // Keep the placeholder title if video info fetch fails - // Update the title to indicate info fetch failed - updateDownload({ - id, - changes: { - title: t('download.infoUnavailable'), - createdAt: Date.now(), - startedAt: Date.now() - } - }) - - // Also update the main process queue - await ipcServices.download.updateDownloadInfo(id, { - title: t('download.infoUnavailable'), - createdAt: Date.now(), - startedAt: Date.now() - }) - } - - toast.success(t('download.oneClickDownloadStarted')) - setUrl('') // Clear the URL after starting download - } catch (error) { - console.error('Failed to start one-click download:', error) - toast.error(t('notifications.downloadFailed')) - } - }, [url, settings, addDownload, updateDownload, t]) + setActiveTab('single') + void startOneClickDownload(deepLinkUrl, { setInputValue: true }) + onConsumeDeepLink?.() + }, [deepLinkUrl, onConsumeDeepLink, startOneClickDownload]) // Playlist handlers const handlePastePlaylistUrl = useCallback(async () => {