From 71ddeb5ab30ebc16374b35367621c53d2a8b56f0 Mon Sep 17 00:00:00 2001 From: Gabisonfire Date: Tue, 26 Aug 2025 12:54:50 -0400 Subject: [PATCH 1/3] Adds local storage refresh and some filtering --- src/main/index.ts | 9 +- src/main/services/downloadService.ts | 171 ++++++++++- src/preload/index.ts | 2 + .../src/components/GameDetailsDialog.tsx | 18 +- src/renderer/src/components/GamesView.tsx | 270 ++++++++++++++++-- src/renderer/src/components/Settings.tsx | 39 ++- src/shared/types/index.ts | 1 + src/shared/types/ipc.ts | 1 + 8 files changed, 476 insertions(+), 35 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index acdd05a..f68df87 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -328,10 +328,6 @@ app.whenReady().then(async () => { await downloadService.removeFromQueue(releaseName) }) - typedIpcMain.on('download:cancel', (_event, releaseName) => - downloadService.cancelUserRequest(releaseName) - ) - typedIpcMain.on('download:retry', (_event, releaseName) => downloadService.retryDownload(releaseName) ) @@ -613,6 +609,11 @@ app.whenReady().then(async () => { return await downloadService.copyObbFolder(folderPath, deviceId) }) + typedIpcMain.handle('downloads:refresh-local-storage', async () => { + console.log('[IPC] Refresh local storage requested') + return await downloadService.refreshLocalStorage() + }) + // Validate that all IPC channels have handlers registered const allHandled = typedIpcMain.validateAllHandlersRegistered() if (!allHandled) { diff --git a/src/main/services/downloadService.ts b/src/main/services/downloadService.ts index 9514339..e309293 100644 --- a/src/main/services/downloadService.ts +++ b/src/main/services/downloadService.ts @@ -1,5 +1,5 @@ import { BrowserWindow } from 'electron' -import { promises as fs, existsSync } from 'fs' +import { promises as fs, existsSync, readdirSync } from 'fs' import adbService from './adbService' import { EventEmitter } from 'events' import { debounce } from './download/utils' @@ -10,6 +10,8 @@ import { InstallationProcessor } from './download/installationProcessor' import { DownloadAPI, GameInfo, DownloadItem, DownloadStatus } from '@shared/types' import settingsService from './settingsService' import { typedWebContentsSend } from '@shared/ipc-utils' +import { join } from 'path' +import gameService from './gameService' interface VrpConfig { baseUri?: string @@ -34,6 +36,10 @@ class DownloadService extends EventEmitter implements DownloadAPI { const downloadPath = settingsService.getDownloadPath() settingsService.on('download-path-changed', (path) => { this.setDownloadPath(path) + // Trigger a re-scan when the downloads path changes + this.scanExistingCompletedDownloads().catch((e) => + console.warn('[Service] scanExistingCompletedDownloads on path change failed:', e) + ) }) this.downloadsPath = downloadPath @@ -51,6 +57,46 @@ class DownloadService extends EventEmitter implements DownloadAPI { setDownloadPath(path: string): void { this.downloadsPath = path + // Update paths for existing items that might have moved to the new location + this.updateExistingItemPaths(path) + } + + private updateExistingItemPaths(newBasePath: string): void { + try { + const queue = this.queueManager.getQueue() + let updatedCount = 0 + + for (const item of queue) { + if (item.releaseName && item.status === 'Completed') { + const newPath = join(newBasePath, item.releaseName) + if (existsSync(newPath)) { + // Check if this path contains the expected content + try { + const contents = readdirSync(newPath) + const hasApk = contents.some((f) => f.toLowerCase().endsWith('.apk')) + const hasInstallScript = contents.some( + (f) => f.toLowerCase() === 'install.txt' || f.toLowerCase() === 'install' + ) + + if (hasApk || hasInstallScript) { + console.log(`[Service] Updating path for moved item ${item.releaseName}: ${item.downloadPath} -> ${newPath}`) + this.queueManager.updateItem(item.releaseName, { downloadPath: newPath }) + updatedCount++ + } + } catch (e) { + console.warn(`[Service] Could not verify contents of ${newPath}:`, e) + } + } + } + } + + if (updatedCount > 0) { + console.log(`[Service] Updated ${updatedCount} item paths after download path change.`) + this.emitUpdate() + } + } catch (e) { + console.warn('[Service] Error updating existing item paths:', e) + } } setAppConnectionState(selectedDevice: string | null, isConnected: boolean): void { @@ -89,6 +135,9 @@ class DownloadService extends EventEmitter implements DownloadAPI { await fs.mkdir(this.downloadsPath, { recursive: true }) await this.queueManager.loadQueue() + // Import any existing completed downloads present on disk into the queue + await this.scanExistingCompletedDownloads() + const changed = this.queueManager.updateAllItems( (item) => item.status === 'Downloading' || @@ -474,7 +523,7 @@ class DownloadService extends EventEmitter implements DownloadAPI { return Promise.resolve() } - public retryDownload(releaseName: string): Promise { + public retryDownload(releaseName: string): void { const item = this.queueManager.findItem(releaseName) if ( item && @@ -514,7 +563,6 @@ class DownloadService extends EventEmitter implements DownloadAPI { } else { console.warn(`[Service Retry] Cannot retry ${releaseName} - status: ${item?.status}`) } - return Promise.resolve() } public pauseDownload(releaseName: string): void { @@ -881,6 +929,123 @@ class DownloadService extends EventEmitter implements DownloadAPI { return false } } + + public async refreshLocalStorage(): Promise<{ added: number; updated: number; total: number }> { + console.log('[Service] Refresh local storage requested') + try { + const beforeCount = this.queueManager.getQueue().length + await this.scanExistingCompletedDownloads() + const afterCount = this.queueManager.getQueue().length + + const added = afterCount - beforeCount + const updated = 0 // This would need to be tracked in scanExistingCompletedDownloads + + console.log(`[Service] Local storage refresh completed successfully. Queue: ${beforeCount} -> ${afterCount}`) + return { added, updated, total: afterCount } + } catch (error) { + console.error('[Service] Error during local storage refresh:', error) + throw error + } + } + + private async scanExistingCompletedDownloads(): Promise { + try { + const basePath = this.downloadsPath + if (!basePath || !existsSync(basePath)) { + console.warn('[Service] scanExistingCompletedDownloads: downloadsPath missing or invalid') + return + } + + const entries = await fs.readdir(basePath, { withFileTypes: true }) + const folders = entries.filter((e) => e.isDirectory()).map((e) => e.name) + if (folders.length === 0) return + + // Build a quick index of existing queue items by releaseName + const existing = new Map() + this.queueManager.getQueue().forEach((item) => { + if (item.releaseName) { + existing.set(item.releaseName, item) + } + }) + + // Load known games to match folder names to releaseName + let games: GameInfo[] = [] + try { + games = await gameService.getGames() + } catch (e) { + console.warn('[Service] scanExistingCompletedDownloads: failed to load games list:', e) + } + const gameByRelease = new Map() + games.forEach((g) => { + if (g.releaseName) gameByRelease.set(g.releaseName, g) + }) + + let addedCount = 0 + let updatedCount = 0 + + for (const folderName of folders) { + const folderPath = join(basePath, folderName) + + // Heuristic: consider a folder a completed download if it contains any APK files or an install script + let looksCompleted = false + try { + const sub = await fs.readdir(folderPath) + const hasApk = sub.some((f) => f.toLowerCase().endsWith('.apk')) + const hasInstallScript = sub.some( + (f) => f.toLowerCase() === 'install.txt' || f.toLowerCase() === 'install' + ) + looksCompleted = hasApk || hasInstallScript + } catch (e) { + console.warn('[Service] scanExistingCompletedDownloads: cannot read folder:', folderPath) + } + if (!looksCompleted) continue + + const matchedGame = gameByRelease.get(folderName) + const existingItem = existing.get(folderName) + + if (existingItem) { + // Update existing item if path has changed + if (existingItem.downloadPath !== folderPath) { + console.log(`[Service] Updating path for existing item ${folderName}: ${existingItem.downloadPath} -> ${folderPath}`) + this.queueManager.updateItem(folderName, { + downloadPath: folderPath, + // Also update other fields if we have better game info + gameName: matchedGame?.name || existingItem.gameName, + packageName: matchedGame?.packageName || existingItem.packageName, + thumbnailPath: matchedGame?.thumbnailPath || existingItem.thumbnailPath, + size: matchedGame?.size || existingItem.size + }) + updatedCount++ + } + } else { + // Add new item + const newItem: DownloadItem = { + gameId: matchedGame?.id || folderName, + releaseName: folderName, + packageName: matchedGame?.packageName || '', + gameName: matchedGame?.name || folderName, + status: 'Completed', + progress: 100, + extractProgress: 100, + addedDate: Date.now(), + thumbnailPath: matchedGame?.thumbnailPath, + downloadPath: folderPath, + size: matchedGame?.size + } + + this.queueManager.addItem(newItem) + addedCount++ + } + } + + if (addedCount > 0 || updatedCount > 0) { + console.log(`[Service] Imported ${addedCount} new and updated ${updatedCount} existing download item(s) from disk.`) + this.emitUpdate() + } + } catch (e) { + console.warn('[Service] scanExistingCompletedDownloads encountered an error:', e) + } + } } export default new DownloadService() diff --git a/src/preload/index.ts b/src/preload/index.ts index aaff131..99d12e9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -122,6 +122,8 @@ const api = { typedIpcRenderer.invoke('downloads:install-manual', filePath, deviceId), copyObbFolder: (folderPath: string, deviceId: string): Promise => typedIpcRenderer.invoke('downloads:copy-obb-folder', folderPath, deviceId), + refreshLocalStorage: (): Promise => + typedIpcRenderer.invoke('downloads:refresh-local-storage'), onQueueUpdated: (callback: (queue: DownloadItem[]) => void): (() => void) => { const listener = (_: IpcRendererEvent, queue: DownloadItem[]): void => callback(queue) typedIpcRenderer.on('download:queue-updated', listener) diff --git a/src/renderer/src/components/GameDetailsDialog.tsx b/src/renderer/src/components/GameDetailsDialog.tsx index c61e53e..3e6d82c 100644 --- a/src/renderer/src/components/GameDetailsDialog.tsx +++ b/src/renderer/src/components/GameDetailsDialog.tsx @@ -30,11 +30,13 @@ import { InfoRegular, CheckmarkCircleRegular, VideoRegular, - BroomRegular as UninstallIcon + BroomRegular as UninstallIcon, + DesktopRegular } from '@fluentui/react-icons' import placeholderImage from '../assets/images/game-placeholder.png' import YouTube from 'react-youtube' import { useGames } from '@renderer/hooks/useGames' +import { useDownload } from '@renderer/hooks/useDownload' const useStyles = makeStyles({ dialogContentLayout: { @@ -187,6 +189,12 @@ const GameDetailsDialog: React.FC = ({ const [loadingNote, setLoadingNote] = useState(false) const [videoId, setVideoId] = useState(null) const [loadingVideo, setLoadingVideo] = useState(false) + const { queue: downloadQueue } = useDownload() + const localPath = (() => { + if (!game?.releaseName) return undefined + const item = downloadQueue.find((i) => i.releaseName === game.releaseName) + return item?.status === 'Completed' ? item.downloadPath : undefined + })() // Fetch note when dialog opens or game changes useEffect(() => { @@ -483,6 +491,14 @@ const GameDetailsDialog: React.FC = ({ {game.size || '-'} + {localPath && ( +
+ + + {localPath} + +
+ )}
{game.downloads?.toLocaleString() || '-'} diff --git a/src/renderer/src/components/GamesView.tsx b/src/renderer/src/components/GamesView.tsx index 31125e7..bab7b00 100644 --- a/src/renderer/src/components/GamesView.tsx +++ b/src/renderer/src/components/GamesView.tsx @@ -95,9 +95,15 @@ const filterGameNameAndPackage: FilterFn = (row, _columnId, filterValu ) } +const filterBoolean: FilterFn = (row, columnId, filterValue) => { + const value = row.getValue(columnId) + return Boolean(value) === Boolean(filterValue) +} + declare module '@tanstack/react-table' { interface FilterFns { gameNameAndPackageFilter: FilterFn + isInstalledFilter: FilterFn } } @@ -154,6 +160,29 @@ const useStyles = makeStyles({ display: 'flex', gap: tokens.spacingHorizontalS }, + filterCheckbox: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXS, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground1, + cursor: 'pointer', + '&:hover': { + backgroundColor: tokens.colorNeutralBackground2 + }, + '& input[type="checkbox"]': { + margin: 0, + cursor: 'pointer' + }, + '& label': { + cursor: 'pointer', + fontSize: '14px', + color: tokens.colorNeutralForeground1, + userSelect: 'none' + } + }, toolbarRight: { display: 'flex', alignItems: 'center', @@ -282,6 +311,60 @@ const GamesView: React.FC = ({ onBackToDevices }) => { const [showObbConfirmDialog, setShowObbConfirmDialog] = useState(false) const [obbFolderToConfirm, setObbFolderToConfirm] = useState(null) + // Add CSS styles for the filter checkbox + useEffect(() => { + const style = document.createElement('style') + style.textContent = ` + .filter-checkbox { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid #d1d1d1; + background-color: transparent; + cursor: pointer; + transition: background-color 0.2s; + white-space: nowrap; + } + .filter-checkbox:hover { + background-color: rgba(0, 0, 0, 0.05); + } + .filter-checkbox input[type="checkbox"] { + margin: 0; + cursor: pointer; + width: 16px; + height: 16px; + } + .filter-checkbox label { + cursor: pointer; + font-size: 14px; + color: inherit; + user-select: none; + margin: 0; + } + .filter-buttons { + display: flex; + align-items: center; + gap: 12px; + } + .games-toolbar-right { + display: flex; + align-items: center; + gap: 16px; + } + .game-count { + font-size: 14px; + color: #605e5c; + white-space: nowrap; + } + ` + document.head.appendChild(style) + return () => { + document.head.removeChild(style) + } + }, []) + const counts = useMemo(() => { const total = games.length const installed = games.filter((g) => g.isInstalled).length @@ -343,6 +426,17 @@ const GamesView: React.FC = ({ onBackToDevices }) => { return map }, [downloadQueue]) + // Map releaseName -> local download path for quick lookup when displaying "Downloaded" + const downloadPathMap = useMemo(() => { + const map = new Map() + downloadQueue.forEach((item) => { + if (item.releaseName && item.downloadPath) { + map.set(item.releaseName, item.downloadPath) + } + }) + return map + }, [downloadQueue]) + useEffect(() => { if (!tableContainerRef.current) return @@ -563,7 +657,39 @@ const GamesView: React.FC = ({ onBackToDevices }) => { accessorKey: 'size', header: 'Size', size: COLUMN_WIDTHS.SIZE, - cell: (info) => info.getValue() || '-', + cell: (info) => { + const game = info.row.original + const size = info.getValue() || '-' + const downloadInfo = game.releaseName + ? downloadStatusMap.get(game.releaseName) + : undefined + const isDownloaded = downloadInfo?.status === 'Completed' + const localPath = isDownloaded && game.releaseName + ? downloadPathMap.get(game.releaseName) + : undefined + return ( +
+ {size as string} + {isDownloaded && localPath && ( + + {localPath} + + )} +
+ ) + }, enableResizing: true }, { @@ -576,7 +702,21 @@ const GamesView: React.FC = ({ onBackToDevices }) => { { accessorKey: 'isInstalled', header: 'Installed Status', - enableResizing: false + enableResizing: false, + filterFn: 'isInstalledFilter' + }, + { + id: 'isDownloaded', + header: 'Downloaded Status', + enableResizing: false, + filterFn: 'isInstalledFilter', + accessorFn: (row) => { + return downloadQueue.some(item => item.releaseName === row.releaseName && item.status === 'Completed') + }, + cell: (info) => { + const isDownloaded = info.getValue() as boolean + return isDownloaded ? 'Downloaded' : 'Not Downloaded' + } }, { accessorKey: 'hasUpdate', @@ -584,20 +724,22 @@ const GamesView: React.FC = ({ onBackToDevices }) => { enableResizing: false } ] - }, [downloadStatusMap, styles, tableWidth]) + }, [downloadStatusMap, styles, tableWidth, downloadPathMap]) const table = useReactTable({ data: games, columns, columnResizeMode: 'onChange', + enableColumnFilters: true, filterFns: { - gameNameAndPackageFilter: filterGameNameAndPackage + gameNameAndPackageFilter: filterGameNameAndPackage, + isInstalledFilter: filterBoolean }, state: { sorting, globalFilter, columnFilters, - columnVisibility: { isInstalled: false, hasUpdate: false }, + columnVisibility: { isInstalled: false, hasUpdate: false, isDownloaded: false }, columnSizing }, onSortingChange: setSorting, @@ -610,7 +752,9 @@ const GamesView: React.FC = ({ onBackToDevices }) => { getSortedRowModel: getSortedRowModel() }) - const { rows } = table.getRowModel() + + + const { rows } = table.getFilteredRowModel() const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableContainerRef.current, @@ -1187,6 +1331,7 @@ const GamesView: React.FC = ({ onBackToDevices }) => { )}
)} +
= ({ onBackToDevices }) => { Last synced: {formatDate(lastSyncTime)} {isConnected && ( -
- - - +
+ {isEditingUserName ? ( +
+ setEditUserNameValue(e.target.value)} + placeholder="Enter your VR gaming name" + size="small" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveUserName() + } else if (e.key === 'Escape') { + handleCancelEditUserName() + } + }} + autoFocus + /> + + +
+ ) : ( +
+ + Username in Multiplayer Games: + + +
+ )}
)} +
{table.getFilteredRowModel().rows.length} displayed +
+ f.id === 'isDownloaded' && f.value === true)} + onChange={(e) => { + if (e.target.checked) { + const newFilters = [...columnFilters.filter(f => f.id !== 'isDownloaded'), { id: 'isDownloaded', value: true }] + setColumnFilters(newFilters) + } else { + const newFilters = columnFilters.filter(f => f.id !== 'isDownloaded') + setColumnFilters(newFilters) + } + }} + /> + +
setGlobalFilter(String(e.target.value))} diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 8daf9a6..4642988 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -30,6 +30,7 @@ import { import { useSettings } from '../hooks/useSettings' import { useGames } from '../hooks/useGames' import { useLogs } from '../hooks/useLogs' +import { useDownload } from '../hooks/useDownload' // Supported speed units with conversion factors to KB/s const SPEED_UNITS = [ @@ -79,7 +80,8 @@ const useStyles = makeStyles({ marginTop: tokens.spacingVerticalM, gap: tokens.spacingHorizontalM, width: '100%', - maxWidth: '800px' + maxWidth: '800px', + flexWrap: 'wrap' }, input: { flexGrow: 1 @@ -408,6 +410,7 @@ const Settings: React.FC = () => { const [localError, setLocalError] = useState(null) const [saveSuccess, setSaveSuccess] = useState(false) + const [refreshResults, setRefreshResults] = useState<{ added: number; updated: number; total: number } | null>(null) // Update local state when the context values change useEffect(() => { @@ -530,6 +533,31 @@ const Settings: React.FC = () => { } } + const handleRefreshLocalStorage = async (): Promise => { + try { + setLocalError(null) + setSaveSuccess(false) + + // Trigger a refresh of the download service to scan for existing completed downloads + // This will update the download queue with any local files found + const result = await window.api.downloads.refreshLocalStorage() + + // Show success message with details + const message = `Local storage refreshed successfully! Found ${result.added} new items and updated ${result.updated} existing items. Total: ${result.total} items.` + setSaveSuccess(true) + setRefreshResults(result) + + // Reset success message after 5 seconds (longer since there's more info) + setTimeout(() => { + setSaveSuccess(false) + setRefreshResults(null) + }, 5000) + } catch (err) { + console.error('Error refreshing local storage:', err) + setLocalError('Failed to refresh local storage') + } + } + // Handle unit conversion when dropdown changes const handleDownloadUnitChange = (newUnit: string): void => { if (!downloadSpeedInput.trim()) { @@ -708,6 +736,9 @@ const Settings: React.FC = () => { +
@@ -800,6 +831,12 @@ const Settings: React.FC = () => { Settings saved successfully )} + {refreshResults && ( + + + Local storage refreshed successfully! Found {refreshResults.added} new items and updated {refreshResults.updated} existing items. Total: {refreshResults.total} items. + + )}
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 6fed346..7832388 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -263,6 +263,7 @@ export interface DownloadAPI { deleteDownloadedFiles: (releaseName: string) => Promise setDownloadPath: (path: string) => void setAppConnectionState: (selectedDevice: string | null, isConnected: boolean) => void + refreshLocalStorage: () => Promise<{ added: number; updated: number; total: number }> } export interface DownloadAPIRenderer extends DownloadAPI { diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index b71888a..0aebc2e 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -124,6 +124,7 @@ export interface IPCChannels { // Manual installation channels 'downloads:install-manual': DefineChannel<[filePath: string, deviceId: string], boolean> 'downloads:copy-obb-folder': DefineChannel<[folderPath: string, deviceId: string], boolean> + 'downloads:refresh-local-storage': DefineChannel<[], { added: number; updated: number; total: number }> } // Types for send (no response) channels From faaa100928a3268314c67f99ae168595bd0246e8 Mon Sep 17 00:00:00 2001 From: Gabisonfire Date: Tue, 26 Aug 2025 12:59:55 -0400 Subject: [PATCH 2/3] Adds removed handler --- src/main/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index f68df87..02ccb5c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -328,6 +328,10 @@ app.whenReady().then(async () => { await downloadService.removeFromQueue(releaseName) }) + typedIpcMain.on('download:cancel', (_event, releaseName) => + downloadService.cancelUserRequest(releaseName) + ) + typedIpcMain.on('download:retry', (_event, releaseName) => downloadService.retryDownload(releaseName) ) From 043df042f6ba0eabf86d77522c00539fbfbb57c5 Mon Sep 17 00:00:00 2001 From: Gabisonfire Date: Mon, 1 Sep 2025 19:05:04 -0400 Subject: [PATCH 3/3] Fixes column sorting not working --- src/renderer/src/components/GamesView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/GamesView.tsx b/src/renderer/src/components/GamesView.tsx index bab7b00..7ff7323 100644 --- a/src/renderer/src/components/GamesView.tsx +++ b/src/renderer/src/components/GamesView.tsx @@ -754,7 +754,7 @@ const GamesView: React.FC = ({ onBackToDevices }) => { - const { rows } = table.getFilteredRowModel() + const { rows } = table.getSortedRowModel() const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableContainerRef.current,