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