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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!

---
Expand Down
27 changes: 27 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
151 changes: 143 additions & 8 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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()
Expand All @@ -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<void> } | 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<void> }> => {
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<void>((closeResolve) => {
server.close(() => closeResolve())
})
})
})
})
}

// Listener for download service events to forward to renderer
downloadService.on('installation:success', (deviceId) => {
Expand All @@ -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,
Expand All @@ -52,11 +165,11 @@ function sendDependencyProgress(
}
}

function createWindow(): void {
async function createWindow(): Promise<void> {
// Create the browser window.
mainWindow = new BrowserWindow({
width: 1200,
minWidth: 1200,
width: 1250,
minWidth: 1250,
height: 900,
show: false,
autoHideMenuBar: true,
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
)
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/main/services/adbService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServiceStatus> {
if (this.status === 'INITIALIZING') {
console.warn('AdbService is already initializing, skipping.')
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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'}`
)
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/main/services/dependencyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down
Loading