Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,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) {
Expand Down
171 changes: 168 additions & 3 deletions src/main/services/downloadService.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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 {
Expand Down Expand Up @@ -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' ||
Expand Down Expand Up @@ -474,7 +523,7 @@ class DownloadService extends EventEmitter implements DownloadAPI {
return Promise.resolve()
}

public retryDownload(releaseName: string): Promise<void> {
public retryDownload(releaseName: string): void {
const item = this.queueManager.findItem(releaseName)
if (
item &&
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void> {
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<string, DownloadItem>()
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<string, GameInfo>()
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()
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ const api = {
typedIpcRenderer.invoke('downloads:install-manual', filePath, deviceId),
copyObbFolder: (folderPath: string, deviceId: string): Promise<boolean> =>
typedIpcRenderer.invoke('downloads:copy-obb-folder', folderPath, deviceId),
refreshLocalStorage: (): Promise<void> =>
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)
Expand Down
18 changes: 17 additions & 1 deletion src/renderer/src/components/GameDetailsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -187,6 +189,12 @@ const GameDetailsDialog: React.FC<GameDetailsDialogProps> = ({
const [loadingNote, setLoadingNote] = useState<boolean>(false)
const [videoId, setVideoId] = useState<string | null>(null)
const [loadingVideo, setLoadingVideo] = useState<boolean>(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(() => {
Expand Down Expand Up @@ -483,6 +491,14 @@ const GameDetailsDialog: React.FC<GameDetailsDialogProps> = ({
<DocumentDataRegular fontSize={16} />
<Text size={300}>{game.size || '-'}</Text>
</div>
{localPath && (
<div className={styles.inlineInfo}>
<DesktopRegular fontSize={16} />
<Text size={300} title={localPath} style={{ fontFamily: 'monospace' }}>
{localPath}
</Text>
</div>
)}
<div className={styles.inlineInfo}>
<DownloadIcon fontSize={16} />
<Text size={300}>{game.downloads?.toLocaleString() || '-'}</Text>
Expand Down
Loading