Skip to content

Commit

Permalink
feat: Refactoring the image loading module
Browse files Browse the repository at this point in the history
  • Loading branch information
ximu3 committed Dec 15, 2024
1 parent aade1e3 commit 7e59035
Show file tree
Hide file tree
Showing 18 changed files with 402 additions and 416 deletions.
2 changes: 1 addition & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ function createWindow(): void {
mainWindow.webContents.send('start-game-from-url', launchGameId, gamePath, mode, config)
launchGameId = null
}
}, 7000)
}, 3000)
})

mainWindow.webContents.setWindowOpenHandler((details) => {
Expand Down
5 changes: 5 additions & 0 deletions src/main/media/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
saveFileIcon,
checkIconExists
} from './common'
import { BrowserWindow } from 'electron'
import log from 'electron-log/main.js'

/**
Expand Down Expand Up @@ -56,6 +57,8 @@ export async function setMedia(
default:
throw new Error(`Invalid source type: ${source}`)
}
const mainWindow = BrowserWindow.getAllWindows()[0]
mainWindow.webContents.send('reload-db-values', `games/${gameId}/${type}`)
} catch (error) {
log.error('Failed to set media', {
gameId,
Expand All @@ -77,6 +80,8 @@ export async function setMedia(
export async function saveIcon(gameId: string, filePath: string): Promise<void> {
try {
await saveFileIcon(gameId, filePath)
const mainWindow = BrowserWindow.getAllWindows()[0]
mainWindow.webContents.send('reload-db-values', `games/${gameId}/icon`)
} catch (error) {
log.error('Failed to save icon', {
gameId,
Expand Down
8 changes: 8 additions & 0 deletions src/main/utils/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ export async function getDataPath(file: string, forceCreate?: boolean): Promise<
}
}

export function getDataPathSync(file: string): string {
const basePath = app.isPackaged
? path.join(app.getPath('userData'), 'app/database')
: path.join(getAppRootPath(), '/dev/database')

return path.join(basePath, file)
}

/**
* Get the logs file path
* @returns The path of the logs file
Expand Down
119 changes: 103 additions & 16 deletions src/main/utils/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,113 @@
import { protocol, net, app } from 'electron'
import { protocol, app } from 'electron'
import { getDataPathSync, getAppTempPath } from './path'
import path from 'path'
import fse from 'fs-extra'

export function setupProtocols(): void {
// Registered app protocol processing
protocol.handle('app', async (request) => {
const IMAGE_EXTENSIONS = ['.webp', '.png', '.jpg', '.jpeg', '.ico', '.gif']

// 创建缓存目录
const CACHE_DIR = getAppTempPath('images')
fse.ensureDirSync(CACHE_DIR)

protocol.handle('img', async (request) => {
try {
const urlObj = new URL(request.url)
const relativePath = decodeURIComponent(urlObj.pathname).replace(/^\//, '')
const filePath = path.resolve(relativePath)
const timestamp = urlObj.searchParams.get('t') || '0'

const dataPath = getDataPathSync('')
const baseFilePath = path.join(dataPath, relativePath)

// Use hash to create cached filenames to avoid long paths or special characters.
const cacheKey = Buffer.from(relativePath).toString('base64')
const cachePath = path.join(CACHE_DIR, `${cacheKey}.${timestamp}`)

// Check the cache
if (await fse.pathExists(cachePath)) {
console.log('Serving from cache:', cachePath)
const cachedData = await fse.readFile(cachePath)
return new Response(cachedData, {
status: 200,
headers: {
'Content-Type': getContentType(path.extname(baseFilePath)),
'Cache-Control': 'public, max-age=31536000'
}
})
}

console.log('Request URL:', request.url)
console.log('File path:', filePath)
// Try all possible extensions
for (const ext of IMAGE_EXTENSIONS) {
const fullPath = baseFilePath + ext

const fileUrl = 'file:///' + filePath.split(path.sep).join('/')
console.log('File URL:', fileUrl)
if (await fse.pathExists(fullPath)) {
try {
// Read the original file
const fileData = await fse.readFile(fullPath)

const response = await net.fetch(fileUrl)
// Write to cache
await fse.writeFile(cachePath, fileData)

if (!response.ok) {
throw new Error(`Failed to load file: ${response.status}`)
return new Response(fileData, {
status: 200,
headers: {
'Content-Type': getContentType(ext),
'Cache-Control': 'public, max-age=31536000'
}
})
} catch (error) {
console.error('Error reading/caching file:', error)
continue
}
}
}

return response
throw new Error('No matching image file found')
} catch (error) {
console.error('Protocol error:', error)
return new Response('Error loading file', {
status: 404
})
return new Response('Error loading file', { status: 404 })
}
})

// Registering for vnite protocol processing
// Get the MIME type
function getContentType(ext: string): string {
const mimeTypes: Record<string, string> = {
'.webp': 'image/webp',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.ico': 'image/x-icon',
'.gif': 'image/gif'
}
return mimeTypes[ext.toLowerCase()] || 'application/octet-stream'
}

// Clear expired cache
async function cleanupOldCache(): Promise<void> {
try {
const files = await fse.readdir(CACHE_DIR)
const now = Date.now()

for (const file of files) {
const filePath = path.join(CACHE_DIR, file)
const stats = await fse.stat(filePath)

// Delete cached files older than 30 days
if (now - stats.mtimeMs > 30 * 24 * 60 * 60 * 1000) {
await fse.remove(filePath)
}
}
} catch (error) {
console.error('Error cleaning up cache:', error)
}
}

// Clean cache regularly (run once a day)
setInterval(cleanupOldCache, 24 * 60 * 60 * 1000)

// Run a cleanup on startup as well
cleanupOldCache()

if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('vnite', process.execPath, [path.resolve(process.argv[1])])
Expand All @@ -39,3 +116,13 @@ export function setupProtocols(): void {
app.setAsDefaultProtocolClient('vnite')
}
}

// Export the cache cleanup methods to be called manually if needed.
export async function clearImageCache(): Promise<void> {
const CACHE_DIR = path.join(getDataPathSync(''), '.cache', 'images')
try {
await fse.emptyDir(CACHE_DIR)
} catch (error) {
console.error('Error clearing cache:', error)
}
}
2 changes: 1 addition & 1 deletion src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' app:; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: app:;"
content="default-src 'self' app:; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: img:;"
/>
</head>
<body>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
import { cn } from '~/utils'
import { useGameMedia } from '~/hooks'
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card'
import { GameImage } from '@ui/game-image'
import { Button } from '@ui/button'
import { ipcInvoke } from '~/utils'
import { toast } from 'sonner'
import { useState } from 'react'
import { UrlDialog } from './UrlDialog'

export function Media({ gameId }: { gameId: string }): JSX.Element {
const { mediaUrl: icon, refreshMedia: refreshIcon } = useGameMedia({
gameId: gameId,
type: 'icon'
})
const { mediaUrl: cover, refreshMedia: refreshCover } = useGameMedia({
gameId: gameId,
type: 'cover'
})
const { mediaUrl: background, refreshMedia: refreshBackground } = useGameMedia({
gameId: gameId,
type: 'background'
})
const [mediaUrl, setMediaUrl] = useState<string>('')
const [isUrlDialogOpen, setIsUrlDialogOpen] = useState({
icon: false,
Expand All @@ -31,17 +19,6 @@ export function Media({ gameId }: { gameId: string }): JSX.Element {
toast.promise(
async () => {
await ipcInvoke('set-game-media', gameId, type, filePath)
switch (type) {
case 'icon':
await refreshIcon()
break
case 'cover':
await refreshCover()
break
case 'background':
await refreshBackground()
break
}
},
{
loading: `正在设置 ${type}...`,
Expand All @@ -55,17 +32,6 @@ export function Media({ gameId }: { gameId: string }): JSX.Element {
toast.promise(
async () => {
await ipcInvoke('set-game-media', gameId, type, mediaUrl)
switch (type) {
case 'icon':
await refreshIcon()
break
case 'cover':
await refreshCover()
break
case 'background':
await refreshBackground()
break
}
},
{
loading: `正在设置 ${type}...`,
Expand Down Expand Up @@ -107,11 +73,12 @@ export function Media({ gameId }: { gameId: string }): JSX.Element {
/>
</div>
<div className={cn('self-center')}>
{icon ? (
<img src={icon} alt="icon" className={cn('w-16 h-16 object-cover')} />
) : (
<div>暂无图标</div>
)}
<GameImage
gameId={gameId}
type="icon"
className={cn('w-16 h-16 object-cover')}
fallback={<div>暂无图标</div>}
/>
</div>
</div>
</CardContent>
Expand Down Expand Up @@ -145,15 +112,12 @@ export function Media({ gameId }: { gameId: string }): JSX.Element {
/>
</div>
<div className={cn('self-center')}>
{background ? (
<img
src={background}
alt="background"
className={cn('w-[500px] h-[264px] object-cover')}
/>
) : (
<div>暂无背景</div>
)}
<GameImage
gameId={gameId}
type="background"
className={cn('w-[500px] h-[264px] object-cover')}
fallback={<div>暂无背景</div>}
/>
</div>
</div>
</CardContent>
Expand Down Expand Up @@ -189,11 +153,12 @@ export function Media({ gameId }: { gameId: string }): JSX.Element {
/>
</div>
<div className={cn('self-center')}>
{cover ? (
<img src={cover} alt="cover" className={cn('w-[300px] h-[458px] object-cover')} />
) : (
<div>暂无封面</div>
)}
<GameImage
gameId={gameId}
type="cover"
className={cn('w-[300px] h-[458px] object-cover')}
fallback={<div>暂无封面</div>}
/>
</div>
</div>
</CardContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cn } from '~/utils'
import { useDBSyncedState, useGameMedia } from '~/hooks'
import { useDBSyncedState } from '~/hooks'
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card'
import {
Select,
Expand All @@ -12,7 +12,7 @@ import {
} from '@ui/select'
import { Input } from '@ui/input'
import { Button } from '@ui/button'
import { ipcInvoke, ipcSend } from '~/utils'
import { ipcInvoke, ipcSend, canAccessImageFile } from '~/utils'
import { ArrayTextarea } from '@ui/array-textarea'

export function Path({ gameId }: { gameId: string }): JSX.Element {
Expand All @@ -29,11 +29,7 @@ export function Path({ gameId }: { gameId: string }): JSX.Element {
'fileConfig',
'timerPath'
])
const { mediaUrl: icon, refreshMedia } = useGameMedia({
gameId,
type: 'icon',
noToastError: true
})

const [launcherMode] = useDBSyncedState('file', `games/${gameId}/launcher.json`, ['mode'])
const [timerPath] = useDBSyncedState('', `games/${gameId}/launcher.json`, [
`${launcherMode}Config`,
Expand All @@ -58,9 +54,9 @@ export function Path({ gameId }: { gameId: string }): JSX.Element {
return
}
await setGamePath(filePath)
const icon = await canAccessImageFile(gameId, 'icon')
if (!icon) {
await ipcInvoke('save-game-icon', gameId, filePath)
await refreshMedia()
}
if (!timerPath) {
ipcSend('launcher-preset', 'default', gameId)
Expand Down
14 changes: 5 additions & 9 deletions src/renderer/src/components/Game/StartGame.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cn } from '~/utils'
import { useDBSyncedState, useGameMedia } from '~/hooks'
import { cn, canAccessImageFile } from '~/utils'
import { useDBSyncedState } from '~/hooks'
import { Button } from '@ui/button'
import { ipcSend, ipcInvoke } from '~/utils'
import { toast } from 'sonner'
Expand All @@ -15,11 +15,7 @@ export function StartGame({
const [mode] = useDBSyncedState('file', `games/${gameId}/launcher.json`, ['mode'])
const [gamePath, setGamePath] = useDBSyncedState('', `games/${gameId}/path.json`, ['gamePath'])
const { runningGames, setRunningGames } = useRunningGames()
const { mediaUrl: icon, refreshMedia } = useGameMedia({
gameId,
type: 'icon',
noToastError: true
})

const [fileConfig] = useDBSyncedState(
{
path: '',
Expand Down Expand Up @@ -56,9 +52,9 @@ export function StartGame({
toast.warning('请先设置游戏路径')
const filePath: string = await ipcInvoke('select-path-dialog', ['openFile'])
await setGamePath(filePath)
if (!icon) {
const isIconAccessible = await canAccessImageFile(gameId, 'icon')
if (!isIconAccessible) {
await ipcInvoke('save-game-icon', gameId, filePath)
await refreshMedia()
}
ipcSend('launcher-preset', 'default', gameId)
return
Expand Down
Loading

0 comments on commit 7e59035

Please sign in to comment.