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/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..cf7492c --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,27 @@ +# v1.3.7 + +- Add persistent Local Library indexing with startup + scheduled rescans to track stored files across app restarts. +- Improve update/reinstall behavior with automatic re-download fallback when local files are missing or outside the active download path. +- Add `Download Only` and `Re-download` queue options with completed-item requeue support. +- Prevent nested duplicate download folders by normalizing release paths during download/fallback flows. +- Fix install pipeline to stop immediately on APK install failure (no OBB push after failed APK install). +- Propagate real ADB install errors (e.g. `INSTALL_FAILED_*`) into queue state and show them as `Install Error` tooltips in list/dialog UI. +- Add status-column icon toggles for filtering Installed / Stored Locally items, including excluded (red strike-through) state. +- Improve sortable header indicators with Fluent sort-line icons for unsorted/asc/desc states. +- Add stalled public-download watchdog handling to avoid queue hangs on zero-progress transfers. + +# 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. +- 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. 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..86fcd67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apprenticevr", - "version": "1.3.4", + "version": "1.3.7", "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..786c67e 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' @@ -11,11 +12,13 @@ import updateService from './services/updateService' import logsService from './services/logsService' import mirrorService from './services/mirrorService' import wifiBookmarksService from './services/wifiBookmarksService' +import localLibraryService from './services/localLibraryService' import { typedIpcMain } from '@shared/ipc-utils' 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 +33,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) => { @@ -41,6 +148,12 @@ downloadService.on('installation:success', (deviceId) => { } }) +localLibraryService.on('updated', (index) => { + if (mainWindow && !mainWindow.isDestroyed()) { + typedWebContentsSend.send(mainWindow, 'local-library:updated', index) + } +}) + // Function to send dependency progress to renderer function sendDependencyProgress( status: DependencyStatus, @@ -52,11 +165,11 @@ function sendDependencyProgress( } } -function createWindow(): void { +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, @@ -70,7 +183,7 @@ function createWindow(): void { }) // 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) { @@ -116,6 +229,10 @@ function createWindow(): void { // Initialize WiFi Bookmarks Service await wifiBookmarksService.initialize() console.log('WiFi Bookmarks Service initialized.') + + // Initialize Local Library Service + await localLibraryService.initialize() + console.log('Local Library Service initialized.') dependencyService.setDependencyServiceStatus('INITIALIZED') // Initialize Update Service @@ -172,7 +289,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) } } @@ -267,7 +388,9 @@ app.whenReady().then(async () => { // --- Download Handlers --- typedIpcMain.handle('download:get-queue', () => downloadService.getQueue()) - typedIpcMain.handle('download:add', (_event, game) => downloadService.addToQueue(game)) + typedIpcMain.handle('download:add', (_event, game, options) => + downloadService.addToQueue(game, options) + ) typedIpcMain.handle('download:delete-files', (_event, releaseName) => downloadService.deleteDownloadedFiles(releaseName) ) @@ -284,6 +407,8 @@ app.whenReady().then(async () => { ) }) }) + typedIpcMain.handle('local-library:get-index', async () => localLibraryService.getIndex()) + typedIpcMain.handle('local-library:rescan', async () => await localLibraryService.rescan()) // --- Upload Handlers --- typedIpcMain.handle( @@ -613,6 +738,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) { @@ -622,7 +748,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 +770,15 @@ app.on('window-all-closed', () => { // Clean up ADB tracking when app is quitting app.on('will-quit', () => { adbService.stopTrackingDevices() + localLibraryService.shutdown().catch((error) => { + console.warn('Failed to shutdown LocalLibraryService:', error) + }) + 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/adbService.ts b/src/main/services/adbService.ts index 225c421..bec6f9a 100644 --- a/src/main/services/adbService.ts +++ b/src/main/services/adbService.ts @@ -28,12 +28,17 @@ class AdbService extends EventEmitter implements AdbAPI { private isTracking = false private status: ServiceStatus = 'NOT_INITIALIZED' private aaptPushed = false + private lastInstallError: string | null = null constructor() { super() this.client = null } + public getLastInstallError(): string | null { + return this.lastInstallError + } + public async initialize(): Promise { if (this.status === 'INITIALIZING') { console.warn('AdbService is already initializing, skipping.') @@ -483,6 +488,7 @@ class AdbService extends EventEmitter implements AdbAPI { console.log( `[ADB Service] Attempting to install ${apkPath} on ${serial}${options?.flags ? ` with flags: ${options.flags.join(' ')}` : ''}...` ) + this.lastInstallError = null const deviceClient = this.client.getDevice(serial) if (options?.flags && options.flags.length > 0) { @@ -554,11 +560,13 @@ class AdbService extends EventEmitter implements AdbAPI { } if (output?.includes('Success')) { + this.lastInstallError = null console.log( `[ADB Service] Successfully installed ${apkPath} with flags. Output: ${output}` ) return true } else { + this.lastInstallError = (output && output.trim()) || 'Installation failed' console.error( `[ADB Service] Installation of ${apkPath} with flags failed or success not confirmed. Output: ${output || 'No output'}` ) @@ -579,6 +587,7 @@ class AdbService extends EventEmitter implements AdbAPI { return false } } catch (error) { + this.lastInstallError = error instanceof Error ? error.message : String(error) console.error( `[ADB Service] Error during flagged installation of ${apkPath} on device ${serial}:`, error @@ -599,14 +608,17 @@ class AdbService extends EventEmitter implements AdbAPI { try { const success = await deviceClient.install(apkPath) if (success) { + this.lastInstallError = null console.log(`[ADB Service] Successfully installed ${apkPath} using adbkit.install.`) } else { + this.lastInstallError = 'Installation reported failure' console.error( `[ADB Service] Installation of ${apkPath} reported failure by adbkit.install.` ) } return success } catch (error) { + this.lastInstallError = error instanceof Error ? error.message : String(error) console.error( `[ADB Service] Error installing package ${apkPath} on device ${serial} (adbkit.install):`, error 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..f553e21 100644 --- a/src/main/services/download/downloadProcessor.ts +++ b/src/main/services/download/downloadProcessor.ts @@ -1,4 +1,4 @@ -import { join } from 'path' +import { basename, dirname, join } from 'path' import { promises as fs, createReadStream, createWriteStream, promises as fsPromises } from 'fs' import { execa, ExecaError } from 'execa' import crypto from 'crypto' @@ -28,6 +28,7 @@ export class DownloadProcessor { private queueManager: QueueManager private vrpConfig: VrpConfig | null = null private debouncedEmitUpdate: () => void + private static readonly PUBLIC_DOWNLOAD_STALL_TIMEOUT_MS = 120000 constructor(queueManager: QueueManager, debouncedEmitUpdate: () => void) { this.queueManager = queueManager @@ -38,6 +39,28 @@ export class DownloadProcessor { this.vrpConfig = config } + /** + * Resolve the item download directory while preventing repeated + * "//..." path nesting across fallback/retry flows. + */ + private resolveItemDownloadPath(item: DownloadItem): string { + let basePath = item.downloadPath + + // Collapse duplicate trailing release-name segments if they exist. + while ( + basename(basePath) === item.releaseName && + basename(dirname(basePath)) === item.releaseName + ) { + basePath = dirname(basePath) + } + + if (basename(basePath) === item.releaseName) { + return basePath + } + + return join(basePath, item.releaseName) + } + // Add getter for vrpConfig public getVrpConfig(): VrpConfig | null { return this.vrpConfig @@ -146,7 +169,7 @@ export class DownloadProcessor { return { success: false, startExtraction: false } } - const downloadPath = join(item.downloadPath, item.releaseName) + const downloadPath = this.resolveItemDownloadPath(item) this.queueManager.updateItem(item.releaseName, { downloadPath: downloadPath }) try { @@ -211,6 +234,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 +251,268 @@ 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 = this.resolveItemDownloadPath(item) + 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', + '--progress', + '--stats', + '1s', + '--stats-one-line' + ] + + 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() + } + } + + let lastProgress = -1 + let lastTransferredBytes = 0 + let lastAdvanceAt = Date.now() + let killedByStallWatchdog = false + let stallWatchdog: NodeJS.Timeout | null = null + + const parseTransferredBytes = (line: string): number | null => { + const transferMatch = line.match( + /([0-9]+(?:\.[0-9]+)?)\s*([KMGTPE]?i?B)\s*\/\s*([0-9]+(?:\.[0-9]+)?)\s*([KMGTPE]?i?B)/i + ) + if (!transferMatch) return null + + const value = Number(transferMatch[1]) + const unit = transferMatch[2].toUpperCase() + if (Number.isNaN(value)) return null + + const scale: Record = { + B: 1, + KIB: 1024, + MIB: 1024 ** 2, + GIB: 1024 ** 3, + TIB: 1024 ** 4, + PIB: 1024 ** 5, + EIB: 1024 ** 6, + KB: 1000, + MB: 1000 ** 2, + GB: 1000 ** 3, + TB: 1000 ** 4, + PB: 1000 ** 5, + EB: 1000 ** 6 + } + + const multiplier = scale[unit] + if (!multiplier) return null + + return Math.floor(value * multiplier) + } + + const bumpActivity = (): void => { + lastAdvanceAt = Date.now() + } + + const handleRcloneOutput = (chunk: Buffer): void => { + 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 transferredBytes = parseTransferredBytes(line) + if (transferredBytes !== null && transferredBytes > lastTransferredBytes) { + lastTransferredBytes = transferredBytes + bumpActivity() + } + + 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 + bumpActivity() + + 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] + ) + } + } + } + } + } + + 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 + }) + + stallWatchdog = setInterval(() => { + if (killedByStallWatchdog) return + const currentItem = this.queueManager.findItem(item.releaseName) + if (!currentItem || currentItem.status !== 'Downloading') return + + const idleMs = Date.now() - lastAdvanceAt + if ( + idleMs >= DownloadProcessor.PUBLIC_DOWNLOAD_STALL_TIMEOUT_MS && + (lastProgress < 100 || lastTransferredBytes === 0) + ) { + killedByStallWatchdog = true + console.error( + `[DownProc] Detected stalled rclone download for ${item.releaseName} (no transfer progress for ${Math.round(idleMs / 1000)}s). Terminating process.` + ) + rcloneProcess.kill('SIGTERM') + } + }, 10000) + + console.log( + `[DownProc] rclone process started for ${item.releaseName} with PID: ${rcloneProcess.pid}` + ) + + await rcloneProcess + + if (stallWatchdog) { + clearInterval(stallWatchdog) + stallWatchdog = null + } + + 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 (stallWatchdog) { + clearInterval(stallWatchdog) + stallWatchdog = null + } + + if (this.activeDownloads.has(item.releaseName)) { + this.activeDownloads.delete(item.releaseName) + this.queueManager.updateItem(item.releaseName, { pid: undefined }) + } + + if (isExecaError(error) && error.exitCode === 143 && !killedByStallWatchdog) { + console.log(`[DownProc] rclone download cancelled for ${item.releaseName}`) + return { success: false, startExtraction: false, finalState: currentItemState } + } + + let errorMessage = 'Public endpoint download failed.' + if (killedByStallWatchdog) { + errorMessage = `Download stalled: no transfer progress for ${Math.round( + DownloadProcessor.PUBLIC_DOWNLOAD_STALL_TIMEOUT_MS / 1000 + )}s` + } else 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 @@ -245,7 +535,7 @@ export class DownloadProcessor { return { success: false, startExtraction: false } } - const downloadPath = join(item.downloadPath, item.releaseName) + const downloadPath = this.resolveItemDownloadPath(item) this.queueManager.updateItem(item.releaseName, { downloadPath: downloadPath }) // Create unique mount point for this download (sanitize name to avoid issues) @@ -336,6 +626,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 +671,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 +707,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 +901,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 +1039,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 +1057,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/main/services/download/installationProcessor.ts b/src/main/services/download/installationProcessor.ts index 3f7c418..7910cb3 100644 --- a/src/main/services/download/installationProcessor.ts +++ b/src/main/services/download/installationProcessor.ts @@ -166,8 +166,17 @@ export class InstallationProcessor { // Ensure -r and -g are included for compatibility and permissions. const combinedFlags = Array.from(new Set(['-r', '-g', ...installArgs])) - await this.adbService.installPackage(deviceId, apkPath, { flags: combinedFlags }) - commandSuccess = true // Assuming installPackage throws on error + const installSucceeded = await this.adbService.installPackage(deviceId, apkPath, { + flags: combinedFlags + }) + if (installSucceeded) { + commandSuccess = true + } else { + const installError = this.adbService.getLastInstallError() + errorMessage = installError + ? `Install command failed for ${apkArg}: ${installError}` + : `Install command failed for ${apkArg}` + } // if (output?.includes('Success')) { // commandSuccess = true // } else { @@ -307,7 +316,23 @@ export class InstallationProcessor { console.log(`[InstallProc Standard] Installing ${apkPath}...`) try { // Use the simplified installPackage, now with flags for reinstall and granting permissions - await this.adbService.installPackage(deviceId, apkPath, { flags: ['-r', '-g'] }) + const installSucceeded = await this.adbService.installPackage(deviceId, apkPath, { + flags: ['-r', '-g'] + }) + if (!installSucceeded) { + const installError = this.adbService.getLastInstallError() + console.error(`[InstallProc Standard] Failed to install ${apk} (adb returned false)`) + this.updateItemStatus( + item.releaseName, + 'InstallError', + 100, + installError + ? `Failed to install ${apk}: ${installError}` + : `Failed to install ${apk}: device rejected install`, + 100 + ) + return false + } console.log(`[InstallProc Standard] Successfully installed ${apk}`) } catch (installError: unknown) { const errorMsg = diff --git a/src/main/services/downloadService.ts b/src/main/services/downloadService.ts index 9514339..15b27c1 100644 --- a/src/main/services/downloadService.ts +++ b/src/main/services/downloadService.ts @@ -1,5 +1,6 @@ import { BrowserWindow } from 'electron' import { promises as fs, existsSync } from 'fs' +import { resolve, sep } from 'path' import adbService from './adbService' import { EventEmitter } from 'events' import { debounce } from './download/utils' @@ -7,9 +8,16 @@ import { QueueManager } from './download/queueManager' import { DownloadProcessor } from './download/downloadProcessor' import { ExtractionProcessor } from './download/extractionProcessor' import { InstallationProcessor } from './download/installationProcessor' -import { DownloadAPI, GameInfo, DownloadItem, DownloadStatus } from '@shared/types' +import { + DownloadAPI, + GameInfo, + DownloadItem, + DownloadStatus, + DownloadAddOptions +} from '@shared/types' import settingsService from './settingsService' import { typedWebContentsSend } from '@shared/ipc-utils' +import localLibraryService from './localLibraryService' interface VrpConfig { baseUri?: string @@ -118,7 +126,7 @@ class DownloadService extends EventEmitter implements DownloadAPI { return Promise.resolve(this.queueManager.getQueue()) } - public addToQueue(game: GameInfo): Promise { + public addToQueue(game: GameInfo, options?: DownloadAddOptions): Promise { if (!this.isInitialized) { console.error('DownloadService not initialized. Cannot add to queue.') return Promise.resolve(false) @@ -132,8 +140,14 @@ class DownloadService extends EventEmitter implements DownloadAPI { if (existing) { if (existing.status === 'Completed') { - console.log(`Game ${game.releaseName} already downloaded.`) - return Promise.resolve(false) + if (!options?.forceRequeueCompleted) { + console.log(`Game ${game.releaseName} already downloaded.`) + return Promise.resolve(false) + } + console.log( + `Re-queue requested for completed item ${game.releaseName}; replacing existing queue entry.` + ) + this.queueManager.removeItem(game.releaseName) } else if (existing.status !== 'Error' && existing.status !== 'Cancelled') { console.log( `Game ${game.releaseName} is already in the queue with status: ${existing.status}.` @@ -154,7 +168,8 @@ class DownloadService extends EventEmitter implements DownloadAPI { addedDate: Date.now(), thumbnailPath: game.thumbnailPath, downloadPath: this.downloadsPath, - size: game.size + size: game.size, + skipInstall: options?.skipInstall === true } this.queueManager.addItem(newItem) console.log(`Added ${game.releaseName} to download queue.`) @@ -285,6 +300,20 @@ class DownloadService extends EventEmitter implements DownloadAPI { return } + // Keep local library index in sync when content extraction has completed. + localLibraryService.rescan().catch((error) => { + console.warn('[Service ProcessQueue] Local library rescan failed after extraction:', error) + }) + + if (itemAfterExtraction.skipInstall) { + console.log( + `[Service ProcessQueue] Extraction successful for ${itemAfterExtraction.releaseName}. Auto-install skipped by request.` + ) + this.isProcessing = false + this.processQueue() + return + } + // Re-check connection state before installation (device might have disconnected during extraction) const finalTargetDeviceId = this.getTargetDeviceForInstallation() if (!finalTargetDeviceId) { @@ -391,6 +420,28 @@ class DownloadService extends EventEmitter implements DownloadAPI { } } + private isPathWithinRoot(pathToCheck: string, rootPath: string): boolean { + const normalizedPath = resolve(pathToCheck) + const normalizedRoot = resolve(rootPath) + return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}${sep}`) + } + + private async hasInstallablePayload(downloadPath: string): Promise { + try { + const entries = await fs.readdir(downloadPath) + return entries.some((entry) => { + const lower = entry.toLowerCase() + return lower.endsWith('.apk') || lower === 'install.txt' + }) + } catch (error) { + console.warn( + `[Service installFromCompleted] Failed to inspect download path "${downloadPath}":`, + error + ) + return false + } + } + public cancelUserRequest(releaseName: string): Promise { const item = this.queueManager.findItem(releaseName) if (!item) { @@ -560,6 +611,9 @@ class DownloadService extends EventEmitter implements DownloadAPI { console.log(`Deleted directory ${downloadPath}.`) const removed = this.queueManager.removeItem(releaseName) if (removed) this.emitUpdate() + localLibraryService.rescan().catch((error) => { + console.warn('[Service deleteDownloadedFiles] Local library rescan failed:', error) + }) return true } catch (error: unknown) { console.error(`Error deleting ${downloadPath} for ${releaseName}:`, error) @@ -591,6 +645,27 @@ class DownloadService extends EventEmitter implements DownloadAPI { throw new Error(`Item ${releaseName} is not in 'Completed' state.`) } + if (!item.downloadPath || !existsSync(item.downloadPath)) { + console.warn( + `[Service installFromCompleted] Local files missing for ${releaseName}. Path: ${item.downloadPath}` + ) + throw new Error('LOCAL_FILES_MISSING') + } + + if (!this.isPathWithinRoot(item.downloadPath, this.downloadsPath)) { + console.warn( + `[Service installFromCompleted] Blocking install for ${releaseName}; path ${item.downloadPath} is outside active download root ${this.downloadsPath}.` + ) + throw new Error('LOCAL_PATH_OUTSIDE_ACTIVE_DOWNLOAD_ROOT') + } + + if (!(await this.hasInstallablePayload(item.downloadPath))) { + console.warn( + `[Service installFromCompleted] Local files missing install payload for ${releaseName} in ${item.downloadPath}.` + ) + throw new Error('LOCAL_FILES_MISSING') + } + if (this.isProcessing) { console.warn( `[Service installFromCompleted] Queue is already processing. Installation for ${releaseName} will be handled if it becomes the next item.` diff --git a/src/main/services/localLibraryService.ts b/src/main/services/localLibraryService.ts new file mode 100644 index 0000000..dd9dc78 --- /dev/null +++ b/src/main/services/localLibraryService.ts @@ -0,0 +1,274 @@ +import { app } from 'electron' +import { EventEmitter } from 'events' +import { basename, join } from 'path' +import { Dirent, existsSync, promises as fs } from 'fs' +import settingsService from './settingsService' +import { LocalLibraryEntry, LocalLibraryIndex } from '@shared/types' + +const MAX_SCAN_DEPTH = 4 +const POLL_INTERVAL_MS = 30000 + +class LocalLibraryService extends EventEmitter { + private readonly indexPath: string + private index: LocalLibraryIndex + private initialized = false + private scanRootPath: string + private scanTimeout: NodeJS.Timeout | null = null + private pollTimer: NodeJS.Timeout | null = null + private scanInProgress = false + private pendingScan = false + + constructor() { + super() + this.indexPath = join(app.getPath('userData'), 'local-library-index.json') + this.scanRootPath = settingsService.getDownloadPath() + this.index = { + rootPath: this.scanRootPath, + generatedAt: Date.now(), + entries: [] + } + + settingsService.on('download-path-changed', (path: string) => { + this.scanRootPath = path + this.scheduleRescan(250) + }) + } + + public async initialize(): Promise { + if (this.initialized) return + + await this.loadIndexFromDisk() + this.scanRootPath = settingsService.getDownloadPath() + + // Ensure root path is always aligned with current setting. + if (this.index.rootPath !== this.scanRootPath) { + this.index = { + rootPath: this.scanRootPath, + generatedAt: Date.now(), + entries: [] + } + await this.saveIndexToDisk() + } + + await this.rescan() + + this.pollTimer = setInterval(() => { + this.scheduleRescan(0) + }, POLL_INTERVAL_MS) + + this.initialized = true + console.log('[LocalLibrary] Service initialized.') + } + + public async shutdown(): Promise { + if (this.scanTimeout) { + clearTimeout(this.scanTimeout) + this.scanTimeout = null + } + if (this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + this.initialized = false + } + + public getIndex(): LocalLibraryIndex { + return this.index + } + + public async rescan(): Promise { + if (this.scanInProgress) { + this.pendingScan = true + return this.index + } + + this.scanInProgress = true + + try { + const nextIndex = await this.buildIndex(this.scanRootPath) + const changed = this.isIndexChanged(this.index, nextIndex) + + this.index = nextIndex + + if (changed) { + await this.saveIndexToDisk() + this.emit('updated', this.index) + console.log( + `[LocalLibrary] Index updated. ${this.index.entries.length} local entry(ies) at ${this.index.rootPath}` + ) + } + + return this.index + } finally { + this.scanInProgress = false + if (this.pendingScan) { + this.pendingScan = false + this.scheduleRescan(100) + } + } + } + + private scheduleRescan(delayMs: number): void { + if (this.scanTimeout) { + clearTimeout(this.scanTimeout) + } + this.scanTimeout = setTimeout(() => { + this.scanTimeout = null + this.rescan().catch((error) => { + console.error('[LocalLibrary] Scheduled rescan failed:', error) + }) + }, delayMs) + } + + private async loadIndexFromDisk(): Promise { + try { + if (!existsSync(this.indexPath)) return + const content = await fs.readFile(this.indexPath, 'utf-8') + const parsed = JSON.parse(content) as LocalLibraryIndex + if (!parsed || !Array.isArray(parsed.entries) || typeof parsed.rootPath !== 'string') return + this.index = parsed + } catch (error) { + console.warn('[LocalLibrary] Failed to load index from disk:', error) + } + } + + private async saveIndexToDisk(): Promise { + try { + await fs.writeFile(this.indexPath, JSON.stringify(this.index, null, 2), 'utf-8') + } catch (error) { + console.warn('[LocalLibrary] Failed to save index to disk:', error) + } + } + + private isIndexChanged(previous: LocalLibraryIndex, next: LocalLibraryIndex): boolean { + if (previous.rootPath !== next.rootPath) return true + + const normalize = (index: LocalLibraryIndex): string => + JSON.stringify({ + rootPath: index.rootPath, + entries: index.entries.map((entry) => ({ + id: entry.id, + releaseName: entry.releaseName, + path: entry.path, + source: entry.source, + apkCount: entry.apkCount, + hasInstallScript: entry.hasInstallScript, + packageNames: [...entry.packageNames].sort() + })) + }) + + return normalize(previous) !== normalize(next) + } + + private async buildIndex(rootPath: string): Promise { + const generatedAt = Date.now() + const entries: LocalLibraryEntry[] = [] + + if (!rootPath || !existsSync(rootPath)) { + return { rootPath, generatedAt, entries } + } + + let topLevelEntries: Dirent[] = [] + try { + topLevelEntries = await fs.readdir(rootPath, { withFileTypes: true }) + } catch (error) { + console.warn(`[LocalLibrary] Failed to read root path "${rootPath}":`, error) + return { rootPath, generatedAt, entries } + } + + for (const topEntry of topLevelEntries) { + const fullPath = join(rootPath, topEntry.name) + + if (topEntry.isFile() && topEntry.name.toLowerCase().endsWith('.apk')) { + const packageName = this.parsePackageNameFromApk(topEntry.name) + entries.push({ + id: fullPath, + releaseName: basename(topEntry.name, '.apk'), + path: fullPath, + source: 'apk-file', + apkCount: 1, + hasInstallScript: false, + packageNames: packageName ? [packageName] : [], + lastSeen: generatedAt + }) + continue + } + + if (!topEntry.isDirectory()) continue + + const folderScan = await this.scanDirectoryForInstallables(fullPath, 0) + if (folderScan.apkFiles.length === 0 && !folderScan.hasInstallScript) continue + + const packageNames = Array.from( + new Set( + folderScan.apkFiles + .map((apkPath) => this.parsePackageNameFromApk(apkPath)) + .filter((name): name is string => Boolean(name)) + ) + ).sort() + + entries.push({ + id: fullPath, + releaseName: topEntry.name, + path: fullPath, + source: 'folder', + apkCount: folderScan.apkFiles.length, + hasInstallScript: folderScan.hasInstallScript, + packageNames, + lastSeen: generatedAt + }) + } + + entries.sort((a, b) => a.releaseName.localeCompare(b.releaseName)) + return { rootPath, generatedAt, entries } + } + + private async scanDirectoryForInstallables( + dirPath: string, + depth: number + ): Promise<{ apkFiles: string[]; hasInstallScript: boolean }> { + const apkFiles: string[] = [] + let hasInstallScript = false + + let dirEntries: Dirent[] = [] + try { + dirEntries = await fs.readdir(dirPath, { withFileTypes: true }) + } catch { + return { apkFiles, hasInstallScript } + } + + for (const entry of dirEntries) { + const entryPath = join(dirPath, entry.name) + const lowerName = entry.name.toLowerCase() + + if (entry.isFile()) { + if (lowerName.endsWith('.apk')) apkFiles.push(entryPath) + if (lowerName === 'install.txt') hasInstallScript = true + continue + } + + if (entry.isDirectory() && depth < MAX_SCAN_DEPTH) { + const nested = await this.scanDirectoryForInstallables(entryPath, depth + 1) + if (nested.apkFiles.length > 0) apkFiles.push(...nested.apkFiles) + if (nested.hasInstallScript) hasInstallScript = true + } + } + + return { apkFiles, hasInstallScript } + } + + private parsePackageNameFromApk(apkPathOrFileName: string): string | null { + const fileName = basename(apkPathOrFileName) + if (!fileName.toLowerCase().endsWith('.apk')) return null + + const baseName = basename(fileName, '.apk') + // Typical Android package format, tolerant of uppercase segments seen in some releases. + if (!/^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+$/.test(baseName)) { + return null + } + + return baseName + } +} + +export default new LocalLibraryService() diff --git a/src/preload/index.ts b/src/preload/index.ts index aaff131..dc8655d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -20,7 +20,10 @@ import { LogsAPIRenderer, MirrorAPIRenderer, Mirror, - WiFiBookmark + WiFiBookmark, + LocalLibraryAPIRenderer, + LocalLibraryIndex, + DownloadAddOptions } from '@shared/types' import { typedIpcRenderer } from '@shared/ipc-utils' @@ -103,7 +106,8 @@ const api = { // Download Queue APIs downloads: { getQueue: (): Promise => typedIpcRenderer.invoke('download:get-queue'), - addToQueue: (game: GameInfo): Promise => typedIpcRenderer.invoke('download:add', game), + addToQueue: (game: GameInfo, options?: DownloadAddOptions): Promise => + typedIpcRenderer.invoke('download:add', game, options), removeFromQueue: (releaseName: string): Promise => typedIpcRenderer.invoke('download:remove', releaseName), cancelUserRequest: (releaseName: string): void => @@ -132,6 +136,15 @@ const api = { setAppConnectionState: (selectedDevice: string | null, isConnected: boolean): void => ipcRenderer.send('download:set-app-connection-state', selectedDevice, isConnected) } satisfies DownloadAPIRenderer, + localLibrary: { + getIndex: (): Promise => typedIpcRenderer.invoke('local-library:get-index'), + rescan: (): Promise => typedIpcRenderer.invoke('local-library:rescan'), + onUpdated: (callback: (index: LocalLibraryIndex) => void): (() => void) => { + const listener = (_: IpcRendererEvent, index: LocalLibraryIndex): void => callback(index) + typedIpcRenderer.on('local-library:updated', listener) + return () => typedIpcRenderer.removeListener('local-library:updated', listener) + } + } satisfies LocalLibraryAPIRenderer, // Upload APIs uploads: { prepareUpload: ( diff --git a/src/renderer/src/assets/games-view.css b/src/renderer/src/assets/games-view.css index a305d62..558a88d 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, @@ -297,6 +309,42 @@ font-weight: 500; } +.status-header-filters { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.status-filter-toggle { + appearance: none; + border: none; + background: transparent; + padding: 0; + margin: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.status-filter-icon-wrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.status-filter-toggle.excluded .status-filter-icon-wrap::after { + content: ''; + position: absolute; + width: 21px; + border-top: 2px solid #ff4d4f; + transform: rotate(-28deg); +} + /* Add Row Background Styles */ .row-installed .game-name-main { color: var(--colorBrandForeground1); /* Lighter green, slightly transparent */ 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/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..659ed71 100644 --- a/src/renderer/src/components/GameDetailsDialog.tsx +++ b/src/renderer/src/components/GameDetailsDialog.tsx @@ -15,7 +15,8 @@ import { Badge, Divider, Spinner, - ProgressBar + ProgressBar, + Tooltip } from '@fluentui/react-components' import { ArrowClockwiseRegular, @@ -33,6 +34,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 +129,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, @@ -150,8 +189,11 @@ interface GameDetailsDialogProps { game: GameInfo | null open: boolean onClose: () => void - downloadStatusMap: Map + downloadStatusMap: Map + isStoredLocally: boolean onInstall: (game: GameInfo) => void + onDownloadOnly: (game: GameInfo) => void + onRedownload: (game: GameInfo) => void onUninstall: (game: GameInfo) => Promise onReinstall: (game: GameInfo) => Promise onUpdate: (game: GameInfo) => Promise @@ -169,7 +211,10 @@ const GameDetailsDialog: React.FC = ({ open, onClose, downloadStatusMap, + isStoredLocally, onInstall, + onDownloadOnly, + onRedownload, onUninstall, onReinstall, onUpdate, @@ -187,6 +232,10 @@ const GameDetailsDialog: React.FC = ({ const [loadingNote, setLoadingNote] = useState(false) const [videoId, setVideoId] = useState(null) const [loadingVideo, setLoadingVideo] = useState(false) + const [videoError, setVideoError] = useState(false) + const statusInfo = game?.releaseName ? downloadStatusMap.get(game.releaseName) : undefined + const currentStatus = statusInfo?.status + const installErrorMessage = statusInfo?.error // Fetch note when dialog opens or game changes useEffect(() => { @@ -228,6 +277,7 @@ const GameDetailsDialog: React.FC = ({ setLoadingVideo(true) setVideoId(null) + setVideoError(false) try { const videoId = await getTrailerVideoIdFromContext(game.name) @@ -253,11 +303,20 @@ 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 const canCancel = status === 'Downloading' || status === 'Extracting' || status === 'Queued' - const isDownloaded = status === 'Completed' + const isDownloaded = isStoredLocally const isInstalled = currentGame.isInstalled const hasUpdate = currentGame.hasUpdate const isInstallError = status === 'InstallError' @@ -321,6 +380,16 @@ const GameDetailsDialog: React.FC = ({ > Update + {!isStoredLocally && ( + + )} + {!isStoredLocally && ( + + )} + <> + + {isConnected && ( + + )} + ) } @@ -452,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 @@ -540,6 +640,37 @@ const GameDetailsDialog: React.FC = ({
{loadingVideo ? ( + ) : videoError && videoId ? ( +
+
+ Trailer thumbnail +
+
+ YouTube + Trailer can’t play in-app + + Some videos block embeds. Open it on YouTube instead. + + +
+
+
+
) : videoId ? (
= ({ opts={{ width: '100%', height: '100%', + host: 'https://www.youtube.com', playerVars: { - autoplay: 0 + autoplay: 0, + origin: getYouTubeOrigin() } }} + onError={() => { + setVideoError(true) + }} />
) : ( diff --git a/src/renderer/src/components/GamesView.tsx b/src/renderer/src/components/GamesView.tsx index 31125e7..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, @@ -41,12 +41,16 @@ import { DialogContent, DialogActions } from '@fluentui/react-components' +import { Tooltip } from '@fluentui/react-components' import { ArrowClockwiseRegular, DismissRegular, PlugDisconnectedRegular, CheckmarkCircleRegular, DesktopRegular, + Star16Filled, + Star16Regular, + StarHalf16Filled, BatteryChargeRegular, StorageRegular, PersonRegular, @@ -54,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' @@ -81,9 +88,10 @@ const FIXED_COLUMNS_WIDTH = COLUMN_WIDTHS.SIZE + COLUMN_WIDTHS.LAST_UPDATED -type FilterType = 'all' | 'installed' | 'update' +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() @@ -95,9 +103,50 @@ 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 + gameNameAndPackageFilter: FilterFn + booleanEquals: FilterFn } } @@ -267,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) @@ -281,32 +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() + 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, + 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 - return { total, installed, updates } - }, [games]) + const downloaded = games.filter((g) => !g.isInstalled && isGameStoredLocally(g)).length + return { total, installed, downloaded, updates } + }, [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') + 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': + 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) => { @@ -328,21 +487,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 @@ -380,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, @@ -390,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 @@ -407,18 +589,26 @@ const GamesView: React.FC = ({ onBackToDevices }) => {
{isDownloaded && ( - + + + + + )} {isInstalled && ( - + + + + + )} {isUpdateAvailable && ( = ({ onBackToDevices }) => {
)} {isInstallError && ( - - Install Error - + + + + Install Error + + + )}
{(isDownloading || isExtracting || isInstalling) && downloadInfo && ( @@ -555,7 +752,7 @@ const GamesView: React.FC = ({ 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,28 +773,44 @@ 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', + 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: { - 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, @@ -651,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) @@ -687,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( @@ -751,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.` @@ -813,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.` @@ -859,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( @@ -1292,13 +1593,73 @@ const GamesView: React.FC = ({ onBackToDevices }) => { All ({counts.total}) +
)} {header.column.getCanResize() && ( @@ -1392,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' : '' @@ -1438,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/context/GamesProvider.tsx b/src/renderer/src/context/GamesProvider.tsx index a29cc69..7837337 100644 --- a/src/renderer/src/context/GamesProvider.tsx +++ b/src/renderer/src/context/GamesProvider.tsx @@ -156,6 +156,15 @@ export const GamesProvider: React.FC = ({ children }) => { // enrich the games with the installed packages and the device version codes const games = useMemo((): GameInfo[] => { + if (!isDeviceConnected) { + return rawGames.map((game) => ({ + ...game, + isInstalled: false, + deviceVersionCode: undefined, + hasUpdate: false + })) + } + const installedSet = new Set(installedPackages.map((pkg) => pkg.packageName)) return rawGames.map((game) => { @@ -185,7 +194,7 @@ export const GamesProvider: React.FC = ({ children }) => { hasUpdate } }) - }, [rawGames, installedPackages]) + }, [rawGames, installedPackages, isDeviceConnected]) const localGames = useMemo((): GameInfo[] => { return installedPackages.map((game) => ({ 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] }