Skip to content
Closed
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
38 changes: 38 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# v1.3.8

- Improve direct-download reliability with stronger rclone retry/timeout settings and safer transfer concurrency.
- Apply configured download speed limits to direct rclone downloads as bandwidth limits.
- Add adaptive stall detection windows for public downloads to reduce false-positive stall failures.
- Fix retry behavior for `InstallError` items to reuse existing local payloads instead of forcing re-download.
- Improve update fallback flow when completed queue entries are missing by automatically re-queuing download.
- Add `Download Only` action for installed titles from the game details dialog.
- Add FUSE diagnostics in Settings with status check, installer shortcut, and removal guidance shortcut.
- Add startup FUSE warning dialog with remediation options when FUSE is unavailable.

# 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
}
}
})
3 changes: 2 additions & 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.8",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
Expand All @@ -20,6 +20,7 @@
"build:win:x64": "electron-vite build && electron-builder --win --x64",
"build:win:ia32": "electron-vite build && electron-builder --win --ia32",
"build:mac": "electron-vite build && electron-builder --mac",
"build:mac:unsigned": "CSC_IDENTITY_AUTO_DISCOVERY=false electron-vite build && electron-builder --mac",
"build:mac:x64": "electron-vite build && electron-builder --mac --x64",
"build:mac:arm64": "electron-vite build && electron-builder --mac --arm64",
"build:mac:universal": "electron-vite build && electron-builder --mac --universal",
Expand Down
209 changes: 191 additions & 18 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,14 @@ 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 fuseService from './services/fuseService'
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 +34,111 @@ 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
let hasShownFuseMissingDialog = false

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 +150,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 +167,43 @@ function sendDependencyProgress(
}
}

function createWindow(): void {
async function showFuseMissingDialogIfNeeded(): Promise<void> {
if (hasShownFuseMissingDialog) return
if (!mainWindow || mainWindow.isDestroyed()) return

try {
const fuseStatus = await fuseService.getStatus()
if (!fuseStatus.supported || fuseStatus.available) return

hasShownFuseMissingDialog = true
const installButton = 'Install FUSE'
const continueButton = 'Continue Without FUSE'

const result = await dialog.showMessageBox(mainWindow, {
type: 'warning',
title: 'FUSE Not Installed',
message: 'Mount-based downloads are unavailable because FUSE is not installed.',
detail:
'What this means:\n- With FUSE: mount-based downloads are available and generally more resilient.\n- Without FUSE: the app uses direct download fallback, which can be slower and more likely to stall on unstable links.\n\nHow to fix:\nClick "Install FUSE", complete installation, then restart Apprentice VR.',
buttons: [installButton, continueButton],
defaultId: 0,
cancelId: 1,
noLink: true
})

if (result.response === 0) {
await fuseService.openInstaller()
}
} catch (error) {
console.warn('[Main] Failed to show FUSE guidance dialog:', error)
}
}

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 +217,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,8 +263,14 @@ 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')

await showFuseMissingDialogIfNeeded()

// Initialize Update Service
if (mainWindow) {
updateService.initialize()
Expand Down Expand Up @@ -172,7 +325,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,23 +424,23 @@ 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)
)
typedIpcMain.handle('download:install-from-completed', (_event, releaseName, deviceId) => {
typedIpcMain.handle(
'download:install-from-completed',
async (_event, releaseName, deviceId) => {
console.log(
`[IPC] Received request to install from completed: ${releaseName} on device ${deviceId}`
)
// No return value needed, fire-and-forget, status updated via queue listener
downloadService.installFromCompleted(releaseName, deviceId).catch((err) => {
// Log error here as the renderer won't get a rejection for this invoke
console.error(
`[IPC Handler Error] installFromCompleted failed for ${releaseName} on ${deviceId}:`,
err
)
})
})
await downloadService.installFromCompleted(releaseName, deviceId)
}
)
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 @@ -416,6 +573,12 @@ app.whenReady().then(async () => {
typedIpcMain.handle('settings:set-color-scheme', (_event, scheme) =>
settingsService.setColorScheme(scheme)
)
typedIpcMain.handle('settings:get-fuse-status', async () => await fuseService.getStatus())
typedIpcMain.handle('settings:open-fuse-installer', async () => await fuseService.openInstaller())
typedIpcMain.handle(
'settings:open-fuse-removal-guide',
async () => await fuseService.openRemovalGuide()
)

// --- Logs Handlers ---
typedIpcMain.handle('logs:upload-current', async () => {
Expand Down Expand Up @@ -613,6 +776,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 +786,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 +808,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
Loading