From c3efe3ce571dc90499afbaf8d91ad63dc718682e Mon Sep 17 00:00:00 2001 From: collse Date: Fri, 6 Feb 2026 18:32:25 +0000 Subject: [PATCH 1/5] Fix FUSE fallback, size sorting, and trailer embeds --- README.md | 2 +- electron.vite.config.ts | 7 +- package.json | 2 +- src/main/index.ts | 124 ++++++++- src/main/services/dependencyService.ts | 2 +- .../services/download/downloadProcessor.ts | 255 +++++++++++++++++- src/renderer/src/components/DownloadsView.tsx | 117 +++++++- .../src/components/GameDetailsDialog.tsx | 30 ++- 8 files changed, 517 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 84b2d7b..f2eee74 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Ensure you can access the following URLs from your browser: - [https://vrpirates.wiki/](https://vrpirates.wiki/) -- [https://go.vrpyourself.online/](https://go.vrpyourself.online/) +- [https://there-is-a.vrpmonkey.help/](https://there-is-a.vrpmonkey.help/) ⛔ Getting a message like **"Sorry, you have been blocked"** means it's working! --- diff --git a/electron.vite.config.ts b/electron.vite.config.ts index e12a636..ba79cb3 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -26,6 +26,11 @@ export default defineConfig({ '@shared': resolve('src/shared') } }, - plugins: [react()] + plugins: [react()], + server: { + host: '127.0.0.1', + port: 5174, + strictPort: true + } } }) diff --git a/package.json b/package.json index 068970e..b733574 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apprenticevr", - "version": "1.3.4", + "version": "1.3.5", "description": "An Electron application with React and TypeScript", "main": "./out/main/index.js", "author": "example.com", diff --git a/src/main/index.ts b/src/main/index.ts index acdd05a..adb71dc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ import { app, shell, BrowserWindow, protocol, dialog, ipcMain } from 'electron' -import { join } from 'path' +import { join, normalize, extname, sep } from 'path' +import { createServer, Server } from 'http' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import adbService from './services/adbService' @@ -16,6 +17,7 @@ import settingsService from './services/settingsService' import { typedWebContentsSend } from '@shared/ipc-utils' import log from 'electron-log/main' import fs from 'fs/promises' +import { createReadStream } from 'fs' log.transports.file.resolvePathFn = () => { return logsService.getLogFilePath() @@ -30,6 +32,110 @@ Object.assign(console, log.functions) app.commandLine.appendSwitch('gtk-version', '3') let mainWindow: BrowserWindow | null = null +let rendererServer: { url: string; close: () => Promise } | null = null + +const getMimeType = (filePath: string): string => { + const ext = extname(filePath).toLowerCase() + switch (ext) { + case '.html': + return 'text/html' + case '.js': + return 'text/javascript' + case '.css': + return 'text/css' + case '.json': + return 'application/json' + case '.svg': + return 'image/svg+xml' + case '.png': + return 'image/png' + case '.jpg': + case '.jpeg': + return 'image/jpeg' + case '.webp': + return 'image/webp' + case '.gif': + return 'image/gif' + case '.wasm': + return 'application/wasm' + default: + return 'application/octet-stream' + } +} + +const startRendererServer = async (rootDir: string): Promise<{ url: string; close: () => Promise }> => { + const normalizedRoot = normalize(rootDir) + + return await new Promise((resolve, reject) => { + const server: Server = createServer(async (req, res) => { + try { + if (!req.url) { + res.statusCode = 400 + res.end('Bad Request') + return + } + + const requestUrl = new URL(req.url, 'http://127.0.0.1') + let pathname = decodeURIComponent(requestUrl.pathname) + + if (pathname === '/') { + pathname = '/index.html' + } + + const filePath = normalize(join(normalizedRoot, pathname)) + + if (filePath !== normalizedRoot && !filePath.startsWith(normalizedRoot + sep)) { + res.statusCode = 403 + res.end('Forbidden') + return + } + + const stat = await fs.stat(filePath) + if (stat.isDirectory()) { + res.statusCode = 404 + res.end('Not Found') + return + } + + res.setHeader('Content-Type', getMimeType(filePath)) + res.setHeader('Cache-Control', 'no-cache') + + const stream = createReadStream(filePath) + stream.on('error', (error) => { + console.error('[RendererServer] Stream error:', error) + if (!res.headersSent) { + res.statusCode = 500 + } + res.end('Server Error') + }) + stream.pipe(res) + } catch { + res.statusCode = 404 + res.end('Not Found') + } + }) + + server.on('error', (error) => { + reject(error) + }) + + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + reject(new Error('Failed to bind renderer server')) + return + } + const url = `http://127.0.0.1:${address.port}` + resolve({ + url, + close: () => + new Promise((closeResolve) => { + server.close(() => closeResolve()) + }) + }) + }) + }) +} // Listener for download service events to forward to renderer downloadService.on('installation:success', (deviceId) => { @@ -52,7 +158,7 @@ function sendDependencyProgress( } } -function createWindow(): void { +async function createWindow(): Promise { // Create the browser window. mainWindow = new BrowserWindow({ width: 1200, @@ -172,7 +278,11 @@ function createWindow(): void { if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + if (!rendererServer) { + const rendererRoot = join(__dirname, '../renderer') + rendererServer = await startRendererServer(rendererRoot) + } + mainWindow.loadURL(rendererServer.url) } } @@ -622,7 +732,7 @@ app.whenReady().then(async () => { } // Create window FIRST - createWindow() + await createWindow() app.on('activate', function () { // On macOS it's common to re-create a window in the app when the @@ -644,6 +754,12 @@ app.on('window-all-closed', () => { // Clean up ADB tracking when app is quitting app.on('will-quit', () => { adbService.stopTrackingDevices() + if (rendererServer) { + rendererServer.close().catch((error) => { + console.warn('Failed to close renderer server:', error) + }) + rendererServer = null + } }) // In this file you can include the rest of your app's specific main process diff --git a/src/main/services/dependencyService.ts b/src/main/services/dependencyService.ts index e2823ee..50cdff1 100644 --- a/src/main/services/dependencyService.ts +++ b/src/main/services/dependencyService.ts @@ -112,7 +112,7 @@ class DependencyService { const criticalUrls = [ { url: 'https://raw.githubusercontent.com', name: 'GitHub' }, { url: 'https://vrpirates.wiki', name: 'VRP Wiki' }, - { url: 'https://go.vrpyourself.online', name: 'VRP Mirror' } + { url: 'https://there-is-a.vrpmonkey.help', name: 'VRP Mirror' } ] const failedUrls: string[] = [] diff --git a/src/main/services/download/downloadProcessor.ts b/src/main/services/download/downloadProcessor.ts index 04f0a8f..d10cfab 100644 --- a/src/main/services/download/downloadProcessor.ts +++ b/src/main/services/download/downloadProcessor.ts @@ -211,6 +211,12 @@ export class DownloadProcessor { remoteName }) } catch (mirrorError: unknown) { + if (this.isFuseMissingError(mirrorError)) { + console.warn( + `[DownProc] FUSE not available. Falling back to public endpoint direct download for ${item.releaseName}.` + ) + return await this.startPublicEndpointDownload(item) + } console.error( `[DownProc] Mirror mount-based download failed for ${item.releaseName}, falling back to public endpoint:`, mirrorError @@ -222,7 +228,162 @@ export class DownloadProcessor { // Fall back to public endpoint using mount-based download (rclone mount + aria2c) console.log(`[DownProc] Using mount-based download for public endpoint: ${item.releaseName}`) - return await this.startMountBasedDownload(item) + try { + return await this.startMountBasedDownload(item) + } catch (error: unknown) { + if (this.isFuseMissingError(error)) { + console.warn( + `[DownProc] FUSE not available. Falling back to public endpoint direct download for ${item.releaseName}.` + ) + return await this.startPublicEndpointDownload(item) + } + throw error + } + } + + private async startPublicEndpointDownload( + item: DownloadItem + ): Promise<{ success: boolean; startExtraction: boolean; finalState?: DownloadItem }> { + console.log(`[DownProc] Using public endpoint for ${item.releaseName}`) + + if (!this.vrpConfig?.baseUri || !this.vrpConfig?.password) { + console.error('[DownProc] Missing VRP baseUri or password.') + this.updateItemStatus(item.releaseName, 'Error', 0, 'Missing VRP configuration') + return { success: false, startExtraction: false } + } + + const rclonePath = dependencyService.getRclonePath() + if (!rclonePath) { + console.error('[DownProc] Rclone path not found.') + this.updateItemStatus(item.releaseName, 'Error', 0, 'Rclone dependency not found') + return { success: false, startExtraction: false } + } + + const downloadPath = join(item.downloadPath, item.releaseName) + this.queueManager.updateItem(item.releaseName, { downloadPath: downloadPath }) + + const gameNameHash = crypto + .createHash('md5') + .update(item.releaseName + '\n') + .digest('hex') + const source = `:http:/${gameNameHash}` + + const rcloneArgs = [ + 'copy', + source, + downloadPath, + '--http-url', + this.vrpConfig.baseUri, + '--no-check-certificate' + ] + + const rcloneLogTail: string[] = [] + const maxLogLines = 50 + const pushLogLine = (line: string): void => { + const trimmed = line.replace(/\r?\n$/, '') + if (!trimmed) return + rcloneLogTail.push(trimmed) + if (rcloneLogTail.length > maxLogLines) { + rcloneLogTail.shift() + } + } + + const handleRcloneOutput = (chunk: Buffer): void => { + const text = chunk.toString() + const lines = text.split(/\r?\n/) + for (const line of lines) { + if (line) { + console.log(`[DownProc][rclone] ${line}`) + pushLogLine(line) + } + } + } + + try { + this.updateItemStatus(item.releaseName, 'Downloading', 0) + + const rcloneProcess = execa(rclonePath, rcloneArgs, { + all: true, + buffer: false, + windowsHide: true + }) + + if (rcloneProcess.all) { + rcloneProcess.all.on('data', handleRcloneOutput) + } + + this.activeDownloads.set(item.releaseName, { + cancel: () => { + rcloneProcess.kill('SIGTERM') + }, + mountProcess: rcloneProcess + }) + + console.log( + `[DownProc] rclone process started for ${item.releaseName} with PID: ${rcloneProcess.pid}` + ) + + await rcloneProcess + + this.activeDownloads.delete(item.releaseName) + this.queueManager.updateItem(item.releaseName, { pid: undefined }) + + this.updateItemStatus(item.releaseName, 'Downloading', 100) + console.log( + `[DownProc] rclone process finished successfully for ${item.releaseName}.` + ) + + return { + success: true, + startExtraction: true, + finalState: this.queueManager.findItem(item.releaseName) + } + } catch (error: unknown) { + const isExecaError = (err: unknown): err is ExecaError => + typeof err === 'object' && err !== null && 'shortMessage' in err + const currentItemState = this.queueManager.findItem(item.releaseName) + const statusBeforeCatch = currentItemState?.status ?? 'Unknown' + + console.error(`[DownProc] rclone download error for ${item.releaseName}:`, error) + if (rcloneLogTail.length > 0) { + console.error(`[DownProc] rclone log tail:\n${rcloneLogTail.join('\n')}`) + } + + if (this.activeDownloads.has(item.releaseName)) { + this.activeDownloads.delete(item.releaseName) + this.queueManager.updateItem(item.releaseName, { pid: undefined }) + } + + if (isExecaError(error) && error.exitCode === 143) { + console.log(`[DownProc] rclone download cancelled for ${item.releaseName}`) + return { success: false, startExtraction: false, finalState: currentItemState } + } + + let errorMessage = 'Public endpoint download failed.' + if (isExecaError(error)) { + errorMessage = error.shortMessage || error.message + } else if (error instanceof Error) { + errorMessage = error.message + } else { + errorMessage = String(error) + } + errorMessage = errorMessage.substring(0, 500) + + if (statusBeforeCatch !== 'Cancelled' && statusBeforeCatch !== 'Error') { + this.updateItemStatus( + item.releaseName, + 'Error', + currentItemState?.progress ?? 0, + errorMessage + ) + } + + return { + success: false, + startExtraction: false, + finalState: this.queueManager.findItem(item.releaseName) + } + } } // Mount-based download using rclone mount + rsync for better pause/resume @@ -336,6 +497,44 @@ export class DownloadProcessor { windowsHide: true }) + const mountLogTail: string[] = [] + const maxLogLines = 50 + const pushLogLine = (line: string): void => { + const trimmed = line.replace(/\r?\n$/, '') + if (!trimmed) return + mountLogTail.push(trimmed) + if (mountLogTail.length > maxLogLines) { + mountLogTail.shift() + } + } + + const handleMountOutput = (chunk: Buffer): void => { + const text = chunk.toString() + const lines = text.split(/\r?\n/) + for (const line of lines) { + if (line) { + console.log(`[DownProc][rclone] ${line}`) + pushLogLine(line) + } + } + } + + if (mountProcess.all) { + mountProcess.all.on('data', handleMountOutput) + } + + let mountFailure: Error | null = null + mountProcess.catch((error) => { + mountFailure = error instanceof Error ? error : new Error(String(error)) + console.error( + `[DownProc] rclone mount process failed for ${item.releaseName}:`, + error + ) + if (mountLogTail.length > 0) { + console.error(`[DownProc] rclone mount log tail:\n${mountLogTail.join('\n')}`) + } + }) + // Store mount process for cleanup (we'll add it to the main download controller later) // Note: We'll remove this separate mount storage once we integrate it into the main controller @@ -343,13 +542,35 @@ export class DownloadProcessor { let mountReady = false for (let i = 0; i < 10; i++) { await new Promise((resolve) => setTimeout(resolve, 1000)) + if (mountFailure) { + const tail = mountLogTail.length > 0 ? `\n${mountLogTail.join('\n')}` : '' + const mountFailureMessage = String(mountFailure) + throw new Error(`rclone mount failed: ${mountFailureMessage}${tail}`) + } + if (mountProcess.exitCode !== null) { + const tail = mountLogTail.length > 0 ? `\n${mountLogTail.join('\n')}` : '' + throw new Error(`rclone mount exited early (code ${mountProcess.exitCode}).${tail}`) + } try { const testRead = await fs.readdir(mountPoint) if (testRead.length >= 0) { - // Even empty directory means mount is working - mountReady = true - console.log(`[DownProc] Mount ready after ${i + 1} seconds`) - break + let isMounted = true + if (process.platform !== 'win32') { + try { + const mountStat = await fsPromises.stat(mountPoint) + const parentStat = await fsPromises.stat(join(mountPoint, '..')) + isMounted = mountStat.dev !== parentStat.dev + } catch { + isMounted = false + } + } + + if (isMounted) { + // Even empty directory means mount is working (remote may be empty). + mountReady = true + console.log(`[DownProc] Mount ready after ${i + 1} seconds`) + break + } } } catch { console.log(`[DownProc] Mount not ready yet, attempt ${i + 1}/10`) @@ -357,7 +578,8 @@ export class DownloadProcessor { } if (!mountReady) { - throw new Error('Mount failed to become ready within 10 seconds') + const tail = mountLogTail.length > 0 ? `\n${mountLogTail.join('\n')}` : '' + throw new Error(`Mount failed to become ready within 10 seconds.${tail}`) } // Verify mount contents are accessible and download all files @@ -550,6 +772,10 @@ export class DownloadProcessor { this.queueManager.updateItem(item.releaseName, { pid: undefined }) } + if (this.isFuseMissingError(error)) { + throw error + } + // Handle cancellation if (isExecaError(error) && error.exitCode === 143) { console.log(`[DownProc] Mount-based download cancelled for ${item.releaseName}`) @@ -684,7 +910,17 @@ export class DownloadProcessor { this.updateItemStatus(item.releaseName, 'Downloading', item.progress ?? 0) // Restart the download using the stream-based approach - return await this.startMountBasedDownload(item) + try { + return await this.startMountBasedDownload(item) + } catch (error: unknown) { + if (this.isFuseMissingError(error)) { + console.warn( + `[DownProc] FUSE not available. Falling back to public endpoint direct download for ${item.releaseName}.` + ) + return await this.startPublicEndpointDownload(item) + } + throw error + } } // Method to check if a download is active @@ -692,6 +928,11 @@ export class DownloadProcessor { return this.activeDownloads.has(releaseName) } + private isFuseMissingError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /cannot find FUSE/i.test(message) || /cgofuse/i.test(message) + } + // Helper method to get all files recursively from a directory private async getFilesRecursively( dir: string, diff --git a/src/renderer/src/components/DownloadsView.tsx b/src/renderer/src/components/DownloadsView.tsx index 49ecf27..17f4a1f 100644 --- a/src/renderer/src/components/DownloadsView.tsx +++ b/src/renderer/src/components/DownloadsView.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo, useState } from 'react' import { useDownload } from '../hooks/useDownload' import { useAdb } from '../hooks/useAdb' import { DownloadItem } from '@shared/types' @@ -10,7 +10,9 @@ import { Button, ProgressBar, Image, - Badge + Badge, + Dropdown, + Option } from '@fluentui/react-components' import { DeleteRegular, @@ -31,6 +33,20 @@ const useStyles = makeStyles({ padding: tokens.spacingHorizontalXXL, gap: tokens.spacingVerticalL }, + headerRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: tokens.spacingHorizontalM + }, + sortControls: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalS + }, + sortDropdown: { + minWidth: '160px' + }, itemRow: { display: 'grid', gridTemplateColumns: '60px 1fr auto auto', // Thumbnail, Info, Progress/Status, Actions @@ -97,6 +113,7 @@ const DownloadsView: React.FC = ({ onClose }) => { const { games } = useGames() // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, setDialogGame] = useGameDialog() + const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date') const formatAddedTime = (timestamp: number): string => { try { @@ -107,6 +124,66 @@ const DownloadsView: React.FC = ({ onClose }) => { } } + const gameSizeByRelease = useMemo(() => { + const map = new Map() + for (const game of games) { + if (game.releaseName && game.size) { + map.set(game.releaseName, game.size) + } + if (game.packageName && game.size) { + map.set(game.packageName, game.size) + } + } + return map + }, [games]) + + const resolveItemSize = (item: DownloadItem): string | undefined => { + if (item.size && item.size.trim().length > 0) { + return item.size + } + return gameSizeByRelease.get(item.releaseName) || gameSizeByRelease.get(item.packageName) + } + + const formatSize = (size?: string): string => { + if (!size || size.trim().length === 0) return 'Unknown size' + return size + } + + const parseSizeToBytes = (size?: string): number => { + if (!size) return 0 + const match = size.trim().match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i) + if (!match) return 0 + const value = Number(match[1]) + if (Number.isNaN(value)) return 0 + const unit = match[2].toUpperCase() + const multiplier = + unit === 'KB' + ? 1024 + : unit === 'MB' + ? 1024 ** 2 + : unit === 'GB' + ? 1024 ** 3 + : unit === 'TB' + ? 1024 ** 4 + : 1 + return value * multiplier + } + + const sortedQueue = useMemo(() => { + const copy = [...queue] + if (sortBy === 'name') { + copy.sort((a, b) => a.gameName.localeCompare(b.gameName)) + } else if (sortBy === 'size') { + copy.sort( + (a, b) => + parseSizeToBytes(resolveItemSize(b)) - parseSizeToBytes(resolveItemSize(a)) + ) + } else { + copy.sort((a, b) => b.addedDate - a.addedDate) + } + return copy + }, [queue, sortBy, gameSizeByRelease]) + const handleInstallFromCompleted = (releaseName: string): void => { if (!releaseName || !selectedDevice) { console.error('Missing releaseName or selectedDevice for install from completed action') @@ -175,9 +252,36 @@ const DownloadsView: React.FC = ({ onClose }) => { Download queue is empty. ) : (
- {queue - .sort((a, b) => b.addedDate - a.addedDate) - .map((item) => ( +
+ Downloads +
+ Sort by + { + if (data.optionValue) { + setSortBy(data.optionValue as 'date' | 'name' | 'size') + } + }} + placeholder="Sort by..." + > + + + + +
+
+ {sortedQueue.map((item) => (
{/* Thumbnail */} = ({ onClose }) => { {item.releaseName} - Added: {formatAddedTime(item.addedDate)} + Added: {formatAddedTime(item.addedDate)} · Size:{' '} + {formatSize(resolveItemSize(item))}
{/* Progress / Status */} diff --git a/src/renderer/src/components/GameDetailsDialog.tsx b/src/renderer/src/components/GameDetailsDialog.tsx index c61e53e..b5e9da9 100644 --- a/src/renderer/src/components/GameDetailsDialog.tsx +++ b/src/renderer/src/components/GameDetailsDialog.tsx @@ -187,6 +187,7 @@ const GameDetailsDialog: React.FC = ({ const [loadingNote, setLoadingNote] = useState(false) const [videoId, setVideoId] = useState(null) const [loadingVideo, setLoadingVideo] = useState(false) + const [videoError, setVideoError] = useState(false) // Fetch note when dialog opens or game changes useEffect(() => { @@ -228,6 +229,7 @@ const GameDetailsDialog: React.FC = ({ setLoadingVideo(true) setVideoId(null) + setVideoError(false) try { const videoId = await getTrailerVideoIdFromContext(game.name) @@ -253,6 +255,15 @@ const GameDetailsDialog: React.FC = ({ } }, [open, game, getTrailerVideoIdFromContext]) + const getYouTubeOrigin = (): string | undefined => { + if (typeof window === 'undefined') return undefined + const origin = window.location.origin + if (origin === 'null' || origin.startsWith('file:')) { + return 'http://localhost' + } + return origin + } + // Helper function to render action buttons based on game state const renderActionButtons = (currentGame: GameInfo): React.ReactNode => { const status = downloadStatusMap.get(currentGame.releaseName || '')?.status @@ -540,6 +551,18 @@ const GameDetailsDialog: React.FC = ({
{loadingVideo ? ( + ) : videoError && videoId ? ( + + Trailer unavailable in-app.{' '} + + Open on YouTube + + . + ) : videoId ? (
= ({ opts={{ width: '100%', height: '100%', + host: 'https://www.youtube.com', playerVars: { - autoplay: 0 + autoplay: 0, + origin: getYouTubeOrigin() } }} + onError={() => { + setVideoError(true) + }} />
) : ( From 3422322ae9125230a3ab3e9ddcd1dc56768fb0dc Mon Sep 17 00:00:00 2001 From: collse Date: Fri, 6 Feb 2026 18:32:42 +0000 Subject: [PATCH 2/5] Add v1.3.5 release notes --- RELEASE_NOTES.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 RELEASE_NOTES.md diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..a6d81bb --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,6 @@ +# v1.3.5 + +- Fix downloads on macOS without FUSE by falling back to direct HTTP download. +- Add download sorting (Name, Date Added, Size) and display actual size. +- Restore in-app YouTube trailers in production builds by serving the renderer over localhost. +- Improve rclone error logging and mount readiness checks. From edfd59edf5ad40f6faebb8c0b34fe848a77cfaba Mon Sep 17 00:00:00 2001 From: collse Date: Fri, 6 Feb 2026 18:33:02 +0000 Subject: [PATCH 3/5] Fix download progress parsing for rclone stats --- .../services/download/downloadProcessor.ts | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main/services/download/downloadProcessor.ts b/src/main/services/download/downloadProcessor.ts index d10cfab..da4b0ad 100644 --- a/src/main/services/download/downloadProcessor.ts +++ b/src/main/services/download/downloadProcessor.ts @@ -274,7 +274,11 @@ export class DownloadProcessor { downloadPath, '--http-url', this.vrpConfig.baseUri, - '--no-check-certificate' + '--no-check-certificate', + '--progress', + '--stats', + '1s', + '--stats-one-line' ] const rcloneLogTail: string[] = [] @@ -288,13 +292,35 @@ export class DownloadProcessor { } } + let lastProgress = -1 const handleRcloneOutput = (chunk: Buffer): void => { - const text = chunk.toString() - const lines = text.split(/\r?\n/) + const text = chunk.toString().replace(/\r/g, '\n') + const lines = text.split(/\n/) for (const line of lines) { if (line) { console.log(`[DownProc][rclone] ${line}`) pushLogLine(line) + + const progressMatch = + line.match(/Transferred:.*?(\d+)%/) || line.match(/,\s*(\d+)%\s*,/) + if (progressMatch && progressMatch[1]) { + const progress = Number(progressMatch[1]) + if (!Number.isNaN(progress) && progress !== lastProgress) { + lastProgress = progress + + const speedMatch = line.match(/,\s*([0-9.]+\s*\w+\/s)(?:,|$)/) + const etaMatch = line.match(/ETA\s+([0-9hms:]+|[-]+)\b/i) + + this.updateItemStatus( + item.releaseName, + 'Downloading', + progress, + undefined, + speedMatch?.[1], + etaMatch?.[1] + ) + } + } } } } From c35f63d0b8e4500c6f980258d5b98a1c8ab18ab7 Mon Sep 17 00:00:00 2001 From: collse Date: Fri, 6 Feb 2026 18:33:21 +0000 Subject: [PATCH 4/5] Release 1.3.6 --- RELEASE_NOTES.md | 9 + package.json | 2 +- src/main/index.ts | 7 +- src/renderer/src/assets/games-view.css | 12 ++ .../src/assets/images/youtube-logo.svg | 7 + .../src/components/GameDetailsDialog.tsx | 79 ++++++- src/renderer/src/components/GamesView.tsx | 198 ++++++++++++++---- src/renderer/src/context/GamesProvider.tsx | 11 +- 8 files changed, 274 insertions(+), 51 deletions(-) create mode 100644 src/renderer/src/assets/images/youtube-logo.svg diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a6d81bb..8c405f1 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,12 @@ +# v1.3.6 + +- Fix download progress display for direct HTTP downloads by parsing rclone stats output. +- Add Ready to Install filter (stored locally, not installed) with icons and tooltips. +- Keep toolbar controls and status text on a single line; set minimum window width to 1250px. +- Update popularity display to 5-star ratings with half stars. +- Ensure Installed filter reflects actual device installs only. +- Improve trailer fallback UI with thumbnail + YouTube logo and clearer messaging. + # v1.3.5 - Fix downloads on macOS without FUSE by falling back to direct HTTP download. diff --git a/package.json b/package.json index b733574..51398ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apprenticevr", - "version": "1.3.5", + "version": "1.3.6", "description": "An Electron application with React and TypeScript", "main": "./out/main/index.js", "author": "example.com", diff --git a/src/main/index.ts b/src/main/index.ts index adb71dc..ccf0946 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -161,8 +161,8 @@ function sendDependencyProgress( async function createWindow(): Promise { // Create the browser window. mainWindow = new BrowserWindow({ - width: 1200, - minWidth: 1200, + width: 1250, + minWidth: 1250, height: 900, show: false, autoHideMenuBar: true, @@ -176,7 +176,7 @@ async function createWindow(): Promise { }) // Explicitly set minimum size to ensure constraint is enforced - mainWindow.setMinimumSize(1200, 900) + mainWindow.setMinimumSize(1250, 900) mainWindow.on('ready-to-show', async () => { if (mainWindow) { @@ -723,6 +723,7 @@ app.whenReady().then(async () => { return await downloadService.copyObbFolder(folderPath, deviceId) }) + // Validate that all IPC channels have handlers registered const allHandled = typedIpcMain.validateAllHandlersRegistered() if (!allHandled) { diff --git a/src/renderer/src/assets/games-view.css b/src/renderer/src/assets/games-view.css index a305d62..55ba222 100644 --- a/src/renderer/src/assets/games-view.css +++ b/src/renderer/src/assets/games-view.css @@ -41,6 +41,7 @@ display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid #e0e0e0; @@ -51,11 +52,17 @@ display: flex; align-items: center; gap: 16px; + flex-wrap: nowrap; +} + +.games-toolbar-left button { + white-space: nowrap; } .last-synced { color: var(--colorNeutralForeground2); font-size: 0.9em; + white-space: nowrap; } .game-count { @@ -270,15 +277,20 @@ display: flex; gap: 8px; margin-left: 16px; + flex-wrap: nowrap; } .filter-buttons button { + display: inline-flex; + align-items: center; + gap: 6px; padding: 4px 10px; font-size: 0.85em; background-color: #f8f9fa; border: 1px solid #dadce0; border-radius: 12px; /* More rounded */ cursor: pointer; + white-space: nowrap; transition: background-color 0.2s, border-color 0.2s, diff --git a/src/renderer/src/assets/images/youtube-logo.svg b/src/renderer/src/assets/images/youtube-logo.svg new file mode 100644 index 0000000..7620021 --- /dev/null +++ b/src/renderer/src/assets/images/youtube-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/renderer/src/components/GameDetailsDialog.tsx b/src/renderer/src/components/GameDetailsDialog.tsx index b5e9da9..b5b1346 100644 --- a/src/renderer/src/components/GameDetailsDialog.tsx +++ b/src/renderer/src/components/GameDetailsDialog.tsx @@ -33,6 +33,7 @@ import { BroomRegular as UninstallIcon } from '@fluentui/react-icons' import placeholderImage from '../assets/images/game-placeholder.png' +import youtubeLogo from '../assets/images/youtube-logo.svg' import YouTube from 'react-youtube' import { useGames } from '@renderer/hooks/useGames' @@ -127,6 +128,43 @@ const useStyles = makeStyles({ paddingTop: '56.25%', // 16:9 aspect ratio marginTop: tokens.spacingVerticalM }, + youtubeFallback: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'grid', + placeItems: 'center', + backgroundColor: tokens.colorNeutralBackground2, + borderRadius: tokens.borderRadiusMedium, + overflow: 'hidden', + textAlign: 'center' + }, + youtubeFallbackContent: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: tokens.spacingVerticalS, + padding: tokens.spacingVerticalM + }, + youtubeFallbackThumb: { + width: '100%', + height: '100%', + objectFit: 'cover', + position: 'absolute', + inset: 0, + filter: 'brightness(0.45)' + }, + youtubeFallbackLogo: { + width: '120px', + height: 'auto', + marginBottom: tokens.spacingVerticalS + }, + youtubeFallbackOverlay: { + position: 'relative', + zIndex: 1 + }, youtubePlayer: { position: 'absolute', top: 0, @@ -552,17 +590,36 @@ const GameDetailsDialog: React.FC = ({ {loadingVideo ? ( ) : videoError && videoId ? ( - - Trailer unavailable in-app.{' '} - - Open on YouTube - - . - +
+
+ Trailer thumbnail +
+
+ YouTube + Trailer can’t play in-app + + Some videos block embeds. Open it on YouTube instead. + + +
+
+
+
) : videoId ? (
= (row, _columnId, filterValue) => { const searchStr = String(filterValue).toLowerCase() @@ -95,6 +99,46 @@ const filterGameNameAndPackage: FilterFn = (row, _columnId, filterValu ) } +const booleanEqualsFilter: FilterFn = (row, columnId, filterValue) => { + if (filterValue === undefined) return true + return row.getValue(columnId) === filterValue +} + +const renderPopularityStars = (value: number): React.ReactNode => { + const normalized = Math.max(0, Math.min(100, value)) + const rating = (normalized / 100) * 5 + const filledCount = Math.floor(rating) + const hasHalf = rating - filledCount >= 0.5 + + return ( +
+ {Array.from({ length: 5 }).map((_, index) => { + if (index < filledCount) { + return ( + + ) + } + if (index === filledCount && hasHalf) { + return ( + + ) + } + return ( + + ) + })} +
+ ) +} + declare module '@tanstack/react-table' { interface FilterFns { gameNameAndPackageFilter: FilterFn @@ -282,19 +326,46 @@ const GamesView: React.FC = ({ onBackToDevices }) => { const [showObbConfirmDialog, setShowObbConfirmDialog] = useState(false) const [obbFolderToConfirm, setObbFolderToConfirm] = useState(null) + const downloadStatusMap = useMemo(() => { + const map = new Map() + downloadQueue.forEach((item) => { + if (item.releaseName) { + const progress = + item.status === 'Extracting' ? (item.extractProgress ?? 0) : (item.progress ?? 0) + map.set(item.releaseName, { + status: item.status, + progress: progress + }) + } + }) + return map + }, [downloadQueue]) + const counts = useMemo(() => { const total = games.length const installed = games.filter((g) => g.isInstalled).length const updates = games.filter((g) => g.hasUpdate).length - return { total, installed, updates } - }, [games]) + const downloaded = games.filter((g) => { + if (!g.releaseName || g.isInstalled) return false + return downloadStatusMap.get(g.releaseName)?.status === 'Completed' + }).length + return { total, installed, downloaded, updates } + }, [games, downloadStatusMap]) useEffect(() => { setColumnFilters((prev) => { - const otherFilters = prev.filter((f) => f.id !== 'isInstalled' && f.id !== 'hasUpdate') + const otherFilters = prev.filter( + (f) => f.id !== 'isInstalled' && f.id !== 'hasUpdate' && f.id !== 'isDownloaded' + ) switch (activeFilter) { case 'installed': return [...otherFilters, { id: 'isInstalled', value: true }] + case 'downloaded': + return [ + ...otherFilters, + { id: 'isDownloaded', value: true }, + { id: 'isInstalled', value: false } + ] case 'update': return [ ...otherFilters, @@ -328,21 +399,6 @@ const GamesView: React.FC = ({ onBackToDevices }) => { } }, [selectedDevice, loadPackages, games]) - const downloadStatusMap = useMemo(() => { - const map = new Map() - downloadQueue.forEach((item) => { - if (item.releaseName) { - const progress = - item.status === 'Extracting' ? (item.extractProgress ?? 0) : (item.progress ?? 0) - map.set(item.releaseName, { - status: item.status, - progress: progress - }) - } - }) - return map - }, [downloadQueue]) - useEffect(() => { if (!tableContainerRef.current) return @@ -407,18 +463,26 @@ const GamesView: React.FC = ({ onBackToDevices }) => {
{isDownloaded && ( - + + + + + )} {isInstalled && ( - + + + + + )} {isUpdateAvailable && ( = ({ onBackToDevices }) => { size: COLUMN_WIDTHS.POPULARITY, cell: (info) => { const count = info.getValue() - return typeof count === 'number' ? count.toLocaleString() : '-' + return typeof count === 'number' ? renderPopularityStars(count) : '-' }, enableResizing: true }, @@ -576,12 +640,24 @@ const GamesView: React.FC = ({ onBackToDevices }) => { { accessorKey: 'isInstalled', header: 'Installed Status', - enableResizing: false + enableResizing: false, + filterFn: 'booleanEquals' }, { accessorKey: 'hasUpdate', header: 'Update Status', - enableResizing: false + enableResizing: false, + filterFn: 'booleanEquals' + }, + { + id: 'isDownloaded', + header: 'Downloaded Status', + accessorFn: (row) => { + if (!row.releaseName) return false + return downloadStatusMap.get(row.releaseName)?.status === 'Completed' + }, + enableResizing: false, + filterFn: 'booleanEquals' } ] }, [downloadStatusMap, styles, tableWidth]) @@ -591,13 +667,14 @@ const GamesView: React.FC = ({ onBackToDevices }) => { columns, columnResizeMode: 'onChange', filterFns: { - gameNameAndPackageFilter: filterGameNameAndPackage + gameNameAndPackageFilter: filterGameNameAndPackage, + booleanEquals: booleanEqualsFilter }, state: { sorting, globalFilter, columnFilters, - columnVisibility: { isInstalled: false, hasUpdate: false }, + columnVisibility: { isInstalled: false, hasUpdate: false, isDownloaded: false }, columnSizing }, onSortingChange: setSorting, @@ -1295,7 +1372,58 @@ const GamesView: React.FC = ({ onBackToDevices }) => { onClick={() => setActiveFilter('installed')} className={activeFilter === 'installed' ? 'active' : ''} > - Installed ({counts.installed}) + + + + + + + Installed ({counts.installed}) + + + + {!isStoredLocally && ( + + )} + {!isStoredLocally && ( + + )} + <> + + {isConnected && ( + + )} + ) } @@ -501,27 +543,36 @@ const GameDetailsDialog: React.FC = ({
- { - const status = downloadStatusMap.get(game.releaseName || '')?.status - if (game.isInstalled) return 'success' - if (status === 'Completed') return 'brand' - if (status === 'InstallError') return 'danger' - if (status === 'Installing') return 'brand' - return 'informative' - })()} - appearance="filled" - > - {(() => { - const status = downloadStatusMap.get(game.releaseName || '')?.status - if (game.isInstalled) return 'Installed' - if (status === 'Completed') return 'Downloaded' - if (status === 'InstallError') return 'Install Error' - if (status === 'Installing') return 'Installing' - return 'Not Installed' - })()} - + {currentStatus === 'InstallError' && !game.isInstalled ? ( + + + + Install Error + + + + ) : ( + { + if (game.isInstalled) return 'success' + if (currentStatus === 'Completed') return 'brand' + if (currentStatus === 'Installing') return 'brand' + return 'informative' + })()} + appearance="filled" + > + {(() => { + if (game.isInstalled) return 'Installed' + if (currentStatus === 'Completed') return 'Downloaded' + if (currentStatus === 'Installing') return 'Installing' + return 'Not Installed' + })()} + + )} {game.hasUpdate && ( Update Available diff --git a/src/renderer/src/components/GamesView.tsx b/src/renderer/src/components/GamesView.tsx index 3a987ef..c9f6651 100644 --- a/src/renderer/src/components/GamesView.tsx +++ b/src/renderer/src/components/GamesView.tsx @@ -16,7 +16,7 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useAdb } from '../hooks/useAdb' import { useGames } from '../hooks/useGames' import { useDownload } from '../hooks/useDownload' -import { GameInfo } from '@shared/types' +import { GameInfo, LocalLibraryIndex } from '@shared/types' import placeholderImage from '../assets/images/game-placeholder.png' import { Button, @@ -58,7 +58,10 @@ import { FolderAddRegular, DocumentRegular, ChevronDownRegular, - CopyRegular + CopyRegular, + ArrowSort20Filled, + ArrowSortUpLines20Regular, + ArrowSortDownLines20Regular } from '@fluentui/react-icons' import { ArrowLeftRegular } from '@fluentui/react-icons' import GameDetailsDialog from './GameDetailsDialog' @@ -86,8 +89,9 @@ const FIXED_COLUMNS_WIDTH = COLUMN_WIDTHS.LAST_UPDATED type FilterType = 'all' | 'installed' | 'downloaded' | 'update' +type GamesTableRow = GameInfo & { isDownloadedLocal: boolean } -const filterGameNameAndPackage: FilterFn = (row, _columnId, filterValue) => { +const filterGameNameAndPackage: FilterFn = (row, _columnId, filterValue) => { const searchStr = String(filterValue).toLowerCase() const gameName = String(row.original.name ?? '').toLowerCase() const packageName = String(row.original.packageName ?? '').toLowerCase() @@ -99,7 +103,7 @@ const filterGameNameAndPackage: FilterFn = (row, _columnId, filterValu ) } -const booleanEqualsFilter: FilterFn = (row, columnId, filterValue) => { +const booleanEqualsFilter: FilterFn = (row, columnId, filterValue) => { if (filterValue === undefined) return true return row.getValue(columnId) === filterValue } @@ -141,7 +145,8 @@ const renderPopularityStars = (value: number): React.ReactNode => { declare module '@tanstack/react-table' { interface FilterFns { - gameNameAndPackageFilter: FilterFn + gameNameAndPackageFilter: FilterFn + booleanEquals: FilterFn } } @@ -311,6 +316,8 @@ const GamesView: React.FC = ({ onBackToDevices }) => { const [sorting, setSorting] = useState([]) const [columnFilters, setColumnFilters] = useState([]) const [activeFilter, setActiveFilter] = useState('all') + const [excludeInstalled, setExcludeInstalled] = useState(false) + const [excludeStoredLocal, setExcludeStoredLocal] = useState(false) const [isLoading, setIsLoading] = useState(false) const [dialogGame, setDialogGame] = useGameDialog() const [isDialogOpen, setIsDialogOpen] = useState(false) @@ -325,59 +332,140 @@ const GamesView: React.FC = ({ onBackToDevices }) => { const [installSuccess, setInstallSuccess] = useState(null) const [showObbConfirmDialog, setShowObbConfirmDialog] = useState(false) const [obbFolderToConfirm, setObbFolderToConfirm] = useState(null) + const [localLibraryIndex, setLocalLibraryIndex] = useState({ + rootPath: '', + generatedAt: 0, + entries: [] + }) const downloadStatusMap = useMemo(() => { - const map = new Map() + const map = new Map() downloadQueue.forEach((item) => { if (item.releaseName) { const progress = item.status === 'Extracting' ? (item.extractProgress ?? 0) : (item.progress ?? 0) map.set(item.releaseName, { status: item.status, - progress: progress + progress: progress, + error: item.error }) } }) return map }, [downloadQueue]) + const localAvailability = useMemo(() => { + const releaseNames = new Set() + const packageNames = new Set() + + for (const entry of localLibraryIndex.entries) { + if (entry.releaseName) { + releaseNames.add(entry.releaseName) + } + for (const packageName of entry.packageNames) { + packageNames.add(packageName) + } + } + + return { releaseNames, packageNames } + }, [localLibraryIndex]) + + const isGameStoredLocally = useCallback( + (game: GameInfo): boolean => { + if (game.releaseName && localAvailability.releaseNames.has(game.releaseName)) { + return true + } + if (game.packageName && localAvailability.packageNames.has(game.packageName)) { + return true + } + + return false + }, + [localAvailability] + ) + + useEffect(() => { + let isMounted = true + + const loadLocalLibrary = async (): Promise => { + try { + const index = await window.api.localLibrary.getIndex() + if (isMounted) { + setLocalLibraryIndex(index) + } + + // Force a fresh scan so manually added/removed files are reflected quickly. + const refreshedIndex = await window.api.localLibrary.rescan() + if (isMounted) { + setLocalLibraryIndex(refreshedIndex) + } + } catch (error) { + console.error('[GamesView] Failed to load local library index:', error) + } + } + + const removeLocalLibraryUpdated = window.api.localLibrary.onUpdated((index) => { + setLocalLibraryIndex(index) + }) + + loadLocalLibrary() + + return () => { + isMounted = false + removeLocalLibraryUpdated() + } + }, []) + const counts = useMemo(() => { const total = games.length const installed = games.filter((g) => g.isInstalled).length const updates = games.filter((g) => g.hasUpdate).length - const downloaded = games.filter((g) => { - if (!g.releaseName || g.isInstalled) return false - return downloadStatusMap.get(g.releaseName)?.status === 'Completed' - }).length + const downloaded = games.filter((g) => !g.isInstalled && isGameStoredLocally(g)).length return { total, installed, downloaded, updates } - }, [games, downloadStatusMap]) + }, [games, isGameStoredLocally]) + + const tableData = useMemo( + () => + games.map((game) => ({ + ...game, + isDownloadedLocal: isGameStoredLocally(game) + })), + [games, isGameStoredLocally] + ) useEffect(() => { setColumnFilters((prev) => { const otherFilters = prev.filter( (f) => f.id !== 'isInstalled' && f.id !== 'hasUpdate' && f.id !== 'isDownloaded' ) + const nextFilters = [...otherFilters] + switch (activeFilter) { case 'installed': - return [...otherFilters, { id: 'isInstalled', value: true }] + nextFilters.push({ id: 'isInstalled', value: true }) + break case 'downloaded': - return [ - ...otherFilters, - { id: 'isDownloaded', value: true }, - { id: 'isInstalled', value: false } - ] + nextFilters.push({ id: 'isDownloaded', value: true }, { id: 'isInstalled', value: false }) + break case 'update': - return [ - ...otherFilters, - { id: 'isInstalled', value: true }, - { id: 'hasUpdate', value: true } - ] + nextFilters.push({ id: 'isInstalled', value: true }, { id: 'hasUpdate', value: true }) + break case 'all': default: - return otherFilters + break } + + // Optional exclusions (icon toggles) when they don't conflict with a strict active filter. + if (excludeInstalled && activeFilter !== 'installed' && activeFilter !== 'update') { + nextFilters.push({ id: 'isInstalled', value: false }) + } + if (excludeStoredLocal && activeFilter !== 'downloaded') { + nextFilters.push({ id: 'isDownloaded', value: false }) + } + + return nextFilters }) - }, [activeFilter]) + }, [activeFilter, excludeInstalled, excludeStoredLocal]) useEffect(() => { const unsubscribe = window.api.adb.onInstallationCompleted((deviceId) => { @@ -436,7 +524,7 @@ const GamesView: React.FC = ({ onBackToDevices }) => { } }, []) - const columns = useMemo[]>(() => { + const columns = useMemo[]>(() => { // Calculate dynamic width for name column, with a minimum width const nameColumnWidth = Math.max( COLUMN_WIDTHS.MIN_NAME_PACKAGE, @@ -446,16 +534,54 @@ const GamesView: React.FC = ({ onBackToDevices }) => { return [ { id: 'downloadStatus', - header: '', + header: () => ( +
+ + + + + + +
+ ), size: COLUMN_WIDTHS.STATUS, enableResizing: false, enableSorting: false, cell: ({ row }) => { const game = row.original - const downloadInfo = game.releaseName - ? downloadStatusMap.get(game.releaseName) - : undefined - const isDownloaded = downloadInfo?.status === 'Completed' + const isDownloaded = game.isDownloadedLocal const isInstalled = game.isInstalled const isUpdateAvailable = game.hasUpdate @@ -571,9 +697,16 @@ const GamesView: React.FC = ({ onBackToDevices }) => {
)} {isInstallError && ( - - Install Error - + + + + Install Error + + + )}
{(isDownloading || isExtracting || isInstalling) && downloadInfo && ( @@ -652,18 +785,21 @@ const GamesView: React.FC = ({ onBackToDevices }) => { { id: 'isDownloaded', header: 'Downloaded Status', - accessorFn: (row) => { - if (!row.releaseName) return false - return downloadStatusMap.get(row.releaseName)?.status === 'Completed' - }, + accessorKey: 'isDownloadedLocal', enableResizing: false, filterFn: 'booleanEquals' } ] - }, [downloadStatusMap, styles, tableWidth]) - - const table = useReactTable({ - data: games, + }, [ + downloadStatusMap, + excludeInstalled, + excludeStoredLocal, + styles, + tableWidth + ]) + + const table = useReactTable({ + data: tableData, columns, columnResizeMode: 'onChange', filterFns: { @@ -728,7 +864,7 @@ const GamesView: React.FC = ({ onBackToDevices }) => { const handleRowClick = ( _event: React.MouseEvent, - row: Row + row: Row ): void => { console.log('Row clicked for game:', row.original.name) setDialogGame(row.original) @@ -764,6 +900,59 @@ const GamesView: React.FC = ({ onBackToDevices }) => { }) } + const handleRedownload = (game: GameInfo): void => { + if (!game) return + console.log(`Re-download action triggered for: ${game.packageName}`) + addDownloadToQueue(game, { skipInstall: true, forceRequeueCompleted: true }) + .then((success) => { + if (success) { + console.log( + `Successfully added ${game.releaseName} to download queue (skip auto-install).` + ) + } else { + console.log(`Failed to add ${game.releaseName} to queue (might already exist).`) + } + }) + .catch((err) => { + console.error('Error adding to queue for re-download:', err) + }) + } + + const handleDownloadOnly = (game: GameInfo): void => { + if (!game) return + console.log(`Download-only action triggered for: ${game.packageName}`) + addDownloadToQueue(game, { skipInstall: true, forceRequeueCompleted: true }) + .then((success) => { + if (success) { + console.log(`Successfully added ${game.releaseName} to download queue (download-only).`) + } else { + console.log(`Failed to add ${game.releaseName} to queue (might already exist).`) + } + }) + .catch((err) => { + console.error('Error adding to queue for download-only:', err) + }) + } + + const shouldFallbackToRedownload = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error) + return ( + message.includes('LOCAL_FILES_MISSING') || + message.includes('LOCAL_PATH_OUTSIDE_ACTIVE_DOWNLOAD_ROOT') + ) + } + + const queueForRedownload = async (game: GameInfo, context: string): Promise => { + console.log(`${context}: Queuing re-download for ${game.releaseName}.`) + const addToQueueSuccess = await addDownloadToQueue(game, { + forceRequeueCompleted: true + }) + if (!addToQueueSuccess) { + console.warn(`${context}: Failed to queue ${game.releaseName} for re-download.`) + } + return addToQueueSuccess + } + const handleUninstall = async (game: GameInfo): Promise => { if (!game || !game.packageName || !selectedDevice) { console.error( @@ -828,24 +1017,37 @@ const GamesView: React.FC = ({ onBackToDevices }) => { // The game is now uninstalled from the device. // Downloaded files (if any) should still be present. - const downloadInfo = downloadStatusMap.get(game.releaseName) + const hasLocalFiles = isGameStoredLocally(game) - if (downloadInfo?.status === 'Completed') { + if (hasLocalFiles) { console.log( - `Reinstall: Files for ${game.releaseName} are 'Completed'. Initiating install from completed.` + `Reinstall: Local files found for ${game.releaseName}. Initiating install from completed.` ) - await window.api.downloads.installFromCompleted(game.releaseName, selectedDevice) - console.log(`Reinstall: 'installFromCompleted' called for ${game.releaseName}.`) + try { + await window.api.downloads.installFromCompleted(game.releaseName, selectedDevice) + console.log(`Reinstall: 'installFromCompleted' called for ${game.releaseName}.`) + } catch (installError) { + if (shouldFallbackToRedownload(installError)) { + const queued = await queueForRedownload(game, 'Reinstall') + if (!queued) { + window.alert( + `Reinstall for ${game.name} failed: Could not queue re-download. Please check logs.` + ) + } + } else { + throw installError + } + } } else { console.log( - `Reinstall: Files for ${game.releaseName} not 'Completed' (status: ${downloadInfo?.status}). Adding to download queue.` + `Reinstall: Local files not found for ${game.releaseName}. Adding to download queue.` ) - const addToQueueSuccess = await addDownloadToQueue(game) + const addToQueueSuccess = await queueForRedownload(game, 'Reinstall') if (addToQueueSuccess) { console.log(`Reinstall: Successfully added ${game.releaseName} to download queue.`) } else { console.warn( - `Reinstall: Failed to add ${game.releaseName} to queue. Current status: ${downloadInfo?.status}.` + `Reinstall: Failed to add ${game.releaseName} to queue.` ) window.alert( `Reinstall for ${game.name} failed: Could not add to download queue. Please check logs.` @@ -890,26 +1092,39 @@ const GamesView: React.FC = ({ onBackToDevices }) => { ) try { - const downloadInfo = downloadStatusMap.get(game.releaseName) + const hasLocalFiles = isGameStoredLocally(game) - if (downloadInfo?.status === 'Completed') { + if (hasLocalFiles) { console.log( - `Update for ${game.releaseName}: Files are already 'Completed'. Initiating install from completed.` + `Update for ${game.releaseName}: Local files found. Initiating install from completed.` ) - await window.api.downloads.installFromCompleted(game.releaseName, selectedDevice) - console.log(`Update: 'installFromCompleted' called for ${game.releaseName}.`) + try { + await window.api.downloads.installFromCompleted(game.releaseName, selectedDevice) + console.log(`Update: 'installFromCompleted' called for ${game.releaseName}.`) + } catch (installError) { + if (shouldFallbackToRedownload(installError)) { + const queued = await queueForRedownload(game, 'Update') + if (!queued) { + window.alert( + `Could not queue ${game.name} for re-download. It might already be in the queue or an error occurred.` + ) + } + } else { + throw installError + } + } // Optionally, refresh packages or rely on 'installation-completed' event // loadPackages().catch(err => console.error('Update: Error refreshing packages post-install:', err)); } else { console.log( - `Update for ${game.releaseName}: Files not 'Completed' (status: ${downloadInfo?.status}). Adding to download queue.` + `Update for ${game.releaseName}: Local files not found. Adding to download queue.` ) - const addToQueueSuccess = await addDownloadToQueue(game) + const addToQueueSuccess = await queueForRedownload(game, 'Update') if (addToQueueSuccess) { console.log(`Update: Successfully added ${game.releaseName} to download queue.`) } else { console.warn( - `Update: Failed to add ${game.releaseName} to queue. Current status: ${downloadInfo?.status}.` + `Update: Failed to add ${game.releaseName} to queue.` ) window.alert( `Could not queue ${game.name} for update. It might already be in the queue or an error occurred. Please check logs.` @@ -936,17 +1151,26 @@ const GamesView: React.FC = ({ onBackToDevices }) => { cancelDownload(game.releaseName) } - const handleInstallFromCompleted = (game: GameInfo): void => { + const handleInstallFromCompleted = async (game: GameInfo): Promise => { if (!game || !game.releaseName || !selectedDevice) { console.error('Missing game, releaseName, or deviceId for install from completed action') window.alert('Cannot start installation: Missing required information.') return } console.log(`Requesting install from completed for ${game.releaseName} on ${selectedDevice}`) - window.api.downloads.installFromCompleted(game.releaseName, selectedDevice).catch((err) => { + try { + await window.api.downloads.installFromCompleted(game.releaseName, selectedDevice) + } catch (err) { + if (shouldFallbackToRedownload(err)) { + const queued = await queueForRedownload(game, 'InstallFromCompleted') + if (!queued) { + window.alert('Failed to queue re-download. Please check the main process logs.') + } + return + } console.error('Error triggering install from completed:', err) window.alert('Failed to start installation. Please check the main process logs.') - }) + } } const handleDeleteDownloaded = useCallback( @@ -1369,7 +1593,10 @@ const GamesView: React.FC = ({ onBackToDevices }) => { All ({counts.total})
)} {header.column.getCanResize() && ( @@ -1520,7 +1786,7 @@ const GamesView: React.FC = ({ onBackToDevices }) => { style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }} > {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const row = rows[virtualRow.index] as Row + const row = rows[virtualRow.index] as Row const rowClasses = [ row.original.isInstalled ? 'row-installed' : 'row-not-installed', row.original.hasUpdate ? 'row-update-available' : '' @@ -1566,7 +1832,10 @@ const GamesView: React.FC = ({ onBackToDevices }) => { open={isDialogOpen} onClose={handleCloseDialog} downloadStatusMap={downloadStatusMap} + isStoredLocally={dialogGame ? isGameStoredLocally(dialogGame) : false} onInstall={handleInstall} + onDownloadOnly={handleDownloadOnly} + onRedownload={handleRedownload} onUninstall={handleUninstall} onReinstall={handleReinstall} onUpdate={handleUpdate} diff --git a/src/renderer/src/context/DownloadContext.ts b/src/renderer/src/context/DownloadContext.ts index feaa832..f57aea6 100644 --- a/src/renderer/src/context/DownloadContext.ts +++ b/src/renderer/src/context/DownloadContext.ts @@ -1,11 +1,11 @@ import { createContext } from 'react' -import { DownloadItem, GameInfo } from '@shared/types' +import { DownloadAddOptions, DownloadItem, GameInfo } from '@shared/types' export interface DownloadContextType { queue: DownloadItem[] isLoading: boolean error: string | null - addToQueue: (game: GameInfo) => Promise + addToQueue: (game: GameInfo, options?: DownloadAddOptions) => Promise removeFromQueue: (releaseName: string) => Promise cancelDownload: (releaseName: string) => void retryDownload: (releaseName: string) => void diff --git a/src/renderer/src/context/DownloadProvider.tsx b/src/renderer/src/context/DownloadProvider.tsx index d89f090..95a2376 100644 --- a/src/renderer/src/context/DownloadProvider.tsx +++ b/src/renderer/src/context/DownloadProvider.tsx @@ -1,6 +1,6 @@ import React, { ReactNode, useEffect, useState, useCallback } from 'react' import { DownloadContext, DownloadContextType } from './DownloadContext' -import { DownloadItem, GameInfo } from '@shared/types' +import { DownloadAddOptions, DownloadItem, GameInfo } from '@shared/types' interface DownloadProviderProps { children: ReactNode @@ -46,10 +46,10 @@ export const DownloadProvider: React.FC = ({ children }) } }, []) - const addToQueue = useCallback(async (game: GameInfo): Promise => { + const addToQueue = useCallback(async (game: GameInfo, options?: DownloadAddOptions): Promise => { console.log(`Context: Adding ${game.releaseName} to queue...`) try { - const success = await window.api.downloads.addToQueue(game) + const success = await window.api.downloads.addToQueue(game, options) if (!success) { console.warn( `Context: Failed to add ${game.releaseName} to queue (likely already present).` diff --git a/src/renderer/src/electron-api.d.ts b/src/renderer/src/electron-api.d.ts index e7346a4..a9d7b32 100644 --- a/src/renderer/src/electron-api.d.ts +++ b/src/renderer/src/electron-api.d.ts @@ -10,7 +10,8 @@ import { DependencyAPIRenderer, LogsAPIRenderer, MirrorAPIRenderer, - WiFiBookmark + WiFiBookmark, + LocalLibraryAPIRenderer } from '@shared/types' declare global { @@ -26,6 +27,7 @@ declare global { updates: UpdateAPIRenderer logs: LogsAPIRenderer mirrors: MirrorAPIRenderer + localLibrary: LocalLibraryAPIRenderer dialog: { showDirectoryPicker: () => Promise showFilePicker: (options?: { diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 6fed346..c0a8393 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -141,6 +141,13 @@ export interface DownloadItem { eta?: string extractProgress?: number size?: string + // If true, download/extract completes but auto-install step is skipped. + skipInstall?: boolean +} + +export interface DownloadAddOptions { + skipInstall?: boolean + forceRequeueCompleted?: boolean } export interface DownloadProgress { @@ -149,6 +156,23 @@ export interface DownloadProgress { progress: number } +export interface LocalLibraryEntry { + id: string + releaseName: string + path: string + source: 'folder' | 'apk-file' + apkCount: number + hasInstallScript: boolean + packageNames: string[] + lastSeen: number +} + +export interface LocalLibraryIndex { + rootPath: string + generatedAt: number + entries: LocalLibraryEntry[] +} + // Update types export interface CommitInfo { sha: string @@ -254,7 +278,7 @@ export interface GameAPIRenderer export interface DownloadAPI { getQueue: () => Promise - addToQueue: (game: GameInfo) => Promise + addToQueue: (game: GameInfo, options?: DownloadAddOptions) => Promise removeFromQueue: (releaseName: string) => Promise cancelUserRequest: (releaseName: string) => void retryDownload: (releaseName: string) => void @@ -272,6 +296,15 @@ export interface DownloadAPIRenderer extends DownloadAPI { copyObbFolder: (folderPath: string, deviceId: string) => Promise } +export interface LocalLibraryAPI { + getIndex: () => Promise + rescan: () => Promise +} + +export interface LocalLibraryAPIRenderer extends LocalLibraryAPI { + onUpdated: (callback: (index: LocalLibraryIndex) => void) => () => void +} + export interface UploadAPI { prepareUpload: ( packageName: string, diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index b71888a..8b5cf07 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -12,7 +12,8 @@ import { BlacklistEntry, Mirror, MirrorTestResult, - WiFiBookmark + WiFiBookmark, + LocalLibraryIndex } from './index' // Define types for all IPC channels between renderer and main @@ -62,10 +63,15 @@ export interface IPCChannels { // Download related channels 'download:get-queue': DefineChannel<[], DownloadItem[]> - 'download:add': DefineChannel<[game: GameInfo], boolean> + 'download:add': DefineChannel< + [game: GameInfo, options?: { skipInstall?: boolean; forceRequeueCompleted?: boolean }], + boolean + > 'download:remove': DefineChannel<[releaseName: string], void> 'download:delete-files': DefineChannel<[releaseName: string], boolean> 'download:install-from-completed': DefineChannel<[releaseName: string, deviceId: string], void> + 'local-library:get-index': DefineChannel<[], LocalLibraryIndex> + 'local-library:rescan': DefineChannel<[], LocalLibraryIndex> // Upload related channels 'upload:prepare': DefineChannel< @@ -165,4 +171,5 @@ export interface IPCEvents { 'update:update-downloaded': [updateInfo: UpdateInfo] 'mirrors:test-progress': [id: string, status: 'testing' | 'success' | 'failed', error?: string] 'mirrors:mirrors-updated': [mirrors: Mirror[]] + 'local-library:updated': [index: LocalLibraryIndex] }