Handle download deep links (#41)
* Add download deep link support * Queue deep links until renderer ready
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user