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
dist
out
.conductor/
.DS_Store
.eslintcache
*.log*

View File

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

View File

@@ -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",

View File

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

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 { 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) => {

View File

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

View File

@@ -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()

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" />
<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>

View File

@@ -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>
)
}

View File

@@ -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://'))

View File

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

View File

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

View File

@@ -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
View File

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