Compare commits

..

4 Commits

Author SHA1 Message Date
Nexmoe
1ee717888f chore: release v1.0.2 2025-12-06 16:46:10 +08:00
Nexmoe
c7815eb81a feat: register vidbee protocol and respect vidbee:// paths in images (#35) 2025-12-06 16:45:48 +08:00
Nexmoe
3946b5ef2f feat: resolve ~ in config paths before passing to engine (#34) 2025-11-29 11:10:16 +08:00
Nexmoe
ef99eb1f59 feat: add Chromium option closes #32 (#33) 2025-11-22 19:28:20 +08:00
15 changed files with 215 additions and 52 deletions

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
node_modules node_modules
dist dist
out out
.conductor/
.DS_Store .DS_Store
.eslintcache .eslintcache
*.log* *.log*

View File

@@ -2,6 +2,10 @@ appId: com.vidbee
productName: VidBee productName: VidBee
directories: directories:
buildResources: build buildResources: build
protocols:
- name: VidBee
schemes:
- vidbee
files: files:
- '!**/.vscode/*' - '!**/.vscode/*'
- '!src/*' - '!src/*'
@@ -19,6 +23,7 @@ nsis:
uninstallDisplayName: ${productName} uninstallDisplayName: ${productName}
createDesktopShortcut: always createDesktopShortcut: always
mac: mac:
identity: null
entitlementsInherit: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist
notarize: false notarize: false
artifactName: ${name}-${version}-${arch}.${ext} artifactName: ${name}-${version}-${arch}.${ext}

View File

@@ -1,6 +1,6 @@
{ {
"name": "vidbee", "name": "vidbee",
"version": "1.0.1", "version": "1.0.2",
"description": "A modern Electron application for downloading videos and audios", "description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "VidBee", "author": "VidBee",

View File

@@ -1,5 +1,6 @@
import path from 'node:path' import path from 'node:path'
import type { AppSettings, DownloadOptions } from '../../shared/types' import type { AppSettings, DownloadOptions } from '../../shared/types'
import { resolvePathWithHome } from '../utils/path-helpers'
export const sanitizeFilenameTemplate = (template: string): string => { export const sanitizeFilenameTemplate = (template: string): string => {
const trimmed = template.trim() const trimmed = template.trim()
@@ -115,8 +116,9 @@ export const buildDownloadArgs = (
args.push('--proxy', settings.proxy) args.push('--proxy', settings.proxy)
} }
if (settings.configPath) { const configPath = resolvePathWithHome(settings.configPath)
args.push('--config-location', settings.configPath) if (configPath) {
args.push('--config-location', configPath)
} }
args.push(options.url) args.push(options.url)

View File

@@ -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 { 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 log from 'electron-log/main'
import { autoUpdater } from 'electron-updater' import { autoUpdater } from 'electron-updater'
import appIcon from '../../build/icon.png?asset' import appIcon from '../../build/icon.png?asset'
@@ -22,6 +24,19 @@ log.initialize()
// Configure logger settings // Configure logger settings
configureLogger() 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 mainWindow: BrowserWindow | null = null
let isQuitting = false let isQuitting = false
@@ -84,7 +99,7 @@ export function createWindow(): void {
if (process.env.ELECTRON_RENDERER_URL) { if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else { } else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html')) mainWindow.loadURL(`${APP_PROTOCOL_SCHEME}renderer/index.html`)
} }
mainWindow.webContents.on('did-finish-load', () => { 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 { function initAutoUpdater(): void {
try { try {
log.info('Initializing auto-updater...') log.info('Initializing auto-updater...')
@@ -189,6 +264,13 @@ app.whenReady().then(async () => {
// Set app user model id for windows // Set app user model id for windows
electronApp.setAppUserModelId('com.vidbee') 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 // Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production. // and ignore CommandOrControl + R in production.
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {

View File

@@ -20,6 +20,7 @@ import {
} from '../download-engine/format-utils' } from '../download-engine/format-utils'
import { settingsManager } from '../settings' import { settingsManager } from '../settings'
import { scopedLoggers } from '../utils/logger' import { scopedLoggers } from '../utils/logger'
import { resolvePathWithHome } from '../utils/path-helpers'
import { DownloadQueue } from './download-queue' import { DownloadQueue } from './download-queue'
import { ffmpegManager } from './ffmpeg-manager' import { ffmpegManager } from './ffmpeg-manager'
import { historyManager } from './history-manager' import { historyManager } from './history-manager'
@@ -74,8 +75,9 @@ class DownloadEngine extends EventEmitter {
} }
// Add config file if configured // Add config file if configured
if (settings.configPath) { const configPath = resolvePathWithHome(settings.configPath)
args.push('--config-location', `"${settings.configPath}"`) if (configPath) {
args.push('--config-location', configPath)
} }
args.push(url) args.push(url)
@@ -169,8 +171,9 @@ class DownloadEngine extends EventEmitter {
} }
// Add config file if configured // Add config file if configured
if (settings.configPath) { const configPath = resolvePathWithHome(settings.configPath)
args.push('--config-location', `"${settings.configPath}"`) if (configPath) {
args.push('--config-location', configPath)
} }
args.push(url) args.push(url)

View File

@@ -2,7 +2,7 @@ import crypto from 'node:crypto'
import fs from 'node:fs' import fs from 'node:fs'
import fsPromises from 'node:fs/promises' import fsPromises from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { pathToFileURL } from 'node:url' import { APP_PROTOCOL_SCHEME } from '@shared/constants'
import { app } from 'electron' import { app } from 'electron'
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']) const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
@@ -37,7 +37,11 @@ export class ThumbnailCache {
return null return null
} }
if (originalUrl.startsWith('file://') || originalUrl.startsWith('data:')) { if (
originalUrl.startsWith(APP_PROTOCOL_SCHEME) ||
originalUrl.startsWith('file://') ||
originalUrl.startsWith('data:')
) {
return originalUrl return originalUrl
} }
@@ -59,7 +63,7 @@ export class ThumbnailCache {
const existingPath = await this.findExistingPath(basePath, defaultExtension) const existingPath = await this.findExistingPath(basePath, defaultExtension)
if (existingPath) { if (existingPath) {
return pathToFileURL(existingPath).toString() return this.toAppProtocolUrl(existingPath)
} }
const response = await fetch(originalUrl) const response = await fetch(originalUrl)
@@ -75,7 +79,7 @@ export class ThumbnailCache {
const finalPath = `${basePath}${extension}` const finalPath = `${basePath}${extension}`
await fsPromises.writeFile(finalPath, buffer) await fsPromises.writeFile(finalPath, buffer)
return pathToFileURL(finalPath).toString() return this.toAppProtocolUrl(finalPath)
} catch (error) { } catch (error) {
console.error('Failed to cache thumbnail:', error) console.error('Failed to cache thumbnail:', error)
return null return null
@@ -132,6 +136,13 @@ export class ThumbnailCache {
const basePath = path.join(cacheDir, `${hash}`) const basePath = path.join(cacheDir, `${hash}`)
return { basePath, defaultExtension: extension } 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() export const thumbnailCache = new ThumbnailCache()

View 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
}

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>VidBee</title> <title>VidBee</title>
<meta http-equiv="Content-Security-Policy" <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> </head>
<body> <body>

View File

@@ -7,6 +7,7 @@ import { useAtom, useSetAtom } from 'jotai'
import { ThemeProvider } from 'next-themes' import { ThemeProvider } from 'next-themes'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ipcEvents, ipcServices } from './lib/ipc' import { ipcEvents, ipcServices } from './lib/ipc'
import { About } from './pages/About' import { About } from './pages/About'
@@ -19,8 +20,36 @@ import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptio
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites' 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() { function AppContent() {
const [currentPage, setCurrentPage] = useState<Page>('home')
const [platform, setPlatform] = useState<string>('') const [platform, setPlatform] = useState<string>('')
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom) const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
const setSubscriptions = useSetAtom(setSubscriptionsAtom) const setSubscriptions = useSetAtom(setSubscriptionsAtom)
@@ -29,6 +58,16 @@ function AppContent() {
const { t } = useTranslation() const { t } = useTranslation()
const updateDownloadInProgressRef = useRef(false) const updateDownloadInProgressRef = useRef(false)
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null) 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(() => { useEffect(() => {
loadSettings() loadSettings()
@@ -185,37 +224,10 @@ function AppContent() {
} }
}, [t]) }, [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 ( return (
<div className="flex flex-row h-screen"> <div className="flex flex-row h-screen">
{/* Sidebar Navigation */} {/* Sidebar Navigation */}
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} /> <Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
{/* Main Content */} {/* Main Content */}
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background"> <main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
@@ -227,7 +239,22 @@ function AppContent() {
style={{ maxWidth: '100%' }} style={{ maxWidth: '100%' }}
> >
<div className="w-full overflow-hidden" 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> </div>
</ScrollArea> </ScrollArea>
</main> </main>
@@ -240,7 +267,9 @@ function AppContent() {
function App() { function App() {
return ( return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<AppContent /> <HashRouter>
<AppContent />
</HashRouter>
</ThemeProvider> </ThemeProvider>
) )
} }

View File

@@ -1,3 +1,4 @@
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail' import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
@@ -7,7 +8,7 @@ interface RemoteImageProps {
/** /**
* Remote image URL or local file path * Remote image URL or local file path
* If it's a remote URL (http/https), it will be cached automatically * 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 src?: string | null
alt: string alt: string
@@ -36,7 +37,7 @@ interface RemoteImageProps {
* *
* This component automatically handles: * This component automatically handles:
* - Remote URL caching via thumbnail cache service * - Remote URL caching via thumbnail cache service
* - Local file paths (file://, data:) * - Local file paths (vidbee://, file://, data:)
* - Loading states and error handling * - Loading states and error handling
* - Placeholder display with loading indicator * - Placeholder display with loading indicator
* *
@@ -46,7 +47,7 @@ interface RemoteImageProps {
* <RemoteImage src="https://example.com/image.jpg" alt="Example" /> * <RemoteImage src="https://example.com/image.jpg" alt="Example" />
* *
* // Local file path (no caching) * // 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) * // Without cache (direct load)
* <RemoteImage src="https://example.com/image.jpg" alt="Example" useCache={false} /> * <RemoteImage src="https://example.com/image.jpg" alt="Example" useCache={false} />
@@ -80,6 +81,7 @@ export function RemoteImage({
const shouldUseCache = const shouldUseCache =
useCache && useCache &&
src && src &&
!src.startsWith(APP_PROTOCOL_SCHEME) &&
!src.startsWith('file://') && !src.startsWith('file://') &&
!src.startsWith('data:') && !src.startsWith('data:') &&
(src.startsWith('http://') || src.startsWith('https://')) (src.startsWith('http://') || src.startsWith('https://'))

View File

@@ -1,5 +1,5 @@
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ipcServices } from '../lib/ipc' import { ipcServices } from '../lib/ipc'
export const useCachedThumbnail = (url?: string | null): string | undefined => { export const useCachedThumbnail = (url?: string | null): string | undefined => {
@@ -14,7 +14,11 @@ export const useCachedThumbnail = (url?: string | null): string | undefined => {
return return
} }
if (url.startsWith('file://') || url.startsWith('data:')) { if (
url.startsWith(APP_PROTOCOL_SCHEME) ||
url.startsWith('file://') ||
url.startsWith('data:')
) {
setCachedUrl(url) setCachedUrl(url)
return return
} }

View File

@@ -322,6 +322,7 @@
"browserOptions": { "browserOptions": {
"brave": "Brave", "brave": "Brave",
"chrome": "Chrome", "chrome": "Chrome",
"chromium": "Chromium",
"edge": "Edge", "edge": "Edge",
"firefox": "Firefox", "firefox": "Firefox",
"safari": "Safari" "safari": "Safari"

View File

@@ -436,6 +436,9 @@ export function Settings() {
<SelectContent> <SelectContent>
<SelectItem value="none">{t('settings.none')}</SelectItem> <SelectItem value="none">{t('settings.none')}</SelectItem>
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem> <SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem>
<SelectItem value="chromium">
{t('settings.browserOptions.chromium')}
</SelectItem>
<SelectItem value="firefox"> <SelectItem value="firefox">
{t('settings.browserOptions.firefox')} {t('settings.browserOptions.firefox')}
</SelectItem> </SelectItem>

2
src/shared/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
export const APP_PROTOCOL = 'vidbee'
export const APP_PROTOCOL_SCHEME = `${APP_PROTOCOL}://`