Skip to content

Commit fa7e09b

Browse files
committed
feat: implement manual installation functionality for APKs and folders
- Added IPC handlers for showing file and folder pickers to facilitate manual installations. - Implemented the installManualFile method in the DownloadService to handle APK and folder installations, including validation checks for device connectivity and file existence. - Enhanced the GamesView component with a menu for manual installation options, providing user feedback during the installation process. - Introduced a dialog to display installation progress and success/failure messages, improving user experience during manual installations.
1 parent 97a3a7b commit fa7e09b

11 files changed

Lines changed: 393 additions & 20 deletions

File tree

AUR/PKGBUILD

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Maintainer: jimzrt <jimzrt1@gmail.com>
22

33
pkgname=apprenticevr-bin
4-
pkgver=1.3.1
4+
pkgver=1.3.2
55
pkgrel=1
66
pkgdesc="A modern, cross-platform desktop application designed for managing and sideloading content onto Meta Quest devices"
77
arch=('x86_64' 'aarch64')

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "apprenticevr",
3-
"version": "1.3.1",
3+
"version": "1.3.2",
44
"description": "An Electron application with React and TypeScript",
55
"main": "./out/main/index.js",
66
"author": "example.com",

src/main/index.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,65 @@ app.whenReady().then(async () => {
539539
return filePaths[0]
540540
})
541541

542+
// Manual installation handlers
543+
typedIpcMain.handle('dialog:show-manual-install-picker', async () => {
544+
if (!mainWindow) return null
545+
546+
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
547+
properties: ['openFile', 'openDirectory'],
548+
title: 'Select APK file or folder to install',
549+
filters: [
550+
{ name: 'APK Files', extensions: ['apk'] },
551+
{ name: 'All Files', extensions: ['*'] }
552+
]
553+
})
554+
555+
if (canceled || filePaths.length === 0) {
556+
return null
557+
}
558+
559+
return filePaths[0]
560+
})
561+
562+
typedIpcMain.handle('dialog:show-apk-file-picker', async () => {
563+
if (!mainWindow) return null
564+
565+
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
566+
properties: ['openFile'],
567+
title: 'Select APK file to install',
568+
filters: [
569+
{ name: 'APK Files', extensions: ['apk'] },
570+
{ name: 'All Files', extensions: ['*'] }
571+
]
572+
})
573+
574+
if (canceled || filePaths.length === 0) {
575+
return null
576+
}
577+
578+
return filePaths[0]
579+
})
580+
581+
typedIpcMain.handle('dialog:show-folder-picker', async () => {
582+
if (!mainWindow) return null
583+
584+
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
585+
properties: ['openDirectory'],
586+
title: 'Select folder to install'
587+
})
588+
589+
if (canceled || filePaths.length === 0) {
590+
return null
591+
}
592+
593+
return filePaths[0]
594+
})
595+
596+
typedIpcMain.handle('downloads:install-manual', async (_event, filePath, deviceId) => {
597+
console.log(`[IPC] Manual install requested for ${filePath} on device ${deviceId}`)
598+
return await downloadService.installManualFile(filePath, deviceId)
599+
})
600+
542601
// Validate that all IPC channels have handlers registered
543602
const allHandled = typedIpcMain.validateAllHandlersRegistered()
544603
if (!allHandled) {

src/main/services/downloadService.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,119 @@ class DownloadService extends EventEmitter implements DownloadAPI {
666666
throw error // Re-throw so the IPC handler logs it
667667
}
668668
}
669+
670+
public async installManualFile(filePath: string, deviceId: string): Promise<boolean> {
671+
console.log(`[Service] Manual install requested for ${filePath} on device ${deviceId}`)
672+
673+
// Check if the app is connected to the target device
674+
const targetDeviceForInstall = this.getTargetDeviceForInstallation()
675+
if (!targetDeviceForInstall) {
676+
console.error(
677+
`[Service installManualFile] App is not connected to any device. Cannot install ${filePath}.`
678+
)
679+
return false
680+
}
681+
682+
if (targetDeviceForInstall !== deviceId) {
683+
console.error(
684+
`[Service installManualFile] App is connected to ${targetDeviceForInstall} but installation requested for ${deviceId}.`
685+
)
686+
return false
687+
}
688+
689+
// Check if the target device is still connected and authorized at the ADB level
690+
try {
691+
const devices = await this.adbService.listDevices()
692+
const targetDevice = devices.find((d) => d.id === deviceId && d.type === 'device')
693+
if (!targetDevice) {
694+
console.error(
695+
`[Service installManualFile] Target device ${deviceId} not found or not authorized at ADB level.`
696+
)
697+
return false
698+
}
699+
} catch (err) {
700+
console.error(`[Service installManualFile] Error verifying target device ${deviceId}:`, err)
701+
return false
702+
}
703+
704+
// Check if the file/folder exists
705+
if (!existsSync(filePath)) {
706+
console.error(`[Service installManualFile] File/folder not found: ${filePath}`)
707+
return false
708+
}
709+
710+
try {
711+
const stats = await fs.stat(filePath)
712+
713+
if (stats.isFile() && filePath.toLowerCase().endsWith('.apk')) {
714+
// Single APK file installation
715+
console.log(`[Service installManualFile] Installing single APK: ${filePath}`)
716+
const success = await this.adbService.installPackage(deviceId, filePath, {
717+
flags: ['-r', '-g']
718+
})
719+
if (success) {
720+
console.log(`[Service installManualFile] Successfully installed APK: ${filePath}`)
721+
this.emit('installation:success', deviceId)
722+
}
723+
return success
724+
} else if (stats.isDirectory()) {
725+
// Folder installation - create a temporary DownloadItem to use existing installation logic
726+
console.log(`[Service installManualFile] Installing folder: ${filePath}`)
727+
728+
// Generate a unique identifier for this manual installation
729+
const manualId = `manual-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
730+
731+
// Try to extract package name from folder structure if possible
732+
let packageName = ''
733+
try {
734+
const folderContents = await fs.readdir(filePath)
735+
const apkFiles = folderContents.filter((f) => f.toLowerCase().endsWith('.apk'))
736+
if (apkFiles.length > 0) {
737+
// Look for potential package directory (common pattern in extracted games)
738+
const potentialPackageDirs = folderContents.filter((item) => {
739+
// Common package name patterns
740+
return item.includes('.') && !item.includes(' ') && item.length > 5
741+
})
742+
if (potentialPackageDirs.length === 1) {
743+
packageName = potentialPackageDirs[0]
744+
}
745+
}
746+
} catch (error) {
747+
console.log(`[Service installManualFile] Could not analyze folder structure: ${error}`)
748+
}
749+
750+
// Create a temporary DownloadItem
751+
const tempItem: DownloadItem = {
752+
gameId: manualId,
753+
releaseName: manualId,
754+
packageName: packageName,
755+
gameName: `Manual Install: ${filePath.split(/[/\\]/).pop()}`,
756+
status: 'Completed',
757+
progress: 100,
758+
extractProgress: 100,
759+
addedDate: Date.now(),
760+
downloadPath: filePath
761+
}
762+
763+
// Use the installation processor to handle the folder
764+
const success = await this.installationProcessor.startInstallation(tempItem, deviceId)
765+
if (success) {
766+
console.log(`[Service installManualFile] Successfully installed folder: ${filePath}`)
767+
this.emit('installation:success', deviceId)
768+
}
769+
return success
770+
} else {
771+
console.error(`[Service installManualFile] Unsupported file type: ${filePath}`)
772+
return false
773+
}
774+
} catch (error) {
775+
console.error(
776+
`[Service installManualFile] Error during manual installation of ${filePath}:`,
777+
error
778+
)
779+
return false
780+
}
781+
}
669782
}
670783

671784
export default new DownloadService()

src/preload/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ const api = {
114114
typedIpcRenderer.invoke('download:delete-files', releaseName),
115115
installFromCompleted: (releaseName: string, deviceId: string): Promise<void> =>
116116
typedIpcRenderer.invoke('download:install-from-completed', releaseName, deviceId),
117+
installManualFile: (filePath: string, deviceId: string): Promise<boolean> =>
118+
typedIpcRenderer.invoke('downloads:install-manual', filePath, deviceId),
117119
onQueueUpdated: (callback: (queue: DownloadItem[]) => void): (() => void) => {
118120
const listener = (_: IpcRendererEvent, queue: DownloadItem[]): void => callback(queue)
119121
typedIpcRenderer.on('download:queue-updated', listener)
@@ -236,7 +238,13 @@ const api = {
236238
typedIpcRenderer.invoke('dialog:show-directory-picker'),
237239
showFilePicker: (options?: {
238240
filters?: { name: string; extensions: string[] }[]
239-
}): Promise<string | null> => typedIpcRenderer.invoke('dialog:show-file-picker', options)
241+
}): Promise<string | null> => typedIpcRenderer.invoke('dialog:show-file-picker', options),
242+
showManualInstallPicker: (): Promise<string | null> =>
243+
typedIpcRenderer.invoke('dialog:show-manual-install-picker'),
244+
showApkFilePicker: (): Promise<string | null> =>
245+
typedIpcRenderer.invoke('dialog:show-apk-file-picker'),
246+
showFolderPicker: (): Promise<string | null> =>
247+
typedIpcRenderer.invoke('dialog:show-folder-picker')
240248
},
241249
// WiFi bookmarks API
242250
wifiBookmarks: {

src/renderer/src/assets/games-view.css

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@
4141
display: flex;
4242
justify-content: space-between;
4343
align-items: center;
44-
margin-bottom: 16px;
45-
padding-bottom: 12px;
44+
margin-bottom: 8px;
45+
padding-bottom: 8px;
4646
border-bottom: 1px solid #e0e0e0;
4747
flex-shrink: 0; /* Prevent toolbar from shrinking */
4848
}
@@ -153,12 +153,14 @@
153153
display: flex;
154154
align-items: center;
155155
gap: 16px;
156+
/* move to the right */
157+
margin-left: auto;
156158
}
157159

158160
.table-wrapper {
159161
overflow-y: auto; /* Make table body scrollable */
160162
flex-grow: 1;
161-
margin-top: 16px;
163+
margin-top: 8px;
162164
border: 1px solid var(--colorNeutralForeground2);
163165
border-radius: 4px;
164166
position: relative; /* Added: Needed for absolute positioning of children like popover */

src/renderer/src/components/AppLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const useStyles = makeStyles({
6262
header: {
6363
display: 'flex',
6464
alignItems: 'center',
65-
padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalL}`,
65+
padding: `${tokens.spacingVerticalNone} ${tokens.spacingHorizontalL}`,
6666
borderBottom: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`,
6767
backgroundColor: tokens.colorNeutralBackground3,
6868
gap: tokens.spacingHorizontalM,

0 commit comments

Comments
 (0)