Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ee717888f | ||
|
|
c7815eb81a | ||
|
|
3946b5ef2f | ||
|
|
ef99eb1f59 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.conductor/
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ appId: com.vidbee
|
||||
productName: VidBee
|
||||
directories:
|
||||
buildResources: build
|
||||
protocols:
|
||||
- name: VidBee
|
||||
schemes:
|
||||
- vidbee
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
@@ -19,6 +23,7 @@ nsis:
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
identity: null
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path'
|
||||
import type { AppSettings, DownloadOptions } from '../../shared/types'
|
||||
import { resolvePathWithHome } from '../utils/path-helpers'
|
||||
|
||||
export const sanitizeFilenameTemplate = (template: string): string => {
|
||||
const trimmed = template.trim()
|
||||
@@ -115,8 +116,9 @@ export const buildDownloadArgs = (
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', settings.configPath)
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
args.push(options.url)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { join } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { app, BrowserWindow, type BrowserWindowConstructorOptions, shell } from 'electron'
|
||||
import { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||
import { app, BrowserWindow, type BrowserWindowConstructorOptions, protocol, shell } from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import appIcon from '../../build/icon.png?asset'
|
||||
@@ -22,6 +24,19 @@ log.initialize()
|
||||
// Configure logger settings
|
||||
configureLogger()
|
||||
|
||||
const RENDERER_DIST_PATH = join(__dirname, '../renderer')
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: APP_PROTOCOL,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let isQuitting = false
|
||||
|
||||
@@ -84,7 +99,7 @@ export function createWindow(): void {
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
mainWindow.loadURL(`${APP_PROTOCOL_SCHEME}renderer/index.html`)
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
@@ -117,6 +132,66 @@ function setupDownloadEvents(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeRequestPath(requestUrl: URL): string {
|
||||
const rawPath = `${requestUrl.hostname}${decodeURIComponent(requestUrl.pathname)}`
|
||||
const trimmedLeading = rawPath.replace(/^\/+/, '')
|
||||
const cleaned = trimmedLeading.replace(/\/+$/, '')
|
||||
return cleaned || 'index.html'
|
||||
}
|
||||
|
||||
function isWithinBase(targetPath: string, basePath: string): boolean {
|
||||
const relativePath = relative(basePath, targetPath)
|
||||
return !relativePath.startsWith('..') && !isAbsolute(relativePath)
|
||||
}
|
||||
|
||||
function resolveVidbeeFilePath(requestUrl: URL, userDataPath: string): string | null {
|
||||
const sanitizedPath = sanitizeRequestPath(requestUrl)
|
||||
const [rootSegment, ...restSegments] = sanitizedPath.split('/')
|
||||
const rendererPath = restSegments.join('/') || 'index.html'
|
||||
|
||||
if (rootSegment === 'renderer') {
|
||||
const rendererTarget = resolve(RENDERER_DIST_PATH, rendererPath)
|
||||
|
||||
if (isWithinBase(rendererTarget, RENDERER_DIST_PATH) && existsSync(rendererTarget)) {
|
||||
return rendererTarget
|
||||
}
|
||||
}
|
||||
|
||||
const userDataTarget = resolve(userDataPath, sanitizedPath)
|
||||
|
||||
if (isWithinBase(userDataTarget, userDataPath) && existsSync(userDataTarget)) {
|
||||
return userDataTarget
|
||||
}
|
||||
|
||||
const rendererFallback = resolve(RENDERER_DIST_PATH, sanitizedPath)
|
||||
|
||||
if (isWithinBase(rendererFallback, RENDERER_DIST_PATH) && existsSync(rendererFallback)) {
|
||||
return rendererFallback
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function registerVidbeeProtocol(): void {
|
||||
try {
|
||||
const userDataPath = app.getPath('userData')
|
||||
protocol.registerFileProtocol(APP_PROTOCOL, (request, callback) => {
|
||||
const requestUrl = new URL(request.url)
|
||||
const filePath = resolveVidbeeFilePath(requestUrl, userDataPath)
|
||||
|
||||
if (!filePath) {
|
||||
log.error(`File not found for ${request.url}`)
|
||||
callback({ error: -6 })
|
||||
return
|
||||
}
|
||||
|
||||
callback(filePath)
|
||||
})
|
||||
} catch (error) {
|
||||
log.error(`Failed to register ${APP_PROTOCOL} protocol:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
function initAutoUpdater(): void {
|
||||
try {
|
||||
log.info('Initializing auto-updater...')
|
||||
@@ -189,6 +264,13 @@ app.whenReady().then(async () => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.vidbee')
|
||||
|
||||
registerVidbeeProtocol()
|
||||
|
||||
const registered = app.setAsDefaultProtocolClient(APP_PROTOCOL)
|
||||
if (!registered) {
|
||||
log.warn(`Failed to register ${APP_PROTOCOL} protocol handler`)
|
||||
}
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../download-engine/format-utils'
|
||||
import { settingsManager } from '../settings'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
import { resolvePathWithHome } from '../utils/path-helpers'
|
||||
import { DownloadQueue } from './download-queue'
|
||||
import { ffmpegManager } from './ffmpeg-manager'
|
||||
import { historyManager } from './history-manager'
|
||||
@@ -74,8 +75,9 @@ class DownloadEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', `"${settings.configPath}"`)
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
args.push(url)
|
||||
@@ -169,8 +171,9 @@ class DownloadEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', `"${settings.configPath}"`)
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
args.push(url)
|
||||
|
||||
@@ -2,7 +2,7 @@ import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import fsPromises from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||
import { app } from 'electron'
|
||||
|
||||
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||
@@ -37,7 +37,11 @@ export class ThumbnailCache {
|
||||
return null
|
||||
}
|
||||
|
||||
if (originalUrl.startsWith('file://') || originalUrl.startsWith('data:')) {
|
||||
if (
|
||||
originalUrl.startsWith(APP_PROTOCOL_SCHEME) ||
|
||||
originalUrl.startsWith('file://') ||
|
||||
originalUrl.startsWith('data:')
|
||||
) {
|
||||
return originalUrl
|
||||
}
|
||||
|
||||
@@ -59,7 +63,7 @@ export class ThumbnailCache {
|
||||
|
||||
const existingPath = await this.findExistingPath(basePath, defaultExtension)
|
||||
if (existingPath) {
|
||||
return pathToFileURL(existingPath).toString()
|
||||
return this.toAppProtocolUrl(existingPath)
|
||||
}
|
||||
|
||||
const response = await fetch(originalUrl)
|
||||
@@ -75,7 +79,7 @@ export class ThumbnailCache {
|
||||
const finalPath = `${basePath}${extension}`
|
||||
|
||||
await fsPromises.writeFile(finalPath, buffer)
|
||||
return pathToFileURL(finalPath).toString()
|
||||
return this.toAppProtocolUrl(finalPath)
|
||||
} catch (error) {
|
||||
console.error('Failed to cache thumbnail:', error)
|
||||
return null
|
||||
@@ -132,6 +136,13 @@ export class ThumbnailCache {
|
||||
const basePath = path.join(cacheDir, `${hash}`)
|
||||
return { basePath, defaultExtension: extension }
|
||||
}
|
||||
|
||||
private toAppProtocolUrl(filePath: string): string {
|
||||
const userDataPath = app.getPath('userData')
|
||||
const relativePath = path.relative(userDataPath, filePath).replace(/\\/g, '/')
|
||||
|
||||
return `${APP_PROTOCOL_SCHEME}${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
export const thumbnailCache = new ThumbnailCache()
|
||||
|
||||
19
src/main/utils/path-helpers.ts
Normal file
19
src/main/utils/path-helpers.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
export const resolvePathWithHome = (rawPath?: string | null): string | undefined => {
|
||||
const trimmed = rawPath?.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (trimmed === '~') {
|
||||
return os.homedir()
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
||||
return path.join(os.homedir(), trimmed.slice(2))
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>VidBee</title>
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-eval' https://rybbit.102417.xyz; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-eval' https://rybbit.102417.xyz; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: vidbee: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAtom, useSetAtom } from 'jotai'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcEvents, ipcServices } from './lib/ipc'
|
||||
import { About } from './pages/About'
|
||||
@@ -19,8 +20,36 @@ import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptio
|
||||
|
||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
||||
|
||||
const pageToPath: Record<Page, string> = {
|
||||
home: '/',
|
||||
subscriptions: '/subscriptions',
|
||||
settings: '/settings',
|
||||
about: '/about',
|
||||
sites: '/sites'
|
||||
}
|
||||
|
||||
const normalizePathname = (pathname: string): string => {
|
||||
const trimmed = pathname.replace(/\/+$/, '')
|
||||
return trimmed === '' ? '/' : trimmed
|
||||
}
|
||||
|
||||
const pathToPage = (pathname: string): Page => {
|
||||
const normalized = normalizePathname(pathname)
|
||||
switch (normalized) {
|
||||
case '/subscriptions':
|
||||
return 'subscriptions'
|
||||
case '/settings':
|
||||
return 'settings'
|
||||
case '/about':
|
||||
return 'about'
|
||||
case '/sites':
|
||||
return 'sites'
|
||||
default:
|
||||
return 'home'
|
||||
}
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('home')
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
|
||||
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
||||
@@ -29,6 +58,16 @@ function AppContent() {
|
||||
const { t } = useTranslation()
|
||||
const updateDownloadInProgressRef = useRef(false)
|
||||
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const currentPage = pathToPage(location.pathname)
|
||||
|
||||
const handlePageChange = (page: Page) => {
|
||||
const targetPath = pageToPath[page] ?? '/'
|
||||
if (normalizePathname(location.pathname) !== targetPath) {
|
||||
navigate(targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
@@ -185,37 +224,10 @@ function AppContent() {
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'home':
|
||||
return (
|
||||
<Home
|
||||
onOpenSupportedSites={() => setCurrentPage('sites')}
|
||||
onOpenSettings={() => setCurrentPage('settings')}
|
||||
/>
|
||||
)
|
||||
case 'settings':
|
||||
return <Settings />
|
||||
case 'subscriptions':
|
||||
return <Subscriptions />
|
||||
case 'about':
|
||||
return <About />
|
||||
case 'sites':
|
||||
return <SupportedSites />
|
||||
default:
|
||||
return (
|
||||
<Home
|
||||
onOpenSupportedSites={() => setCurrentPage('sites')}
|
||||
onOpenSettings={() => setCurrentPage('settings')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row h-screen">
|
||||
{/* Sidebar Navigation */}
|
||||
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
|
||||
@@ -227,7 +239,22 @@ function AppContent() {
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<div className="w-full overflow-hidden" style={{ maxWidth: '100%' }}>
|
||||
{renderPage()}
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
onOpenSupportedSites={() => handlePageChange('sites')}
|
||||
onOpenSettings={() => handlePageChange('settings')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/subscriptions" element={<Subscriptions />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/sites" element={<SupportedSites />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
@@ -240,7 +267,9 @@ function AppContent() {
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<AppContent />
|
||||
<HashRouter>
|
||||
<AppContent />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
@@ -7,7 +8,7 @@ interface RemoteImageProps {
|
||||
/**
|
||||
* Remote image URL or local file path
|
||||
* If it's a remote URL (http/https), it will be cached automatically
|
||||
* If it's a local path (file:// or data:), it will be used directly
|
||||
* If it's a local path (vidbee://, file:// or data:), it will be used directly
|
||||
*/
|
||||
src?: string | null
|
||||
alt: string
|
||||
@@ -36,7 +37,7 @@ interface RemoteImageProps {
|
||||
*
|
||||
* This component automatically handles:
|
||||
* - Remote URL caching via thumbnail cache service
|
||||
* - Local file paths (file://, data:)
|
||||
* - Local file paths (vidbee://, file://, data:)
|
||||
* - Loading states and error handling
|
||||
* - Placeholder display with loading indicator
|
||||
*
|
||||
@@ -46,7 +47,7 @@ interface RemoteImageProps {
|
||||
* <RemoteImage src="https://example.com/image.jpg" alt="Example" />
|
||||
*
|
||||
* // Local file path (no caching)
|
||||
* <RemoteImage src="file:///path/to/image.jpg" alt="Local" />
|
||||
* <RemoteImage src="vidbee://thumbnails/example.jpg" alt="Local" />
|
||||
*
|
||||
* // Without cache (direct load)
|
||||
* <RemoteImage src="https://example.com/image.jpg" alt="Example" useCache={false} />
|
||||
@@ -80,6 +81,7 @@ export function RemoteImage({
|
||||
const shouldUseCache =
|
||||
useCache &&
|
||||
src &&
|
||||
!src.startsWith(APP_PROTOCOL_SCHEME) &&
|
||||
!src.startsWith('file://') &&
|
||||
!src.startsWith('data:') &&
|
||||
(src.startsWith('http://') || src.startsWith('https://'))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
|
||||
export const useCachedThumbnail = (url?: string | null): string | undefined => {
|
||||
@@ -14,7 +14,11 @@ export const useCachedThumbnail = (url?: string | null): string | undefined => {
|
||||
return
|
||||
}
|
||||
|
||||
if (url.startsWith('file://') || url.startsWith('data:')) {
|
||||
if (
|
||||
url.startsWith(APP_PROTOCOL_SCHEME) ||
|
||||
url.startsWith('file://') ||
|
||||
url.startsWith('data:')
|
||||
) {
|
||||
setCachedUrl(url)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -322,6 +322,7 @@
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"chromium": "Chromium",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
|
||||
@@ -436,6 +436,9 @@ export function Settings() {
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem>
|
||||
<SelectItem value="chromium">
|
||||
{t('settings.browserOptions.chromium')}
|
||||
</SelectItem>
|
||||
<SelectItem value="firefox">
|
||||
{t('settings.browserOptions.firefox')}
|
||||
</SelectItem>
|
||||
|
||||
2
src/shared/constants.ts
Normal file
2
src/shared/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const APP_PROTOCOL = 'vidbee'
|
||||
export const APP_PROTOCOL_SCHEME = `${APP_PROTOCOL}://`
|
||||
Reference in New Issue
Block a user