Handle download deep links (#41)

* Add download deep link support

* Queue deep links until renderer ready
This commit is contained in:
Nexmoe
2025-12-19 22:18:11 +08:00
committed by GitHub
parent db16d5d051
commit 58a4d2da36
3 changed files with 231 additions and 95 deletions

View File

@@ -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.

View File

@@ -54,6 +54,7 @@ function AppContent() {
const { t } = useTranslation()
const updateDownloadInProgressRef = useRef(false)
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
const [deepLinkUrl, setDeepLinkUrl] = useState<string | null>(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={
<Home
deepLinkUrl={deepLinkUrl}
onConsumeDeepLink={() => setDeepLinkUrl(null)}
onOpenSupportedSites={handleOpenSupportedSites}
onOpenSettings={() => handlePageChange('settings')}
/>

View File

@@ -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<HTMLInputElement>) => {
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 () => {