mirror of
https://github.com/Myxelium/Bridge-Multi.git
synced 2026-04-11 14:19:38 +00:00
Restructure
This commit is contained in:
42
src-electron/ipc/CacheHandler.ipc.ts
Normal file
42
src-electron/ipc/CacheHandler.ipc.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Dirent, readdir as _readdir } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { rimraf } from 'rimraf'
|
||||
import { inspect, promisify } from 'util'
|
||||
|
||||
import { devLog } from '../shared/ElectronUtilFunctions'
|
||||
import { IPCInvokeHandler } from '../shared/IPCHandler'
|
||||
import { tempPath } from '../shared/Paths'
|
||||
|
||||
const readdir = promisify(_readdir)
|
||||
|
||||
/**
|
||||
* Handles the 'clear-cache' event.
|
||||
*/
|
||||
class ClearCacheHandler implements IPCInvokeHandler<'clear-cache'> {
|
||||
event = 'clear-cache' as const
|
||||
|
||||
/**
|
||||
* Deletes all the files under `tempPath`
|
||||
*/
|
||||
async handler() {
|
||||
let files: Dirent[]
|
||||
try {
|
||||
files = await readdir(tempPath, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
devLog('Failed to read cache: ', err)
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
devLog(`Deleting ${file.isFile() ? 'file' : 'folder'}: ${join(tempPath, file.name)}`)
|
||||
await rimraf(join(tempPath, file.name))
|
||||
} catch (err) {
|
||||
devLog(`Failed to delete ${file.isFile() ? 'file' : 'folder'}: `, inspect(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const clearCacheHandler = new ClearCacheHandler()
|
||||
19
src-electron/ipc/OpenURLHandler.ipc.ts
Normal file
19
src-electron/ipc/OpenURLHandler.ipc.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { shell } from 'electron'
|
||||
|
||||
import { IPCEmitHandler } from '../shared/IPCHandler'
|
||||
|
||||
/**
|
||||
* Handles the 'open-url' event.
|
||||
*/
|
||||
class OpenURLHandler implements IPCEmitHandler<'open-url'> {
|
||||
event = 'open-url' as const
|
||||
|
||||
/**
|
||||
* Opens `url` in the default browser.
|
||||
*/
|
||||
handler(url: string) {
|
||||
shell.openExternal(url)
|
||||
}
|
||||
}
|
||||
|
||||
export const openURLHandler = new OpenURLHandler()
|
||||
94
src-electron/ipc/SettingsHandler.ipc.ts
Normal file
94
src-electron/ipc/SettingsHandler.ipc.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as fs from 'fs'
|
||||
import { promisify } from 'util'
|
||||
|
||||
import { IPCEmitHandler, IPCInvokeHandler } from '../shared/IPCHandler'
|
||||
import { dataPath, settingsPath, tempPath, themesPath } from '../shared/Paths'
|
||||
import { defaultSettings, Settings } from '../shared/Settings'
|
||||
|
||||
const exists = promisify(fs.exists)
|
||||
const mkdir = promisify(fs.mkdir)
|
||||
const readFile = promisify(fs.readFile)
|
||||
const writeFile = promisify(fs.writeFile)
|
||||
|
||||
let settings: Settings
|
||||
|
||||
/**
|
||||
* Handles the 'set-settings' event.
|
||||
*/
|
||||
class SetSettingsHandler implements IPCEmitHandler<'set-settings'> {
|
||||
event = 'set-settings' as const
|
||||
|
||||
/**
|
||||
* Updates Bridge's settings object to `newSettings` and saves them to Bridge's data directories.
|
||||
*/
|
||||
handler(newSettings: Settings) {
|
||||
settings = newSettings
|
||||
SetSettingsHandler.saveSettings(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves `settings` to Bridge's data directories.
|
||||
*/
|
||||
static async saveSettings(settings: Settings) {
|
||||
const settingsJSON = JSON.stringify(settings, undefined, 2)
|
||||
await writeFile(settingsPath, settingsJSON, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the 'get-settings' event.
|
||||
*/
|
||||
class GetSettingsHandler implements IPCInvokeHandler<'get-settings'> {
|
||||
event = 'get-settings' as const
|
||||
|
||||
/**
|
||||
* @returns the current settings oject, or default settings if they couldn't be loaded.
|
||||
*/
|
||||
handler() {
|
||||
return this.getSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the current settings oject, or default settings if they couldn't be loaded.
|
||||
*/
|
||||
getSettings() {
|
||||
if (settings == undefined) {
|
||||
return defaultSettings
|
||||
} else {
|
||||
return settings
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If data directories don't exist, creates them and saves the default settings.
|
||||
* Otherwise, loads user settings from data directories.
|
||||
* If this process fails, default settings are used.
|
||||
*/
|
||||
async initSettings() {
|
||||
try {
|
||||
// Create data directories if they don't exists
|
||||
for (const path of [dataPath, tempPath, themesPath]) {
|
||||
if (!await exists(path)) {
|
||||
await mkdir(path)
|
||||
}
|
||||
}
|
||||
|
||||
// Read/create settings
|
||||
if (await exists(settingsPath)) {
|
||||
settings = JSON.parse(await readFile(settingsPath, 'utf8'))
|
||||
settings = Object.assign(JSON.parse(JSON.stringify(defaultSettings)), settings)
|
||||
} else {
|
||||
await SetSettingsHandler.saveSettings(defaultSettings)
|
||||
settings = defaultSettings
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize settings! Default settings will be used.')
|
||||
console.error(e)
|
||||
settings = defaultSettings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getSettingsHandler = new GetSettingsHandler()
|
||||
export const setSettingsHandler = new SetSettingsHandler()
|
||||
export function getSettings() { return getSettingsHandler.getSettings() }
|
||||
135
src-electron/ipc/UpdateHandler.ipc.ts
Normal file
135
src-electron/ipc/UpdateHandler.ipc.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { autoUpdater, UpdateInfo } from 'electron-updater'
|
||||
|
||||
import { emitIPCEvent } from '../main'
|
||||
import { IPCEmitHandler, IPCInvokeHandler } from '../shared/IPCHandler'
|
||||
|
||||
export interface UpdateProgress {
|
||||
bytesPerSecond: number
|
||||
percent: number
|
||||
transferred: number
|
||||
total: number
|
||||
}
|
||||
|
||||
let updateAvailable = false
|
||||
|
||||
/**
|
||||
* Checks for updates when the program is launched.
|
||||
*/
|
||||
class UpdateChecker implements IPCEmitHandler<'retry-update'> {
|
||||
event = 'retry-update' as const
|
||||
|
||||
constructor() {
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.logger = null
|
||||
this.registerUpdaterListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for an update.
|
||||
*/
|
||||
handler() {
|
||||
this.checkForUpdates()
|
||||
}
|
||||
|
||||
checkForUpdates() {
|
||||
autoUpdater.checkForUpdates().catch(reason => {
|
||||
updateAvailable = null
|
||||
emitIPCEvent('update-error', reason)
|
||||
})
|
||||
}
|
||||
|
||||
private registerUpdaterListeners() {
|
||||
autoUpdater.on('error', (err: Error) => {
|
||||
updateAvailable = null
|
||||
emitIPCEvent('update-error', err)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info: UpdateInfo) => {
|
||||
updateAvailable = true
|
||||
emitIPCEvent('update-available', info)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
|
||||
updateAvailable = false
|
||||
emitIPCEvent('update-available', null)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const updateChecker = new UpdateChecker()
|
||||
|
||||
/**
|
||||
* Handles the 'get-update-available' event.
|
||||
*/
|
||||
class GetUpdateAvailableHandler implements IPCInvokeHandler<'get-update-available'> {
|
||||
event = 'get-update-available' as const
|
||||
|
||||
/**
|
||||
* @returns `true` if an update is available.
|
||||
*/
|
||||
handler() {
|
||||
return updateAvailable
|
||||
}
|
||||
}
|
||||
|
||||
export const getUpdateAvailableHandler = new GetUpdateAvailableHandler()
|
||||
|
||||
/**
|
||||
* Handles the 'get-current-version' event.
|
||||
*/
|
||||
class GetCurrentVersionHandler implements IPCInvokeHandler<'get-current-version'> {
|
||||
event = 'get-current-version' as const
|
||||
|
||||
/**
|
||||
* @returns the current version of Bridge.
|
||||
*/
|
||||
handler() {
|
||||
return autoUpdater.currentVersion.raw
|
||||
}
|
||||
}
|
||||
|
||||
export const getCurrentVersionHandler = new GetCurrentVersionHandler()
|
||||
|
||||
/**
|
||||
* Handles the 'download-update' event.
|
||||
*/
|
||||
class DownloadUpdateHandler implements IPCEmitHandler<'download-update'> {
|
||||
event = 'download-update' as const
|
||||
downloading = false
|
||||
|
||||
/**
|
||||
* Begins the process of downloading the latest update.
|
||||
*/
|
||||
handler() {
|
||||
if (this.downloading) { return }
|
||||
this.downloading = true
|
||||
|
||||
autoUpdater.on('download-progress', (updateProgress: UpdateProgress) => {
|
||||
emitIPCEvent('update-progress', updateProgress)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
emitIPCEvent('update-downloaded', undefined)
|
||||
})
|
||||
|
||||
autoUpdater.downloadUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadUpdateHandler = new DownloadUpdateHandler()
|
||||
|
||||
/**
|
||||
* Handles the 'quit-and-install' event.
|
||||
*/
|
||||
class QuitAndInstallHandler implements IPCEmitHandler<'quit-and-install'> {
|
||||
event = 'quit-and-install' as const
|
||||
|
||||
/**
|
||||
* Immediately closes the application and installs the update.
|
||||
*/
|
||||
handler() {
|
||||
autoUpdater.quitAndInstall() // autoUpdater installs a downloaded update on the next program restart by default
|
||||
}
|
||||
}
|
||||
|
||||
export const quitAndInstallHandler = new QuitAndInstallHandler()
|
||||
20
src-electron/ipc/browse/AlbumArtHandler.ipc.ts
Normal file
20
src-electron/ipc/browse/AlbumArtHandler.ipc.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { AlbumArtResult } from '../../shared/interfaces/songDetails.interface'
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
* Handles the 'album-art' event.
|
||||
*/
|
||||
class AlbumArtHandler implements IPCInvokeHandler<'album-art'> {
|
||||
event = 'album-art' as const
|
||||
|
||||
/**
|
||||
* @returns an `AlbumArtResult` object containing the album art for the song with `songID`.
|
||||
*/
|
||||
async handler(songID: number): Promise<AlbumArtResult> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/album-art/${songID}`)
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export const albumArtHandler = new AlbumArtHandler()
|
||||
20
src-electron/ipc/browse/BatchSongDetailsHandler.ipc.ts
Normal file
20
src-electron/ipc/browse/BatchSongDetailsHandler.ipc.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { VersionResult } from '../../shared/interfaces/songDetails.interface'
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
* Handles the 'batch-song-details' event.
|
||||
*/
|
||||
class BatchSongDetailsHandler implements IPCInvokeHandler<'batch-song-details'> {
|
||||
event = 'batch-song-details' as const
|
||||
|
||||
/**
|
||||
* @returns an array of all the chart versions with a songID found in `songIDs`.
|
||||
*/
|
||||
async handler(songIDs: number[]): Promise<VersionResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songIDs.join(',')}`)
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export const batchSongDetailsHandler = new BatchSongDetailsHandler()
|
||||
28
src-electron/ipc/browse/SearchHandler.ipc.ts
Normal file
28
src-electron/ipc/browse/SearchHandler.ipc.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { SongResult, SongSearch } from '../../shared/interfaces/search.interface'
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
* Handles the 'song-search' event.
|
||||
*/
|
||||
class SearchHandler implements IPCInvokeHandler<'song-search'> {
|
||||
event = 'song-search' as const
|
||||
|
||||
/**
|
||||
* @returns the top 50 songs that match `search`.
|
||||
*/
|
||||
async handler(search: SongSearch): Promise<SongResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(search),
|
||||
})
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export const searchHandler = new SearchHandler()
|
||||
20
src-electron/ipc/browse/SongDetailsHandler.ipc.ts
Normal file
20
src-electron/ipc/browse/SongDetailsHandler.ipc.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { VersionResult } from '../../shared/interfaces/songDetails.interface'
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
* Handles the 'song-details' event.
|
||||
*/
|
||||
class SongDetailsHandler implements IPCInvokeHandler<'song-details'> {
|
||||
event = 'song-details' as const
|
||||
|
||||
/**
|
||||
* @returns the chart versions with `songID`.
|
||||
*/
|
||||
async handler(songID: number): Promise<VersionResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songID}`)
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export const songDetailsHandler = new SongDetailsHandler()
|
||||
283
src-electron/ipc/download/ChartDownload.ts
Normal file
283
src-electron/ipc/download/ChartDownload.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { parse } from 'path'
|
||||
import { rimraf } from 'rimraf'
|
||||
|
||||
import { NewDownload, ProgressType } from 'src/electron/shared/interfaces/download.interface'
|
||||
import { DriveFile } from 'src/electron/shared/interfaces/songDetails.interface'
|
||||
import { emitIPCEvent } from '../../main'
|
||||
import { hasVideoExtension } from '../../shared/ElectronUtilFunctions'
|
||||
import { sanitizeFilename } from '../../shared/UtilFunctions'
|
||||
import { getSettings } from '../SettingsHandler.ipc'
|
||||
// import { FileDownloader, getDownloader } from './FileDownloader'
|
||||
import { FilesystemChecker } from './FilesystemChecker'
|
||||
import { FileTransfer } from './FileTransfer'
|
||||
|
||||
interface EventCallback {
|
||||
/** Note: this will not be the last event if `retry()` is called. */
|
||||
'error': () => void
|
||||
'complete': () => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
export interface DownloadError { header: string; body: string; isLink?: boolean }
|
||||
|
||||
export class ChartDownload {
|
||||
|
||||
private retryFn: () => void | Promise<void>
|
||||
private cancelFn: () => void
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private files: DriveFile[]
|
||||
private percent = 0 // Needs to be stored here because errors won't know the exact percent
|
||||
private tempPath: string
|
||||
private wasCanceled = false
|
||||
|
||||
private readonly individualFileProgressPortion: number
|
||||
private readonly destinationFolderName: string
|
||||
|
||||
private _allFilesProgress = 0
|
||||
get allFilesProgress() { return this._allFilesProgress }
|
||||
private _hasFailed = false
|
||||
/** If this chart download needs to be retried */
|
||||
get hasFailed() { return this._hasFailed }
|
||||
get isArchive() { return this.data.driveData.isArchive }
|
||||
get hash() { return this.data.driveData.filesHash }
|
||||
|
||||
constructor(public versionID: number, private data: NewDownload) {
|
||||
this.updateGUI('', 'Waiting for other downloads to finish...', 'good')
|
||||
this.files = this.filterDownloadFiles(data.driveData.files)
|
||||
this.individualFileProgressPortion = 80 / this.files.length
|
||||
if (data.driveData.inChartPack) {
|
||||
this.destinationFolderName = sanitizeFilename(parse(data.driveData.files[0].name).name)
|
||||
} else {
|
||||
this.destinationFolderName = sanitizeFilename(`${this.data.artist} - ${this.data.chartName} (${this.data.charter})`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `callback` when `event` fires. (no events will be fired after `this.cancel()` is called)
|
||||
*/
|
||||
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
filterDownloadFiles(files: DriveFile[]) {
|
||||
return files.filter(file => {
|
||||
return (file.name != 'ch.dat') && (getSettings().downloadVideos || !hasVideoExtension(file.name))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries the last failed step if it is running.
|
||||
*/
|
||||
retry() { // Only allow it to be called once
|
||||
if (this.retryFn != undefined) {
|
||||
this._hasFailed = false
|
||||
const retryFn = this.retryFn
|
||||
this.retryFn = undefined
|
||||
retryFn()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the GUI to indicate that a retry will be attempted.
|
||||
*/
|
||||
displayRetrying() {
|
||||
this.updateGUI('', 'Waiting for other downloads to finish to retry...', 'good')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the download if it is running.
|
||||
*/
|
||||
cancel() { // Only allow it to be called once
|
||||
if (this.cancelFn != undefined) {
|
||||
const cancelFn = this.cancelFn
|
||||
this.cancelFn = undefined
|
||||
cancelFn()
|
||||
rimraf(this.tempPath).catch(() => { /** Do nothing */ }) // Delete temp folder
|
||||
}
|
||||
this.updateGUI('', '', 'cancel')
|
||||
this.wasCanceled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the GUI with new information about this chart download.
|
||||
*/
|
||||
private updateGUI(header: string, description: string, type: ProgressType, isLink = false) {
|
||||
if (this.wasCanceled) { return }
|
||||
|
||||
emitIPCEvent('download-updated', {
|
||||
versionID: this.versionID,
|
||||
title: `${this.data.chartName} - ${this.data.artist}`,
|
||||
header: header,
|
||||
description: description,
|
||||
percent: this.percent,
|
||||
type: type,
|
||||
isLink,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the retry function, update the GUI, and call the `error` callback.
|
||||
*/
|
||||
private handleError(err: DownloadError, retry: () => void) {
|
||||
this._hasFailed = true
|
||||
this.retryFn = retry
|
||||
this.updateGUI(err.header, err.body, 'error', err.isLink == true)
|
||||
this.callbacks.error()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the download process.
|
||||
*/
|
||||
async beginDownload() {
|
||||
// CHECK FILESYSTEM ACCESS
|
||||
const checker = new FilesystemChecker(this.destinationFolderName)
|
||||
this.cancelFn = () => checker.cancelCheck()
|
||||
|
||||
const checkerComplete = this.addFilesystemCheckerEventListeners(checker)
|
||||
checker.beginCheck()
|
||||
await checkerComplete
|
||||
|
||||
// DOWNLOAD FILES
|
||||
for (let i = 0; i < this.files.length; i++) {
|
||||
let wasCanceled = false
|
||||
this.cancelFn = () => { wasCanceled = true }
|
||||
// const downloader = getDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name))
|
||||
if (wasCanceled) { return }
|
||||
// this.cancelFn = () => downloader.cancelDownload()
|
||||
|
||||
// const downloadComplete = this.addDownloadEventListeners(downloader, i)
|
||||
// downloader.beginDownload()
|
||||
// await downloadComplete
|
||||
}
|
||||
|
||||
// EXTRACT FILES
|
||||
if (this.isArchive) {
|
||||
// const extractor = new FileExtractor(this.tempPath)
|
||||
// this.cancelFn = () => extractor.cancelExtract()
|
||||
|
||||
// const extractComplete = this.addExtractorEventListeners(extractor)
|
||||
// extractor.beginExtract()
|
||||
// await extractComplete
|
||||
}
|
||||
|
||||
// TRANSFER FILES
|
||||
const transfer = new FileTransfer(this.tempPath, this.destinationFolderName)
|
||||
this.cancelFn = () => transfer.cancelTransfer()
|
||||
|
||||
const transferComplete = this.addTransferEventListeners(transfer)
|
||||
transfer.beginTransfer()
|
||||
await transferComplete
|
||||
|
||||
this.callbacks.complete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what happens in reponse to `FilesystemChecker` events.
|
||||
* @returns a `Promise` that resolves when the filesystem has been checked.
|
||||
*/
|
||||
private addFilesystemCheckerEventListeners(checker: FilesystemChecker) {
|
||||
checker.on('start', () => {
|
||||
this.updateGUI('Checking filesystem...', '', 'good')
|
||||
})
|
||||
|
||||
checker.on('error', this.handleError.bind(this))
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
checker.on('complete', tempPath => {
|
||||
this.tempPath = tempPath
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what happens in response to `FileDownloader` events.
|
||||
* @returns a `Promise` that resolves when the download finishes.
|
||||
*/
|
||||
// private addDownloadEventListeners(downloader: FileDownloader, fileIndex: number) {
|
||||
// let downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})`
|
||||
// let downloadStartPoint = 0 // How far into the individual file progress portion the download progress starts
|
||||
// let fileProgress = 0
|
||||
|
||||
// downloader.on('waitProgress', (remainingSeconds: number, totalSeconds: number) => {
|
||||
// downloadStartPoint = this.individualFileProgressPortion / 2
|
||||
// this.percent =
|
||||
// this._allFilesProgress + interpolate(remainingSeconds, totalSeconds, 0, 0, this.individualFileProgressPortion / 2)
|
||||
// this.updateGUI(downloadHeader, `Waiting for Google rate limit... (${remainingSeconds}s)`, 'good')
|
||||
// })
|
||||
|
||||
// downloader.on('requestSent', () => {
|
||||
// fileProgress = downloadStartPoint
|
||||
// this.percent = this._allFilesProgress + fileProgress
|
||||
// this.updateGUI(downloadHeader, 'Sending request...', 'good')
|
||||
// })
|
||||
|
||||
// downloader.on('downloadProgress', (bytesDownloaded: number) => {
|
||||
// downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})`
|
||||
// const size = Number(this.files[fileIndex].size)
|
||||
// fileProgress = interpolate(bytesDownloaded, 0, size, downloadStartPoint, this.individualFileProgressPortion)
|
||||
// this.percent = this._allFilesProgress + fileProgress
|
||||
// this.updateGUI(downloadHeader, `Downloading... (${Math.round(1000 * bytesDownloaded / size) / 10}%)`, 'good')
|
||||
// })
|
||||
|
||||
// downloader.on('error', this.handleError.bind(this))
|
||||
|
||||
// return new Promise<void>(resolve => {
|
||||
// downloader.on('complete', () => {
|
||||
// this._allFilesProgress += this.individualFileProgressPortion
|
||||
// resolve()
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
|
||||
/**
|
||||
* Defines what happens in response to `FileExtractor` events.
|
||||
* @returns a `Promise` that resolves when the extraction finishes.
|
||||
*/
|
||||
// private addExtractorEventListeners(extractor: FileExtractor) {
|
||||
// let archive = ''
|
||||
|
||||
// extractor.on('start', filename => {
|
||||
// archive = filename
|
||||
// this.updateGUI(`[${archive}]`, 'Extracting...', 'good')
|
||||
// })
|
||||
|
||||
// extractor.on('extractProgress', (percent, filecount) => {
|
||||
// this.percent = interpolate(percent, 0, 100, 80, 95)
|
||||
// this.updateGUI(`[${archive}] (${filecount} file${filecount == 1 ? '' : 's'} extracted)`, `Extracting... (${percent}%)`, 'good')
|
||||
// })
|
||||
|
||||
// extractor.on('error', this.handleError.bind(this))
|
||||
|
||||
// return new Promise<void>(resolve => {
|
||||
// extractor.on('complete', () => {
|
||||
// this.percent = 95
|
||||
// resolve()
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
|
||||
/**
|
||||
* Defines what happens in response to `FileTransfer` events.
|
||||
* @returns a `Promise` that resolves when the transfer finishes.
|
||||
*/
|
||||
private addTransferEventListeners(transfer: FileTransfer) {
|
||||
let destinationFolder: string
|
||||
|
||||
transfer.on('start', _destinationFolder => {
|
||||
destinationFolder = _destinationFolder
|
||||
this.updateGUI('Moving files to library folder...', destinationFolder, 'good', true)
|
||||
})
|
||||
|
||||
transfer.on('error', this.handleError.bind(this))
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
transfer.on('complete', () => {
|
||||
this.percent = 100
|
||||
this.updateGUI('Download complete.', destinationFolder, 'done', true)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
86
src-electron/ipc/download/DownloadHandler.ts
Normal file
86
src-electron/ipc/download/DownloadHandler.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Download } from '../../shared/interfaces/download.interface'
|
||||
import { IPCEmitHandler } from '../../shared/IPCHandler'
|
||||
import { ChartDownload } from './ChartDownload'
|
||||
import { DownloadQueue } from './DownloadQueue'
|
||||
|
||||
class DownloadHandler implements IPCEmitHandler<'download'> {
|
||||
event = 'download' as const
|
||||
|
||||
downloadQueue: DownloadQueue = new DownloadQueue()
|
||||
currentDownload: ChartDownload = undefined
|
||||
retryWaiting: ChartDownload[] = []
|
||||
|
||||
handler(data: Download) {
|
||||
switch (data.action) {
|
||||
case 'add': this.addDownload(data); break
|
||||
case 'retry': this.retryDownload(data); break
|
||||
case 'cancel': this.cancelDownload(data); break
|
||||
}
|
||||
}
|
||||
|
||||
private addDownload(data: Download) {
|
||||
const filesHash = data.data.driveData.filesHash // Note: using versionID would cause chart packs to download multiple times
|
||||
if (this.currentDownload?.hash == filesHash || this.downloadQueue.isDownloadingLink(filesHash)) {
|
||||
return
|
||||
}
|
||||
|
||||
const newDownload = new ChartDownload(data.versionID, data.data)
|
||||
this.addDownloadEventListeners(newDownload)
|
||||
if (this.currentDownload == undefined) {
|
||||
this.currentDownload = newDownload
|
||||
newDownload.beginDownload()
|
||||
} else {
|
||||
this.downloadQueue.push(newDownload)
|
||||
}
|
||||
}
|
||||
|
||||
private retryDownload(data: Download) {
|
||||
const index = this.retryWaiting.findIndex(download => download.versionID == data.versionID)
|
||||
if (index != -1) {
|
||||
const retryDownload = this.retryWaiting.splice(index, 1)[0]
|
||||
retryDownload.displayRetrying()
|
||||
if (this.currentDownload == undefined) {
|
||||
this.currentDownload = retryDownload
|
||||
retryDownload.retry()
|
||||
} else {
|
||||
this.downloadQueue.push(retryDownload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cancelDownload(data: Download) {
|
||||
if (this.currentDownload?.versionID == data.versionID) {
|
||||
this.currentDownload.cancel()
|
||||
this.currentDownload = undefined
|
||||
this.startNextDownload()
|
||||
} else {
|
||||
this.downloadQueue.remove(data.versionID)
|
||||
}
|
||||
}
|
||||
|
||||
private addDownloadEventListeners(download: ChartDownload) {
|
||||
download.on('complete', () => {
|
||||
this.currentDownload = undefined
|
||||
this.startNextDownload()
|
||||
})
|
||||
|
||||
download.on('error', () => {
|
||||
this.retryWaiting.push(this.currentDownload)
|
||||
this.currentDownload = undefined
|
||||
this.startNextDownload()
|
||||
})
|
||||
}
|
||||
|
||||
private startNextDownload() {
|
||||
if (!this.downloadQueue.isEmpty()) {
|
||||
this.currentDownload = this.downloadQueue.shift()
|
||||
if (this.currentDownload.hasFailed) {
|
||||
this.currentDownload.retry()
|
||||
} else {
|
||||
this.currentDownload.beginDownload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadHandler = new DownloadHandler()
|
||||
51
src-electron/ipc/download/DownloadQueue.ts
Normal file
51
src-electron/ipc/download/DownloadQueue.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import Comparators from 'comparators'
|
||||
|
||||
import { emitIPCEvent } from '../../main'
|
||||
import { ChartDownload } from './ChartDownload'
|
||||
|
||||
export class DownloadQueue {
|
||||
|
||||
private downloadQueue: ChartDownload[] = []
|
||||
|
||||
isDownloadingLink(filesHash: string) {
|
||||
return this.downloadQueue.some(download => download.hash == filesHash)
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.downloadQueue.length == 0
|
||||
}
|
||||
|
||||
push(chartDownload: ChartDownload) {
|
||||
this.downloadQueue.push(chartDownload)
|
||||
this.sort()
|
||||
}
|
||||
|
||||
shift() {
|
||||
return this.downloadQueue.shift()
|
||||
}
|
||||
|
||||
get(versionID: number) {
|
||||
return this.downloadQueue.find(download => download.versionID == versionID)
|
||||
}
|
||||
|
||||
remove(versionID: number) {
|
||||
const index = this.downloadQueue.findIndex(download => download.versionID == versionID)
|
||||
if (index != -1) {
|
||||
this.downloadQueue[index].cancel()
|
||||
this.downloadQueue.splice(index, 1)
|
||||
emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID))
|
||||
}
|
||||
}
|
||||
|
||||
private sort() {
|
||||
let comparator = Comparators.comparing('allFilesProgress', { reversed: true })
|
||||
|
||||
const prioritizeArchives = true
|
||||
if (prioritizeArchives) {
|
||||
comparator = comparator.thenComparing('isArchive', { reversed: true })
|
||||
}
|
||||
|
||||
this.downloadQueue.sort(comparator)
|
||||
emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID))
|
||||
}
|
||||
}
|
||||
368
src-electron/ipc/download/FileDownloader.ts
Normal file
368
src-electron/ipc/download/FileDownloader.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
// import Bottleneck from 'bottleneck'
|
||||
// import { createWriteStream, writeFile as _writeFile } from 'fs'
|
||||
// import { google } from 'googleapis'
|
||||
// import * as needle from 'needle'
|
||||
// import { join } from 'path'
|
||||
// import { Readable } from 'stream'
|
||||
// import { inspect, promisify } from 'util'
|
||||
|
||||
// import { devLog } from '../../shared/ElectronUtilFunctions'
|
||||
// import { tempPath } from '../../shared/Paths'
|
||||
// import { AnyFunction } from '../../shared/UtilFunctions'
|
||||
// import { DownloadError } from './ChartDownload'
|
||||
// // TODO: replace needle with got (for cancel() method) (if before-headers event is possible?)
|
||||
// import { googleTimer } from './GoogleTimer'
|
||||
|
||||
// const drive = google.drive('v3')
|
||||
// const limiter = new Bottleneck({
|
||||
// minTime: 200, // Wait 200 ms between API requests
|
||||
// })
|
||||
|
||||
// const RETRY_MAX = 2
|
||||
// const writeFile = promisify(_writeFile)
|
||||
|
||||
// interface EventCallback {
|
||||
// 'waitProgress': (remainingSeconds: number, totalSeconds: number) => void
|
||||
// /** Note: this event can be called multiple times if the connection times out or a large file is downloaded */
|
||||
// 'requestSent': () => void
|
||||
// 'downloadProgress': (bytesDownloaded: number) => void
|
||||
// /** Note: after calling retry, the event lifecycle restarts */
|
||||
// 'error': (err: DownloadError, retry: () => void) => void
|
||||
// 'complete': () => void
|
||||
// }
|
||||
// type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
// export type FileDownloader = APIFileDownloader | SlowFileDownloader
|
||||
|
||||
// const downloadErrors = {
|
||||
// timeout: (type: string) => { return { header: 'Timeout', body: `The download server could not be reached. (type=${type})` } },
|
||||
// connectionError: (err: Error) => { return { header: 'Connection Error', body: `${err.name}: ${err.message}` } },
|
||||
// responseError: (statusCode: string) => { return { header: 'Connection failed', body: `Server returned status code: ${statusCode}` } },
|
||||
// htmlError: (path: string) => { return { header: 'Download server returned HTML instead of a file.', body: path, isLink: true } },
|
||||
// linkError: (url: string) => { return { header: 'Invalid link', body: `The download link is not formatted correctly: ${url}` } },
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Downloads a file from `url` to `fullPath`.
|
||||
// * Will handle google drive virus scan warnings. Provides event listeners for download progress.
|
||||
// * On error, provides the ability to retry.
|
||||
// * Will only send download requests once every `getSettings().rateLimitDelay` seconds.
|
||||
// */
|
||||
// class SlowFileDownloader {
|
||||
|
||||
// private callbacks = {} as Callbacks
|
||||
// private retryCount: number
|
||||
// private wasCanceled = false
|
||||
// private req: NodeJS.ReadableStream
|
||||
|
||||
// /**
|
||||
// * @param url The download link.
|
||||
// * @param fullPath The full path to where this file should be stored (including the filename).
|
||||
// */
|
||||
// constructor(private url: string, private fullPath: string) { }
|
||||
|
||||
// /**
|
||||
// * Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
|
||||
// */
|
||||
// on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
// this.callbacks[event] = callback
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Download the file after waiting for the google rate limit.
|
||||
// */
|
||||
// beginDownload() {
|
||||
// googleTimer.on('waitProgress', this.cancelable((remainingSeconds, totalSeconds) => {
|
||||
// this.callbacks.waitProgress(remainingSeconds, totalSeconds)
|
||||
// }))
|
||||
|
||||
// googleTimer.on('complete', this.cancelable(() => {
|
||||
// this.requestDownload()
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Sends a request to download the file at `this.url`.
|
||||
// * @param cookieHeader the "cookie=" header to include this request.
|
||||
// */
|
||||
// private requestDownload(cookieHeader?: string) {
|
||||
// this.callbacks.requestSent()
|
||||
// this.req = needle.get(this.url, {
|
||||
// 'follow_max': 10,
|
||||
// 'open_timeout': 5000,
|
||||
// 'headers': Object.assign({
|
||||
// 'Referer': this.url,
|
||||
// 'Accept': '*/*',
|
||||
// },
|
||||
// (cookieHeader ? { 'Cookie': cookieHeader } : undefined)
|
||||
// ),
|
||||
// })
|
||||
|
||||
// this.req.on('timeout', this.cancelable((type: string) => {
|
||||
// this.retryCount++
|
||||
// if (this.retryCount <= RETRY_MAX) {
|
||||
// devLog(`TIMEOUT: Retry attempt ${this.retryCount}...`)
|
||||
// this.requestDownload(cookieHeader)
|
||||
// } else {
|
||||
// this.failDownload(downloadErrors.timeout(type))
|
||||
// }
|
||||
// }))
|
||||
|
||||
// this.req.on('err', this.cancelable((err: Error) => {
|
||||
// this.failDownload(downloadErrors.connectionError(err))
|
||||
// }))
|
||||
|
||||
// this.req.on('header', this.cancelable((statusCode, headers: Headers) => {
|
||||
// if (statusCode != 200) {
|
||||
// this.failDownload(downloadErrors.responseError(statusCode))
|
||||
// return
|
||||
// }
|
||||
|
||||
// if (headers['content-type'].startsWith('text/html')) {
|
||||
// this.handleHTMLResponse(headers['set-cookie'])
|
||||
// } else {
|
||||
// this.handleDownloadResponse()
|
||||
// }
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * A Google Drive HTML response to a download request usually means this is the "file too large to scan for viruses" warning.
|
||||
// * This function sends the request that results from clicking "download anyway", or generates an error if it can't be found.
|
||||
// * @param cookieHeader The "cookie=" header of this request.
|
||||
// */
|
||||
// private handleHTMLResponse(cookieHeader: string) {
|
||||
// let virusScanHTML = ''
|
||||
// this.req.on('data', this.cancelable(data => virusScanHTML += data))
|
||||
// this.req.on('done', this.cancelable((err: Error) => {
|
||||
// if (err) {
|
||||
// this.failDownload(downloadErrors.connectionError(err))
|
||||
// } else {
|
||||
// try {
|
||||
// const confirmTokenRegex = /confirm=([0-9A-Za-z\-_]+)&/g
|
||||
// const confirmTokenResults = confirmTokenRegex.exec(virusScanHTML)
|
||||
// const confirmToken = confirmTokenResults[1]
|
||||
// const downloadID = this.url.substr(this.url.indexOf('id=') + 'id='.length)
|
||||
// this.url = `https://drive.google.com/uc?confirm=${confirmToken}&id=${downloadID}`
|
||||
// const warningCode = /download_warning_([^=]*)=/.exec(cookieHeader)[1]
|
||||
// const NID = /NID=([^;]*);/.exec(cookieHeader)[1].replace('=', '%')
|
||||
// const newHeader = `download_warning_${warningCode}=${confirmToken}; NID=${NID}`
|
||||
// this.requestDownload(newHeader)
|
||||
// } catch (e) {
|
||||
// this.saveHTMLError(virusScanHTML).then(path => {
|
||||
// this.failDownload(downloadErrors.htmlError(path))
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Pipes the data from a download response to `this.fullPath`.
|
||||
// * @param req The download request.
|
||||
// */
|
||||
// private handleDownloadResponse() {
|
||||
// this.callbacks.downloadProgress(0)
|
||||
// let downloadedSize = 0
|
||||
// this.req.pipe(createWriteStream(this.fullPath))
|
||||
// this.req.on('data', this.cancelable(data => {
|
||||
// downloadedSize += data.length
|
||||
// this.callbacks.downloadProgress(downloadedSize)
|
||||
// }))
|
||||
|
||||
// this.req.on('err', this.cancelable((err: Error) => {
|
||||
// this.failDownload(downloadErrors.connectionError(err))
|
||||
// }))
|
||||
|
||||
// this.req.on('end', this.cancelable(() => {
|
||||
// this.callbacks.complete()
|
||||
// }))
|
||||
// }
|
||||
|
||||
// private async saveHTMLError(text: string) {
|
||||
// const errorPath = join(tempPath, 'HTMLError.html')
|
||||
// await writeFile(errorPath, text)
|
||||
// return errorPath
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Display an error message and provide a function to retry the download.
|
||||
// */
|
||||
// private failDownload(error: DownloadError) {
|
||||
// this.callbacks.error(error, this.cancelable(() => this.beginDownload()))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Stop the process of downloading the file. (no more events will be fired after this is called)
|
||||
// */
|
||||
// cancelDownload() {
|
||||
// this.wasCanceled = true
|
||||
// googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
|
||||
// if (this.req) {
|
||||
// // TODO: destroy request
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
|
||||
// */
|
||||
// private cancelable<F extends AnyFunction>(fn: F) {
|
||||
// return (...args: Parameters<F>): ReturnType<F> => {
|
||||
// if (this.wasCanceled) { return }
|
||||
// return fn(...Array.from(args))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Downloads a file from `url` to `fullPath`.
|
||||
// * On error, provides the ability to retry.
|
||||
// */
|
||||
// class APIFileDownloader {
|
||||
// private readonly URL_REGEX = /uc\?id=([^&]*)&export=download/u
|
||||
|
||||
// private callbacks = {} as Callbacks
|
||||
// private retryCount: number
|
||||
// private wasCanceled = false
|
||||
// private fileID: string
|
||||
// private downloadStream: Readable
|
||||
|
||||
// /**
|
||||
// * @param url The download link.
|
||||
// * @param fullPath The full path to where this file should be stored (including the filename).
|
||||
// */
|
||||
// constructor(private url: string, private fullPath: string) {
|
||||
// // url looks like: "https://drive.google.com/uc?id=1TlxtOZlVgRgX7-1tyW0d5QzXVfL-MC3Q&export=download"
|
||||
// this.fileID = this.URL_REGEX.exec(url)[1]
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
|
||||
// */
|
||||
// on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
// this.callbacks[event] = callback
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Download the file after waiting for the google rate limit.
|
||||
// */
|
||||
// beginDownload() {
|
||||
// if (this.fileID == undefined) {
|
||||
// this.failDownload(downloadErrors.linkError(this.url))
|
||||
// }
|
||||
|
||||
// this.startDownloadStream()
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Uses the Google Drive API to start a download stream for the file with `this.fileID`.
|
||||
// */
|
||||
// private startDownloadStream() {
|
||||
// limiter.schedule(this.cancelable(async () => {
|
||||
// this.callbacks.requestSent()
|
||||
// try {
|
||||
// this.downloadStream = (await drive.files.get({
|
||||
// fileId: this.fileID,
|
||||
// alt: 'media',
|
||||
// }, {
|
||||
// responseType: 'stream',
|
||||
// })).data
|
||||
|
||||
// if (this.wasCanceled) { return }
|
||||
|
||||
// this.handleDownloadResponse()
|
||||
// } catch (err) {
|
||||
// this.retryCount++
|
||||
// if (this.retryCount <= RETRY_MAX) {
|
||||
// devLog(`Failed to get file: Retry attempt ${this.retryCount}...`)
|
||||
// if (this.wasCanceled) { return }
|
||||
// this.startDownloadStream()
|
||||
// } else {
|
||||
// devLog(inspect(err))
|
||||
// if (err?.code && err?.response?.statusText) {
|
||||
// this.failDownload(downloadErrors.responseError(`${err.code} (${err.response.statusText})`))
|
||||
// } else {
|
||||
// this.failDownload(downloadErrors.responseError(err?.code ?? 'unknown'))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Pipes the data from a download response to `this.fullPath`.
|
||||
// * @param req The download request.
|
||||
// */
|
||||
// private handleDownloadResponse() {
|
||||
// this.callbacks.downloadProgress(0)
|
||||
// let downloadedSize = 0
|
||||
// const writeStream = createWriteStream(this.fullPath)
|
||||
|
||||
// try {
|
||||
// this.downloadStream.pipe(writeStream)
|
||||
// } catch (err) {
|
||||
// this.failDownload(downloadErrors.connectionError(err))
|
||||
// }
|
||||
|
||||
// this.downloadStream.on('data', this.cancelable((chunk: Buffer) => {
|
||||
// downloadedSize += chunk.length
|
||||
// }))
|
||||
|
||||
// const progressUpdater = setInterval(() => {
|
||||
// this.callbacks.downloadProgress(downloadedSize)
|
||||
// }, 100)
|
||||
|
||||
// this.downloadStream.on('error', this.cancelable((err: Error) => {
|
||||
// clearInterval(progressUpdater)
|
||||
// this.failDownload(downloadErrors.connectionError(err))
|
||||
// }))
|
||||
|
||||
// this.downloadStream.on('end', this.cancelable(() => {
|
||||
// clearInterval(progressUpdater)
|
||||
// writeStream.end()
|
||||
// this.downloadStream.destroy()
|
||||
// this.downloadStream = null
|
||||
|
||||
// this.callbacks.complete()
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Display an error message and provide a function to retry the download.
|
||||
// */
|
||||
// private failDownload(error: DownloadError) {
|
||||
// this.callbacks.error(error, this.cancelable(() => this.beginDownload()))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Stop the process of downloading the file. (no more events will be fired after this is called)
|
||||
// */
|
||||
// cancelDownload() {
|
||||
// this.wasCanceled = true
|
||||
// googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
|
||||
// if (this.downloadStream) {
|
||||
// this.downloadStream.destroy()
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
|
||||
// */
|
||||
// private cancelable<F extends AnyFunction>(fn: F) {
|
||||
// return (...args: Parameters<F>): ReturnType<F> => {
|
||||
// if (this.wasCanceled) { return }
|
||||
// return fn(...Array.from(args))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Downloads a file from `url` to `fullPath`.
|
||||
// * Will handle google drive virus scan warnings. Provides event listeners for download progress.
|
||||
// * On error, provides the ability to retry.
|
||||
// * Will only send download requests once every `getSettings().rateLimitDelay` seconds if a Google account has not been authenticated.
|
||||
// * @param url The download link.
|
||||
// * @param fullPath The full path to where this file should be stored (including the filename).
|
||||
// */
|
||||
// export function getDownloader(url: string, fullPath: string): FileDownloader {
|
||||
// return new SlowFileDownloader(url, fullPath)
|
||||
// }
|
||||
170
src-electron/ipc/download/FileExtractor.ts
Normal file
170
src-electron/ipc/download/FileExtractor.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// import * as zipBin from '7zip-bin'
|
||||
// import { mkdir as _mkdir, readdir, unlink } from 'fs'
|
||||
// import * as node7z from 'node-7z'
|
||||
// import * as unrarjs from 'node-unrar-js' // TODO find better rar library that has async extraction
|
||||
// import { FailReason } from 'node-unrar-js/dist/js/extractor'
|
||||
// import { extname, join } from 'path'
|
||||
// import { promisify } from 'util'
|
||||
|
||||
// import { devLog } from '../../shared/ElectronUtilFunctions'
|
||||
// import { AnyFunction } from '../../shared/UtilFunctions'
|
||||
// import { DownloadError } from './ChartDownload'
|
||||
|
||||
// const mkdir = promisify(_mkdir)
|
||||
|
||||
// interface EventCallback {
|
||||
// 'start': (filename: string) => void
|
||||
// 'extractProgress': (percent: number, fileCount: number) => void
|
||||
// 'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
// 'complete': () => void
|
||||
// }
|
||||
// type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
// const extractErrors = {
|
||||
// readError: (err: NodeJS.ErrnoException) => ({ header: `Failed to read file (${err.code})`, body: `${err.name}: ${err.message}` }),
|
||||
// emptyError: () => ({ header: 'Failed to extract archive', body: 'File archive was downloaded but could not be found' }),
|
||||
// rarmkdirError: (err: NodeJS.ErrnoException, sourceFile: string) => {
|
||||
// return { header: `Extracting archive failed. (${err.code})`, body: `${err.name}: ${err.message} (${sourceFile})` }
|
||||
// },
|
||||
// rarextractError: (result: { reason: FailReason; msg: string }, sourceFile: string) => {
|
||||
// return { header: `Extracting archive failed: ${result.reason}`, body: `${result.msg} (${sourceFile})` }
|
||||
// },
|
||||
// }
|
||||
|
||||
// export class FileExtractor {
|
||||
|
||||
// private callbacks = {} as Callbacks
|
||||
// private wasCanceled = false
|
||||
// constructor(private sourceFolder: string) { }
|
||||
|
||||
// /**
|
||||
// * Calls `callback` when `event` fires. (no events will be fired after `this.cancelExtract()` is called)
|
||||
// */
|
||||
// on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
// this.callbacks[event] = callback
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Extract the chart from `this.sourceFolder`. (assumes there is exactly one archive file in that folder)
|
||||
// */
|
||||
// beginExtract() {
|
||||
// setTimeout(this.cancelable(() => {
|
||||
// readdir(this.sourceFolder, (err, files) => {
|
||||
// if (err) {
|
||||
// this.callbacks.error(extractErrors.readError(err), () => this.beginExtract())
|
||||
// } else if (files.length == 0) {
|
||||
// this.callbacks.error(extractErrors.emptyError(), () => this.beginExtract())
|
||||
// } else {
|
||||
// this.callbacks.start(files[0])
|
||||
// this.extract(join(this.sourceFolder, files[0]), extname(files[0]) == '.rar')
|
||||
// }
|
||||
// })
|
||||
// }), 100) // Wait for filesystem to process downloaded file
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Extracts the file at `fullPath` to `this.sourceFolder`.
|
||||
// */
|
||||
// private async extract(fullPath: string, useRarExtractor: boolean) {
|
||||
// if (useRarExtractor) {
|
||||
// await this.extractRar(fullPath) // Use node-unrar-js to extract the archive
|
||||
// } else {
|
||||
// this.extract7z(fullPath) // Use node-7z to extract the archive
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Extracts a .rar archive found at `fullPath` and puts the extracted results in `this.sourceFolder`.
|
||||
// * @throws an `ExtractError` if this fails.
|
||||
// */
|
||||
// private async extractRar(fullPath: string) {
|
||||
// const extractor = unrarjs.createExtractorFromFile(fullPath, this.sourceFolder)
|
||||
|
||||
// const fileList = extractor.getFileList()
|
||||
|
||||
// if (fileList[0].state != 'FAIL') {
|
||||
|
||||
// // Create directories for nested archives (because unrarjs didn't feel like handling that automatically)
|
||||
// const headers = fileList[1].fileHeaders
|
||||
// for (const header of headers) {
|
||||
// if (header.flags.directory) {
|
||||
// try {
|
||||
// await mkdir(join(this.sourceFolder, header.name), { recursive: true })
|
||||
// } catch (err) {
|
||||
// this.callbacks.error(
|
||||
// extractErrors.rarmkdirError(err, fullPath),
|
||||
// () => this.extract(fullPath, extname(fullPath) == '.rar'),
|
||||
// )
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const extractResult = extractor.extractAll()
|
||||
|
||||
// if (extractResult[0].state == 'FAIL') {
|
||||
// this.callbacks.error(
|
||||
// extractErrors.rarextractError(extractResult[0], fullPath),
|
||||
// () => this.extract(fullPath, extname(fullPath) == '.rar'),
|
||||
// )
|
||||
// } else {
|
||||
// this.deleteArchive(fullPath)
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Extracts a .zip or .7z archive found at `fullPath` and puts the extracted results in `this.sourceFolder`.
|
||||
// */
|
||||
// private extract7z(fullPath: string) {
|
||||
// const zipBinPath = zipBin.path7za.replace('app.asar', 'app.asar.unpacked') // I love electron-builder packaging :)
|
||||
// const stream = node7z.extractFull(fullPath, this.sourceFolder, { $progress: true, $bin: zipBinPath })
|
||||
|
||||
// stream.on('progress', this.cancelable((progress: { percent: number; fileCount: number }) => {
|
||||
// this.callbacks.extractProgress(progress.percent, isNaN(progress.fileCount) ? 0 : progress.fileCount)
|
||||
// }))
|
||||
|
||||
// let extractErrorOccured = false
|
||||
// stream.on('error', this.cancelable(() => {
|
||||
// extractErrorOccured = true
|
||||
// devLog(`Failed to extract [${fullPath}]; retrying with .rar extractor...`)
|
||||
// this.extract(fullPath, true)
|
||||
// }))
|
||||
|
||||
// stream.on('end', this.cancelable(() => {
|
||||
// if (!extractErrorOccured) {
|
||||
// this.deleteArchive(fullPath)
|
||||
// }
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Tries to delete the archive at `fullPath`.
|
||||
// */
|
||||
// private deleteArchive(fullPath: string) {
|
||||
// unlink(fullPath, this.cancelable(err => {
|
||||
// if (err && err.code != 'ENOENT') {
|
||||
// devLog(`Warning: failed to delete archive at [${fullPath}]`)
|
||||
// }
|
||||
|
||||
// this.callbacks.complete()
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Stop the process of extracting the file. (no more events will be fired after this is called)
|
||||
// */
|
||||
// cancelExtract() {
|
||||
// this.wasCanceled = true
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Wraps a function that is able to be prevented if `this.cancelExtract()` was called.
|
||||
// */
|
||||
// private cancelable<F extends AnyFunction>(fn: F) {
|
||||
// return (...args: Parameters<F>): ReturnType<F> => {
|
||||
// if (this.wasCanceled) { return }
|
||||
// return fn(...Array.from(args))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
112
src-electron/ipc/download/FileTransfer.ts
Normal file
112
src-electron/ipc/download/FileTransfer.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Dirent, readdir as _readdir } from 'fs'
|
||||
import mv from 'mv'
|
||||
import { join } from 'path'
|
||||
import { rimraf } from 'rimraf'
|
||||
import { promisify } from 'util'
|
||||
|
||||
import { getSettings } from '../SettingsHandler.ipc'
|
||||
import { DownloadError } from './ChartDownload'
|
||||
|
||||
const readdir = promisify(_readdir)
|
||||
|
||||
interface EventCallback {
|
||||
'start': (destinationFolder: string) => void
|
||||
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
'complete': () => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
const transferErrors = {
|
||||
readError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to read file.'),
|
||||
deleteError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to delete file.'),
|
||||
rimrafError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to delete folder.'),
|
||||
mvError: (err: NodeJS.ErrnoException) => fsError(
|
||||
err,
|
||||
`Failed to move folder to library.${err.code == 'EPERM' ? ' (does the chart already exist?)' : ''}`,
|
||||
),
|
||||
}
|
||||
|
||||
function fsError(err: NodeJS.ErrnoException, description: string) {
|
||||
return { header: description, body: `${err.name}: ${err.message}` }
|
||||
}
|
||||
|
||||
export class FileTransfer {
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private wasCanceled = false
|
||||
private destinationFolder: string
|
||||
private nestedSourceFolder: string // The top-level folder that is copied to the library folder
|
||||
constructor(private sourceFolder: string, destinationFolderName: string) {
|
||||
this.destinationFolder = join(getSettings().libraryPath, destinationFolderName)
|
||||
this.nestedSourceFolder = sourceFolder
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelTransfer()` is called)
|
||||
*/
|
||||
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
async beginTransfer() {
|
||||
this.callbacks.start(this.destinationFolder)
|
||||
await this.cleanFolder()
|
||||
if (this.wasCanceled) { return }
|
||||
this.moveFolder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes common problems with the download chart folder.
|
||||
*/
|
||||
private async cleanFolder() {
|
||||
let files: Dirent[]
|
||||
try {
|
||||
files = await readdir(this.nestedSourceFolder, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
this.callbacks.error(transferErrors.readError(err), () => this.cleanFolder())
|
||||
return
|
||||
}
|
||||
|
||||
// Remove nested folders
|
||||
if (files.length == 1 && !files[0].isFile()) {
|
||||
this.nestedSourceFolder = join(this.nestedSourceFolder, files[0].name)
|
||||
await this.cleanFolder()
|
||||
return
|
||||
}
|
||||
|
||||
// Delete '__MACOSX' folder
|
||||
for (const file of files) {
|
||||
if (!file.isFile() && file.name == '__MACOSX') {
|
||||
try {
|
||||
await rimraf(join(this.nestedSourceFolder, file.name))
|
||||
} catch (err) {
|
||||
this.callbacks.error(transferErrors.rimrafError(err), () => this.cleanFolder())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// TODO: handle other common problems, like chart/audio files not named correctly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the downloaded chart to the library path.
|
||||
*/
|
||||
private moveFolder() {
|
||||
mv(this.nestedSourceFolder, this.destinationFolder, { mkdirp: true }, err => {
|
||||
if (err) {
|
||||
this.callbacks.error(transferErrors.mvError(err), () => this.moveFolder())
|
||||
} else {
|
||||
rimraf(this.sourceFolder) // Delete temp folder
|
||||
this.callbacks.complete()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the process of transfering the file. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelTransfer() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
}
|
||||
122
src-electron/ipc/download/FilesystemChecker.ts
Normal file
122
src-electron/ipc/download/FilesystemChecker.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { randomBytes as _randomBytes } from 'crypto'
|
||||
import { access, constants, mkdir } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
|
||||
import { devLog } from '../../shared/ElectronUtilFunctions'
|
||||
import { tempPath } from '../../shared/Paths'
|
||||
import { AnyFunction } from '../../shared/UtilFunctions'
|
||||
import { getSettings } from '../SettingsHandler.ipc'
|
||||
import { DownloadError } from './ChartDownload'
|
||||
|
||||
const randomBytes = promisify(_randomBytes)
|
||||
|
||||
interface EventCallback {
|
||||
'start': () => void
|
||||
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
'complete': (tempPath: string) => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
const filesystemErrors = {
|
||||
libraryFolder: () => ({ header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' }),
|
||||
libraryAccess: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to access library folder.'),
|
||||
destinationFolderExists: (destinationPath: string) => {
|
||||
return { header: 'This chart already exists in your library folder.', body: destinationPath, isLink: true }
|
||||
},
|
||||
mkdirError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to create temporary folder.'),
|
||||
}
|
||||
|
||||
function fsError(err: NodeJS.ErrnoException, description: string) {
|
||||
return { header: description, body: `${err.name}: ${err.message}` }
|
||||
}
|
||||
|
||||
export class FilesystemChecker {
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private wasCanceled = false
|
||||
constructor(private destinationFolderName: string) { }
|
||||
|
||||
/**
|
||||
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
|
||||
*/
|
||||
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the filesystem is set up for the download.
|
||||
*/
|
||||
beginCheck() {
|
||||
this.callbacks.start()
|
||||
this.checkLibraryFolder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the user has specified a library folder.
|
||||
*/
|
||||
private checkLibraryFolder() {
|
||||
if (getSettings().libraryPath == undefined) {
|
||||
this.callbacks.error(filesystemErrors.libraryFolder(), () => this.beginCheck())
|
||||
} else {
|
||||
access(getSettings().libraryPath, constants.W_OK, this.cancelable(err => {
|
||||
if (err) {
|
||||
this.callbacks.error(filesystemErrors.libraryAccess(err), () => this.beginCheck())
|
||||
} else {
|
||||
this.checkDestinationFolder()
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the destination folder doesn't already exist.
|
||||
*/
|
||||
private checkDestinationFolder() {
|
||||
const destinationPath = join(getSettings().libraryPath, this.destinationFolderName)
|
||||
access(destinationPath, constants.F_OK, this.cancelable(err => {
|
||||
if (err) { // File does not exist
|
||||
this.createDownloadFolder()
|
||||
} else {
|
||||
this.callbacks.error(filesystemErrors.destinationFolderExists(destinationPath), () => this.beginCheck())
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to create a unique folder in Bridge's data paths.
|
||||
*/
|
||||
private async createDownloadFolder(retryCount = 0) {
|
||||
const tempChartPath = join(tempPath, `chart_${(await randomBytes(5)).toString('hex')}`)
|
||||
|
||||
mkdir(tempChartPath, this.cancelable(err => {
|
||||
if (err) {
|
||||
if (retryCount < 5) {
|
||||
devLog(`Error creating folder [${tempChartPath}], retrying with a different folder...`)
|
||||
this.createDownloadFolder(retryCount + 1)
|
||||
} else {
|
||||
this.callbacks.error(filesystemErrors.mkdirError(err), () => this.createDownloadFolder())
|
||||
}
|
||||
} else {
|
||||
this.callbacks.complete(tempChartPath)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the process of checking the filesystem permissions. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelCheck() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a function that is able to be prevented if `this.cancelCheck()` was called.
|
||||
*/
|
||||
private cancelable<F extends AnyFunction>(fn: F) {
|
||||
return (...args: Parameters<F>): ReturnType<F> => {
|
||||
if (this.wasCanceled) { return }
|
||||
return fn(...Array.from(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src-electron/ipc/download/GoogleTimer.ts
Normal file
72
src-electron/ipc/download/GoogleTimer.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { getSettings } from '../SettingsHandler.ipc'
|
||||
|
||||
interface EventCallback {
|
||||
'waitProgress': (remainingSeconds: number, totalSeconds: number) => void
|
||||
'complete': () => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]?: EventCallback[E] }
|
||||
|
||||
class GoogleTimer {
|
||||
|
||||
private rateLimitCounter = Infinity
|
||||
private callbacks: Callbacks = {}
|
||||
|
||||
/**
|
||||
* Initializes the timer to call the callbacks if they are defined.
|
||||
*/
|
||||
constructor() {
|
||||
setInterval(() => {
|
||||
this.rateLimitCounter++
|
||||
this.updateCallbacks()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelTimer()` is called)
|
||||
*/
|
||||
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
this.callbacks[event] = callback
|
||||
this.updateCallbacks() // Fire events immediately after the listeners have been added
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the state of the callbacks and call them if necessary.
|
||||
*/
|
||||
private updateCallbacks() {
|
||||
if (this.hasTimerEnded() && this.callbacks.complete != undefined) {
|
||||
this.endTimer()
|
||||
} else if (this.callbacks.waitProgress != undefined) {
|
||||
const delay = getSettings().rateLimitDelay
|
||||
this.callbacks.waitProgress(delay - this.rateLimitCounter, delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the callbacks from activating when the timer ends.
|
||||
*/
|
||||
cancelTimer() {
|
||||
this.callbacks = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if enough time has elapsed since the last timer activation.
|
||||
*/
|
||||
private hasTimerEnded() {
|
||||
return this.rateLimitCounter > getSettings().rateLimitDelay
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the completion callback and resets the timer.
|
||||
*/
|
||||
private endTimer() {
|
||||
this.rateLimitCounter = 0
|
||||
const completeCallback = this.callbacks.complete
|
||||
this.callbacks = {}
|
||||
completeCallback()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Important: this instance cannot be used by more than one file download at a time.
|
||||
*/
|
||||
export const googleTimer = new GoogleTimer()
|
||||
157
src-electron/main.ts
Normal file
157
src-electron/main.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
import * as path from 'path'
|
||||
import * as url from 'url'
|
||||
|
||||
import { getSettingsHandler } from './ipc/SettingsHandler.ipc'
|
||||
import { updateChecker } from './ipc/UpdateHandler.ipc'
|
||||
// IPC Handlers
|
||||
import { getIPCEmitHandlers, getIPCInvokeHandlers, IPCEmitEvents } from './shared/IPCHandler'
|
||||
import { dataPath } from './shared/Paths'
|
||||
|
||||
import unhandled = require('electron-unhandled')
|
||||
unhandled({ showDialog: true })
|
||||
|
||||
export let mainWindow: BrowserWindow
|
||||
const args = process.argv.slice(1)
|
||||
const isDevBuild = args.some(val => val == '--dev')
|
||||
import remote = require('@electron/remote/main')
|
||||
|
||||
|
||||
remote.initialize()
|
||||
restrictToSingleInstance()
|
||||
handleOSXWindowClosed()
|
||||
app.on('ready', () => {
|
||||
// Load settings from file before the window is created
|
||||
getSettingsHandler.initSettings().then(() => {
|
||||
createBridgeWindow()
|
||||
if (!isDevBuild) {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Only allow a single Bridge window to be open at any one time.
|
||||
* If this is attempted, restore the open window instead.
|
||||
*/
|
||||
function restrictToSingleInstance() {
|
||||
const isFirstBridgeInstance = app.requestSingleInstanceLock()
|
||||
if (!isFirstBridgeInstance) app.quit()
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow != undefined) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard OSX window functionality is to
|
||||
* minimize when closed and maximize when opened.
|
||||
*/
|
||||
function handleOSXWindowClosed() {
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform != 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (mainWindow == undefined) {
|
||||
createBridgeWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches and initializes Bridge's main window.
|
||||
*/
|
||||
async function createBridgeWindow() {
|
||||
|
||||
// Load window size and maximized/restored state from previous session
|
||||
const windowState = windowStateKeeper({
|
||||
defaultWidth: 1000,
|
||||
defaultHeight: 800,
|
||||
path: dataPath,
|
||||
})
|
||||
|
||||
// Create the browser window
|
||||
mainWindow = createBrowserWindow(windowState)
|
||||
|
||||
// Store window size and maximized/restored state for next session
|
||||
windowState.manage(mainWindow)
|
||||
|
||||
// Don't use a system menu
|
||||
mainWindow.setMenu(null)
|
||||
|
||||
// IPC handlers
|
||||
getIPCInvokeHandlers().map(handler => ipcMain.handle(handler.event, (_event, ...args) => handler.handler(args[0])))
|
||||
getIPCEmitHandlers().map(handler => ipcMain.on(handler.event, (_event, ...args) => handler.handler(args[0])))
|
||||
|
||||
// Load angular app
|
||||
await loadWindow()
|
||||
|
||||
if (isDevBuild) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null // Dereference mainWindow when the window is closed
|
||||
})
|
||||
|
||||
// enable the remote webcontents
|
||||
remote.enable(mainWindow.webContents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a BrowserWindow object with initial parameters
|
||||
*/
|
||||
function createBrowserWindow(windowState: windowStateKeeper.State) {
|
||||
let options: Electron.BrowserWindowConstructorOptions = {
|
||||
x: windowState.x,
|
||||
y: windowState.y,
|
||||
width: windowState.width,
|
||||
height: windowState.height,
|
||||
frame: false,
|
||||
title: 'Bridge',
|
||||
webPreferences: {
|
||||
// preload:
|
||||
allowRunningInsecureContent: (isDevBuild) ? true : false,
|
||||
textAreasAreResizable: false,
|
||||
},
|
||||
simpleFullscreen: true,
|
||||
fullscreenable: false,
|
||||
backgroundColor: '#121212',
|
||||
}
|
||||
|
||||
if (process.platform == 'linux' && !isDevBuild) {
|
||||
options = Object.assign(options, { icon: path.join(__dirname, '..', 'assets', 'images', 'system', 'icons', 'png', '48x48.png') })
|
||||
}
|
||||
|
||||
return new BrowserWindow(options)
|
||||
}
|
||||
|
||||
async function loadWindow(retries = 0) {
|
||||
if (retries > 10) { throw new Error('Angular frontend did not load') }
|
||||
try {
|
||||
await mainWindow.loadURL(getLoadUrl())
|
||||
} catch (err) {
|
||||
await loadWindow(retries + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load from localhost during development; load from index.html in production
|
||||
*/
|
||||
function getLoadUrl() {
|
||||
return url.format({
|
||||
protocol: isDevBuild ? 'http:' : 'file:',
|
||||
pathname: isDevBuild ? '//localhost:4200/' : path.join(__dirname, '..', 'index.html'),
|
||||
slashes: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function emitIPCEvent<E extends keyof IPCEmitEvents>(event: E, data: IPCEmitEvents[E]) {
|
||||
mainWindow.webContents.send(event, data)
|
||||
}
|
||||
Reference in New Issue
Block a user