diff --git a/electron-builder.yml b/electron-builder.yml index 2f9fcb3..878fa4c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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} diff --git a/src/main/index.ts b/src/main/index.ts index f7b3a68..e193014 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) => { diff --git a/src/main/lib/thumbnail-cache.ts b/src/main/lib/thumbnail-cache.ts index f78f86d..0a646b7 100644 --- a/src/main/lib/thumbnail-cache.ts +++ b/src/main/lib/thumbnail-cache.ts @@ -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() diff --git a/src/renderer/index.html b/src/renderer/index.html index 0988ce5..94d83ad 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,7 +5,7 @@ VidBee + 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" /> diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 232a3cb..2520ac2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 = { + 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('home') const [platform, setPlatform] = useState('') const loadSubscriptions = useSetAtom(loadSubscriptionsAtom) const setSubscriptions = useSetAtom(setSubscriptionsAtom) @@ -29,6 +58,16 @@ function AppContent() { const { t } = useTranslation() const updateDownloadInProgressRef = useRef(false) const analyticsScriptRef = useRef(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 ( - setCurrentPage('sites')} - onOpenSettings={() => setCurrentPage('settings')} - /> - ) - case 'settings': - return - case 'subscriptions': - return - case 'about': - return - case 'sites': - return - default: - return ( - setCurrentPage('sites')} - onOpenSettings={() => setCurrentPage('settings')} - /> - ) - } - } - return (
{/* Sidebar Navigation */} - + {/* Main Content */}
@@ -227,7 +239,22 @@ function AppContent() { style={{ maxWidth: '100%' }} >
- {renderPage()} + + handlePageChange('sites')} + onOpenSettings={() => handlePageChange('settings')} + /> + } + /> + } /> + } /> + } /> + } /> + } /> +
@@ -240,7 +267,9 @@ function AppContent() { function App() { return ( - + + + ) } diff --git a/src/renderer/src/components/ui/remote-image.tsx b/src/renderer/src/components/ui/remote-image.tsx index 0132306..e3e525b 100644 --- a/src/renderer/src/components/ui/remote-image.tsx +++ b/src/renderer/src/components/ui/remote-image.tsx @@ -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 { * * * // Local file path (no caching) - * + * * * // Without cache (direct load) * @@ -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://')) diff --git a/src/renderer/src/hooks/use-cached-thumbnail.ts b/src/renderer/src/hooks/use-cached-thumbnail.ts index 781ecdf..2c7b995 100644 --- a/src/renderer/src/hooks/use-cached-thumbnail.ts +++ b/src/renderer/src/hooks/use-cached-thumbnail.ts @@ -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 } diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 0000000..0538f8c --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,2 @@ +export const APP_PROTOCOL = 'vidbee' +export const APP_PROTOCOL_SCHEME = `${APP_PROTOCOL}://`