Restructure; use DaisyUI

This commit is contained in:
Geomitron
2023-11-28 19:50:45 -06:00
parent 49c3f38f99
commit 2eef4d0bee
727 changed files with 1283 additions and 298840 deletions

View File

@@ -0,0 +1,28 @@
import { basename, parse } from 'path'
import { inspect } from 'util'
import { lower } from '../src-shared/UtilFunctions'
import { settings } from './ipc/SettingsHandler.ipc'
import { emitIpcEvent } from './main'
/**
* @returns The relative filepath from the library folder to `absoluteFilepath`.
*/
export async function getRelativeFilepath(absoluteFilepath: string) {
if (!settings.libraryPath) { throw 'getRelativeFilepath() failed; libraryPath is undefined' }
return basename(settings.libraryPath) + absoluteFilepath.substring(settings.libraryPath.length)
}
/**
* @returns `true` if `name` has a valid video file extension.
*/
export function hasVideoExtension(name: string) {
return (['.mp4', '.avi', '.webm', '.ogv', '.mpeg'].includes(parse(lower(name)).ext))
}
/**
* Log a message in the main BrowserWindow's console.
*/
export function devLog(message: unknown) {
emitIpcEvent('errorLog', typeof message === 'string' ? message : inspect(message))
}

View File

@@ -0,0 +1,39 @@
import { IpcInvokeHandlers, IpcToMainEmitHandlers } from '../src-shared/interfaces/ipc.interface'
import { getBatchSongDetails } from './ipc/browse/BatchSongDetailsHandler.ipc'
import { songSearch } from './ipc/browse/SearchHandler.ipc'
import { getSongDetails } from './ipc/browse/SongDetailsHandler.ipc'
import { download } from './ipc/download/DownloadHandler'
import { getSettings, setSettings } from './ipc/SettingsHandler.ipc'
import { downloadUpdate, getCurrentVersion, getUpdateAvailable, quitAndInstall, retryUpdate } from './ipc/UpdateHandler.ipc'
import { isMaximized, maximize, minimize, openUrl, quit, restore, showFile, showFolder, showOpenDialog, toggleDevTools } from './ipc/UtilHandlers.ipc'
export function getIpcInvokeHandlers(): IpcInvokeHandlers {
return {
getSettings,
songSearch,
getSongDetails,
getBatchSongDetails,
getCurrentVersion,
getUpdateAvailable,
isMaximized,
showOpenDialog,
}
}
export function getIpcToMainEmitHandlers(): IpcToMainEmitHandlers {
return {
download,
setSettings,
downloadUpdate,
retryUpdate,
quitAndInstall,
openUrl,
toggleDevTools,
maximize,
minimize,
restore,
quit,
showFile,
showFolder,
}
}

View File

@@ -1,42 +0,0 @@
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()

View File

@@ -1,19 +0,0 @@
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()

View File

@@ -1,94 +1,56 @@
import * as fs from 'fs'
import { promisify } from 'util'
import { readFileSync } from 'fs'
import { writeFile } from 'fs/promises'
import { cloneDeep } from 'lodash'
import { mkdirp } from 'mkdirp'
import { inspect } from 'util'
import { IPCEmitHandler, IPCInvokeHandler } from '../shared/IPCHandler'
import { dataPath, settingsPath, tempPath, themesPath } from '../shared/Paths'
import { defaultSettings, Settings } from '../shared/Settings'
import { dataPath, settingsPath, tempPath, themesPath } from '../../src-shared/Paths'
import { defaultSettings, Settings } from '../../src-shared/Settings'
import { devLog } from '../ElectronUtilFunctions'
const exists = promisify(fs.exists)
const mkdir = promisify(fs.mkdir)
const readFile = promisify(fs.readFile)
const writeFile = promisify(fs.writeFile)
export let settings = readSettings()
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
function readSettings() {
try {
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as Partial<Settings>
return Object.assign(cloneDeep(defaultSettings), settings)
} catch (err) {
if (err?.code === 'ENOENT') {
saveSettings(cloneDeep(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
devLog('Failed to load settings. Default settings will be used.\n' + inspect(err))
}
return cloneDeep(defaultSettings)
}
}
export const getSettingsHandler = new GetSettingsHandler()
export const setSettingsHandler = new SetSettingsHandler()
export function getSettings() { return getSettingsHandler.getSettings() }
/**
* Updates Bridge's settings object to `newSettings` and saves them to Bridge's data directories.
*/
export async function setSettings(newSettings: Settings) {
settings = newSettings
await saveSettings(newSettings)
}
/**
* @returns the current settings object, or default settings if they couldn't be loaded.
*/
export async function getSettings() {
return settings
}
/**
* Saves `settings` to Bridge's data directories. If settings are not provided, default settings are used.
*/
async function saveSettings(settings: Settings) {
try {
// Create data directories if they don't exist
for (const path of [dataPath, tempPath, themesPath]) {
await mkdirp(path)
}
await writeFile(settingsPath, JSON.stringify(settings, undefined, 2), 'utf8')
} catch (err) {
devLog('Failed to save settings.\n' + inspect(err))
}
}

View File

@@ -1,135 +1,71 @@
import { autoUpdater, UpdateInfo } from 'electron-updater'
import { inspect } from 'util'
import { emitIPCEvent } from '../main'
import { IPCEmitHandler, IPCInvokeHandler } from '../shared/IPCHandler'
import { UpdateProgress } from '../../src-shared/interfaces/update.interface'
import { emitIpcEvent } from '../main'
export interface UpdateProgress {
bytesPerSecond: number
percent: number
transferred: number
total: number
let updateAvailable: boolean | null = false
autoUpdater.autoDownload = false
autoUpdater.logger = null
autoUpdater.on('error', (err: Error) => {
updateAvailable = null
emitIpcEvent('updateError', inspect(err))
})
autoUpdater.on('update-available', (info: UpdateInfo) => {
updateAvailable = true
emitIpcEvent('updateAvailable', info)
})
autoUpdater.on('update-not-available', () => {
updateAvailable = false
emitIpcEvent('updateAvailable', null)
})
export async function retryUpdate() {
try {
await autoUpdater.checkForUpdates()
} catch (err) {
updateAvailable = null
emitIpcEvent('updateError', inspect(err))
}
}
let updateAvailable = false
export async function getUpdateAvailable() {
return updateAvailable
}
/**
* Checks for updates when the program is launched.
* @returns the current version of Bridge.
*/
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 async function getCurrentVersion() {
return autoUpdater.currentVersion.raw
}
export const updateChecker = new UpdateChecker()
/**
* Handles the 'get-update-available' event.
* Begins the process of downloading the latest update.
*/
class GetUpdateAvailableHandler implements IPCInvokeHandler<'get-update-available'> {
event = 'get-update-available' as const
export function downloadUpdate() {
if (this.downloading) { return }
this.downloading = true
/**
* @returns `true` if an update is available.
*/
handler() {
return updateAvailable
}
autoUpdater.on('download-progress', (updateProgress: UpdateProgress) => {
emitIpcEvent('updateProgress', updateProgress)
})
autoUpdater.on('update-downloaded', () => {
emitIpcEvent('updateDownloaded', undefined)
})
autoUpdater.downloadUpdate()
}
export const getUpdateAvailableHandler = new GetUpdateAvailableHandler()
/**
* Handles the 'get-current-version' event.
* Immediately closes the application and installs the update.
*/
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 function quitAndInstall() {
autoUpdater.quitAndInstall() // autoUpdater installs a downloaded update on the next program restart by default
}
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()

View File

@@ -0,0 +1,46 @@
import { app, dialog, OpenDialogOptions, shell } from 'electron'
import { mainWindow } from '../main'
/**
* Opens `url` in the default browser.
*/
export function openUrl(url: string) {
shell.openExternal(url)
}
export function toggleDevTools() {
mainWindow.webContents.toggleDevTools()
}
export async function isMaximized() {
return mainWindow.isMaximized()
}
export function maximize() {
mainWindow.maximize()
}
export function minimize() {
mainWindow.minimize()
}
export function restore() {
mainWindow.restore()
}
export function quit() {
app.quit()
}
export function showOpenDialog(options: OpenDialogOptions) {
return dialog.showOpenDialog(mainWindow, options)
}
export function showFolder(folderPath: string) {
shell.openPath(folderPath)
}
export function showFile(filePath: string) {
shell.showItemInFolder(filePath)
}

View File

@@ -1,20 +0,0 @@
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()

View File

@@ -1,20 +1,6 @@
import { VersionResult } from '../../shared/interfaces/songDetails.interface'
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { serverURL } from '../../shared/Paths'
import { serverURL } from '../../../src-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 async function getBatchSongDetails(songIds: number[]) {
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songIds.join(',')}`)
return await response.json()
}
export const batchSongDetailsHandler = new BatchSongDetailsHandler()

View File

@@ -1,28 +1,15 @@
import { SongResult, SongSearch } from '../../shared/interfaces/search.interface'
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { serverURL } from '../../shared/Paths'
import { SongSearch } from '../../../src-shared/interfaces/search.interface'
import { serverURL } from '../../../src-shared/Paths'
/**
* Handles the 'song-search' event.
*/
class SearchHandler implements IPCInvokeHandler<'song-search'> {
event = 'song-search' as const
export async function songSearch(search: SongSearch) {
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),
})
/**
* @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()
}
return await response.json()
}
export const searchHandler = new SearchHandler()

View File

@@ -1,20 +1,6 @@
import { VersionResult } from '../../shared/interfaces/songDetails.interface'
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { serverURL } from '../../shared/Paths'
import { serverURL } from '../../../src-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 async function getSongDetails(songId: number) {
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songId}`)
return await response.json()
}
export const songDetailsHandler = new SongDetailsHandler()

View File

@@ -1,12 +1,12 @@
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 { NewDownload, ProgressType } from '../../../src-shared/interfaces/download.interface'
import { DriveFile } from '../../../src-shared/interfaces/songDetails.interface'
import { sanitizeFilename } from '../../../src-shared/UtilFunctions'
import { hasVideoExtension } from '../../ElectronUtilFunctions'
import { emitIpcEvent } from '../../main'
import { settings } from '../SettingsHandler.ipc'
// import { FileDownloader, getDownloader } from './FileDownloader'
import { FilesystemChecker } from './FilesystemChecker'
import { FileTransfer } from './FileTransfer'
@@ -22,8 +22,8 @@ export interface DownloadError { header: string; body: string; isLink?: boolean
export class ChartDownload {
private retryFn: () => void | Promise<void>
private cancelFn: () => void
private retryFn: undefined | (() => void | Promise<void>)
private cancelFn: undefined | (() => void)
private callbacks = {} as Callbacks
private files: DriveFile[]
@@ -62,7 +62,7 @@ export class ChartDownload {
filterDownloadFiles(files: DriveFile[]) {
return files.filter(file => {
return (file.name != 'ch.dat') && (getSettings().downloadVideos || !hasVideoExtension(file.name))
return (file.name !== 'ch.dat') && (settings.downloadVideos || !hasVideoExtension(file.name))
})
}
@@ -70,7 +70,7 @@ export class ChartDownload {
* Retries the last failed step if it is running.
*/
retry() { // Only allow it to be called once
if (this.retryFn != undefined) {
if (this.retryFn !== undefined) {
this._hasFailed = false
const retryFn = this.retryFn
this.retryFn = undefined
@@ -89,7 +89,7 @@ export class ChartDownload {
* Cancels the download if it is running.
*/
cancel() { // Only allow it to be called once
if (this.cancelFn != undefined) {
if (this.cancelFn !== undefined) {
const cancelFn = this.cancelFn
this.cancelFn = undefined
cancelFn()
@@ -105,7 +105,7 @@ export class ChartDownload {
private updateGUI(header: string, description: string, type: ProgressType, isLink = false) {
if (this.wasCanceled) { return }
emitIPCEvent('download-updated', {
emitIpcEvent('downloadUpdated', {
versionID: this.versionID,
title: `${this.data.chartName} - ${this.data.artist}`,
header: header,
@@ -122,7 +122,7 @@ export class ChartDownload {
private handleError(err: DownloadError, retry: () => void) {
this._hasFailed = true
this.retryFn = retry
this.updateGUI(err.header, err.body, 'error', err.isLink == true)
this.updateGUI(err.header, err.body, 'error', err.isLink === true)
this.callbacks.error()
}
@@ -245,7 +245,7 @@ export class ChartDownload {
// 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')
// this.updateGUI(`[${archive}] (${filecount} file${filecount === 1 ? '' : 's'} extracted)`, `Extracting... (${percent}%)`, 'good')
// })
// extractor.on('error', this.handleError.bind(this))

View File

@@ -1,86 +1,82 @@
import { Download } from '../../shared/interfaces/download.interface'
import { IPCEmitHandler } from '../../shared/IPCHandler'
import { Download } from '../../../src-shared/interfaces/download.interface'
import { ChartDownload } from './ChartDownload'
import { DownloadQueue } from './DownloadQueue'
class DownloadHandler implements IPCEmitHandler<'download'> {
event = 'download' as const
const downloadQueue: DownloadQueue = new DownloadQueue()
const retryWaiting: ChartDownload[] = []
downloadQueue: DownloadQueue = new DownloadQueue()
currentDownload: ChartDownload = undefined
retryWaiting: ChartDownload[] = []
let currentDownload: ChartDownload | undefined = undefined
handler(data: Download) {
switch (data.action) {
case 'add': this.addDownload(data); break
case 'retry': this.retryDownload(data); break
case 'cancel': this.cancelDownload(data); break
}
export async function download(data: Download) {
switch (data.action) {
case 'add': addDownload(data); break
case 'retry': retryDownload(data); break
case 'cancel': cancelDownload(data); break
}
}
function addDownload(data: Download) {
const filesHash = data.data!.driveData.filesHash // Note: using versionID would cause chart packs to download multiple times
if (currentDownload?.hash === filesHash || downloadQueue.isDownloadingLink(filesHash)) {
return
}
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!)
addDownloadEventListeners(newDownload)
if (currentDownload === undefined) {
currentDownload = newDownload
newDownload.beginDownload()
} else {
downloadQueue.push(newDownload)
}
}
const newDownload = new ChartDownload(data.versionID, data.data)
this.addDownloadEventListeners(newDownload)
if (this.currentDownload == undefined) {
this.currentDownload = newDownload
newDownload.beginDownload()
function retryDownload(data: Download) {
const index = retryWaiting.findIndex(download => download.versionID === data.versionID)
if (index !== -1) {
const retryDownload = retryWaiting.splice(index, 1)[0]
retryDownload.displayRetrying()
if (currentDownload === undefined) {
currentDownload = retryDownload
retryDownload.retry()
} 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()
}
downloadQueue.push(retryDownload)
}
}
}
export const downloadHandler = new DownloadHandler()
function cancelDownload(data: Download) {
if (currentDownload?.versionID === data.versionID) {
currentDownload.cancel()
currentDownload = undefined
startNextDownload()
} else {
downloadQueue.remove(data.versionID)
}
}
function addDownloadEventListeners(download: ChartDownload) {
download.on('complete', () => {
currentDownload = undefined
startNextDownload()
})
download.on('error', () => {
if (currentDownload) {
retryWaiting.push(currentDownload)
currentDownload = undefined
}
startNextDownload()
})
}
function startNextDownload() {
currentDownload = downloadQueue.shift()
if (currentDownload) {
if (currentDownload.hasFailed) {
currentDownload.retry()
} else {
currentDownload.beginDownload()
}
}
}

View File

@@ -1,6 +1,6 @@
import Comparators from 'comparators'
import Comparators, { Comparator } from 'comparators'
import { emitIPCEvent } from '../../main'
import { emitIpcEvent } from '../../main'
import { ChartDownload } from './ChartDownload'
export class DownloadQueue {
@@ -8,11 +8,11 @@ export class DownloadQueue {
private downloadQueue: ChartDownload[] = []
isDownloadingLink(filesHash: string) {
return this.downloadQueue.some(download => download.hash == filesHash)
return this.downloadQueue.some(download => download.hash === filesHash)
}
isEmpty() {
return this.downloadQueue.length == 0
return this.downloadQueue.length === 0
}
push(chartDownload: ChartDownload) {
@@ -25,27 +25,27 @@ export class DownloadQueue {
}
get(versionID: number) {
return this.downloadQueue.find(download => download.versionID == versionID)
return this.downloadQueue.find(download => download.versionID === versionID)
}
remove(versionID: number) {
const index = this.downloadQueue.findIndex(download => download.versionID == versionID)
if (index != -1) {
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))
emitIpcEvent('queueUpdated', this.downloadQueue.map(download => download.versionID))
}
}
private sort() {
let comparator = Comparators.comparing('allFilesProgress', { reversed: true })
let comparator: Comparator<unknown> | undefined = Comparators.comparing('allFilesProgress', { reversed: true })
const prioritizeArchives = true
if (prioritizeArchives) {
comparator = comparator.thenComparing('isArchive', { reversed: true })
comparator = comparator.thenComparing?.('isArchive', { reversed: true }) || undefined
}
this.downloadQueue.sort(comparator)
emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID))
emitIpcEvent('queueUpdated', this.downloadQueue.map(download => download.versionID))
}
}

View File

@@ -112,7 +112,7 @@
// }))
// this.req.on('header', this.cancelable((statusCode, headers: Headers) => {
// if (statusCode != 200) {
// if (statusCode !== 200) {
// this.failDownload(downloadErrors.responseError(statusCode))
// return
// }
@@ -246,7 +246,7 @@
// * Download the file after waiting for the google rate limit.
// */
// beginDownload() {
// if (this.fileID == undefined) {
// if (this.fileID === undefined) {
// this.failDownload(downloadErrors.linkError(this.url))
// }

View File

@@ -52,11 +52,11 @@
// readdir(this.sourceFolder, (err, files) => {
// if (err) {
// this.callbacks.error(extractErrors.readError(err), () => this.beginExtract())
// } else if (files.length == 0) {
// } 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')
// this.extract(join(this.sourceFolder, files[0]), extname(files[0]) === '.rar')
// }
// })
// }), 100) // Wait for filesystem to process downloaded file
@@ -82,7 +82,7 @@
// const fileList = extractor.getFileList()
// if (fileList[0].state != 'FAIL') {
// if (fileList[0].state !== 'FAIL') {
// // Create directories for nested archives (because unrarjs didn't feel like handling that automatically)
// const headers = fileList[1].fileHeaders
@@ -93,7 +93,7 @@
// } catch (err) {
// this.callbacks.error(
// extractErrors.rarmkdirError(err, fullPath),
// () => this.extract(fullPath, extname(fullPath) == '.rar'),
// () => this.extract(fullPath, extname(fullPath) === '.rar'),
// )
// return
// }
@@ -103,10 +103,10 @@
// const extractResult = extractor.extractAll()
// if (extractResult[0].state == 'FAIL') {
// if (extractResult[0].state === 'FAIL') {
// this.callbacks.error(
// extractErrors.rarextractError(extractResult[0], fullPath),
// () => this.extract(fullPath, extname(fullPath) == '.rar'),
// () => this.extract(fullPath, extname(fullPath) === '.rar'),
// )
// } else {
// this.deleteArchive(fullPath)
@@ -143,7 +143,7 @@
// */
// private deleteArchive(fullPath: string) {
// unlink(fullPath, this.cancelable(err => {
// if (err && err.code != 'ENOENT') {
// if (err && err.code !== 'ENOENT') {
// devLog(`Warning: failed to delete archive at [${fullPath}]`)
// }

View File

@@ -4,7 +4,7 @@ import { join } from 'path'
import { rimraf } from 'rimraf'
import { promisify } from 'util'
import { getSettings } from '../SettingsHandler.ipc'
import { settings } from '../SettingsHandler.ipc'
import { DownloadError } from './ChartDownload'
const readdir = promisify(_readdir)
@@ -17,12 +17,13 @@ interface EventCallback {
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
const transferErrors = {
libraryError: () => ({ header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' }),
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?)' : ''}`,
`Failed to move folder to library.${err.code === 'EPERM' ? ' (does the chart already exist?)' : ''}`,
),
}
@@ -34,10 +35,10 @@ export class FileTransfer {
private callbacks = {} as Callbacks
private wasCanceled = false
private destinationFolder: string
private destinationFolder: string | null
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.destinationFolder = settings.libraryPath ? join(settings.libraryPath, destinationFolderName) : null
this.nestedSourceFolder = sourceFolder
}
@@ -49,10 +50,14 @@ export class FileTransfer {
}
async beginTransfer() {
this.callbacks.start(this.destinationFolder)
await this.cleanFolder()
if (this.wasCanceled) { return }
this.moveFolder()
if (!this.destinationFolder) {
this.callbacks.error(transferErrors.libraryError(), () => this.beginTransfer())
} else {
this.callbacks.start(this.destinationFolder)
await this.cleanFolder()
if (this.wasCanceled) { return }
this.moveFolder()
}
}
/**
@@ -68,7 +73,7 @@ export class FileTransfer {
}
// Remove nested folders
if (files.length == 1 && !files[0].isFile()) {
if (files.length === 1 && !files[0].isFile()) {
this.nestedSourceFolder = join(this.nestedSourceFolder, files[0].name)
await this.cleanFolder()
return
@@ -76,7 +81,7 @@ export class FileTransfer {
// Delete '__MACOSX' folder
for (const file of files) {
if (!file.isFile() && file.name == '__MACOSX') {
if (!file.isFile() && file.name === '__MACOSX') {
try {
await rimraf(join(this.nestedSourceFolder, file.name))
} catch (err) {
@@ -93,14 +98,18 @@ export class FileTransfer {
* 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()
}
})
if (!this.destinationFolder) {
this.callbacks.error(transferErrors.libraryError(), () => this.moveFolder())
} else {
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()
}
})
}
}
/**

View File

@@ -1,16 +1,12 @@
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 { tempPath } from '../../../src-shared/Paths'
import { AnyFunction } from '../../../src-shared/UtilFunctions'
import { devLog } from '../../ElectronUtilFunctions'
import { settings } from '../SettingsHandler.ipc'
import { DownloadError } from './ChartDownload'
const randomBytes = promisify(_randomBytes)
interface EventCallback {
'start': () => void
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
@@ -19,7 +15,7 @@ interface EventCallback {
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.' }),
libraryError: () => ({ 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 }
@@ -56,10 +52,10 @@ export class FilesystemChecker {
* Verifies that the user has specified a library folder.
*/
private checkLibraryFolder() {
if (getSettings().libraryPath == undefined) {
this.callbacks.error(filesystemErrors.libraryFolder(), () => this.beginCheck())
if (settings.libraryPath === undefined) {
this.callbacks.error(filesystemErrors.libraryError(), () => this.beginCheck())
} else {
access(getSettings().libraryPath, constants.W_OK, this.cancelable(err => {
access(settings.libraryPath, constants.W_OK, this.cancelable(err => {
if (err) {
this.callbacks.error(filesystemErrors.libraryAccess(err), () => this.beginCheck())
} else {
@@ -73,21 +69,25 @@ export class FilesystemChecker {
* 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())
}
}))
if (!settings.libraryPath) {
this.callbacks.error(filesystemErrors.libraryError(), () => this.beginCheck())
} else {
const destinationPath = join(settings.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')}`)
const tempChartPath = join(tempPath, `chart_TODO_MAKE_UNIQUE`)
mkdir(tempChartPath, this.cancelable(err => {
if (err) {
@@ -114,7 +114,7 @@ export class FilesystemChecker {
* 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> => {
return (...args: Parameters<F>): ReturnType<F> | void => {
if (this.wasCanceled) { return }
return fn(...Array.from(args))
}

View File

@@ -1,4 +1,4 @@
import { getSettings } from '../SettingsHandler.ipc'
import { settings } from '../SettingsHandler.ipc'
interface EventCallback {
'waitProgress': (remainingSeconds: number, totalSeconds: number) => void
@@ -33,10 +33,10 @@ class GoogleTimer {
* Check the state of the callbacks and call them if necessary.
*/
private updateCallbacks() {
if (this.hasTimerEnded() && this.callbacks.complete != undefined) {
if (this.hasTimerEnded() && this.callbacks.complete !== undefined) {
this.endTimer()
} else if (this.callbacks.waitProgress != undefined) {
const delay = getSettings().rateLimitDelay
} else if (this.callbacks.waitProgress !== undefined) {
const delay = settings.rateLimitDelay
this.callbacks.waitProgress(delay - this.rateLimitCounter, delay)
}
}
@@ -52,7 +52,7 @@ class GoogleTimer {
* Checks if enough time has elapsed since the last timer activation.
*/
private hasTimerEnded() {
return this.rateLimitCounter > getSettings().rateLimitDelay
return this.rateLimitCounter > settings.rateLimitDelay
}
/**
@@ -62,7 +62,7 @@ class GoogleTimer {
this.rateLimitCounter = 0
const completeCallback = this.callbacks.complete
this.callbacks = {}
completeCallback()
completeCallback?.()
}
}

View File

@@ -1,34 +1,27 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import electronUnhandled from 'electron-unhandled'
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 { IpcFromMainEmitEvents } from '../src-shared/interfaces/ipc.interface'
import { dataPath } from '../src-shared/Paths'
import { retryUpdate } from './ipc/UpdateHandler.ipc'
import { getIpcInvokeHandlers, getIpcToMainEmitHandlers } from './IpcHandler'
import unhandled = require('electron-unhandled')
unhandled({ showDialog: true })
electronUnhandled({ 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')
const isDevBuild = args.some(val => val === '--dev')
remote.initialize()
restrictToSingleInstance()
handleOSXWindowClosed()
app.on('ready', () => {
// Load settings from file before the window is created
getSettingsHandler.initSettings().then(() => {
createBridgeWindow()
if (!isDevBuild) {
updateChecker.checkForUpdates()
}
})
app.on('ready', async () => {
createBridgeWindow()
if (!isDevBuild) {
retryUpdate()
}
})
/**
@@ -39,7 +32,7 @@ function restrictToSingleInstance() {
const isFirstBridgeInstance = app.requestSingleInstanceLock()
if (!isFirstBridgeInstance) app.quit()
app.on('second-instance', () => {
if (mainWindow != undefined) {
if (mainWindow !== undefined) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
@@ -52,13 +45,13 @@ function restrictToSingleInstance() {
*/
function handleOSXWindowClosed() {
app.on('window-all-closed', () => {
if (process.platform != 'darwin') {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (mainWindow == undefined) {
if (mainWindow === undefined) {
createBridgeWindow()
}
})
@@ -86,8 +79,16 @@ async function createBridgeWindow() {
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])))
for (const [key, handler] of Object.entries(getIpcInvokeHandlers())) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ipcMain.handle(key, (_event, ...args) => (handler as any)(args[0]))
}
for (const [key, handler] of Object.entries(getIpcToMainEmitHandlers())) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ipcMain.on(key, (_event, ...args) => (handler as any)(args[0]))
}
mainWindow.on('unmaximize', () => emitIpcEvent('minimized', undefined))
mainWindow.on('maximize', () => emitIpcEvent('maximized', undefined))
// Load angular app
await loadWindow()
@@ -95,13 +96,6 @@ async function createBridgeWindow() {
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)
}
/**
@@ -116,7 +110,7 @@ function createBrowserWindow(windowState: windowStateKeeper.State) {
frame: false,
title: 'Bridge',
webPreferences: {
// preload:
preload: path.join(__dirname, 'preload.js'),
allowRunningInsecureContent: (isDevBuild) ? true : false,
textAreasAreResizable: false,
},
@@ -125,7 +119,7 @@ function createBrowserWindow(windowState: windowStateKeeper.State) {
backgroundColor: '#121212',
}
if (process.platform == 'linux' && !isDevBuild) {
if (process.platform === 'linux' && !isDevBuild) {
options = Object.assign(options, { icon: path.join(__dirname, '..', 'assets', 'images', 'system', 'icons', 'png', '48x48.png') })
}
@@ -152,6 +146,7 @@ function getLoadUrl() {
})
}
export function emitIPCEvent<E extends keyof IPCEmitEvents>(event: E, data: IPCEmitEvents[E]) {
// TODO: await mainWindow first
export function emitIpcEvent<E extends keyof IpcFromMainEmitEvents>(event: E, data: IpcFromMainEmitEvents[E]) {
mainWindow.webContents.send(event, data)
}

58
src-electron/preload.ts Normal file
View File

@@ -0,0 +1,58 @@
import { contextBridge, ipcRenderer } from 'electron'
import { ContextBridgeApi, IpcFromMainEmitEvents, IpcInvokeEvents, IpcToMainEmitEvents } from '../src-shared/interfaces/ipc.interface'
function getInvoker<K extends keyof IpcInvokeEvents>(key: K) {
return (data: IpcInvokeEvents[K]['input']) => ipcRenderer.invoke(key, data) as Promise<IpcInvokeEvents[K]['output']>
}
function getEmitter<K extends keyof IpcToMainEmitEvents>(key: K) {
return (data: IpcToMainEmitEvents[K]) => ipcRenderer.emit(key, data)
}
function getListenerAdder<K extends keyof IpcFromMainEmitEvents>(key: K) {
return (listener: (data: IpcFromMainEmitEvents[K]) => void) => {
ipcRenderer.on(key, (_event, ...results) => listener(results[0]))
}
}
const electronApi: ContextBridgeApi = {
invoke: {
getSettings: getInvoker('getSettings'),
songSearch: getInvoker('songSearch'),
getSongDetails: getInvoker('getSongDetails'),
getBatchSongDetails: getInvoker('getBatchSongDetails'),
getCurrentVersion: getInvoker('getCurrentVersion'),
getUpdateAvailable: getInvoker('getUpdateAvailable'),
isMaximized: getInvoker('isMaximized'),
showOpenDialog: getInvoker('showOpenDialog'),
},
emit: {
download: getEmitter('download'),
setSettings: getEmitter('setSettings'),
downloadUpdate: getEmitter('downloadUpdate'),
retryUpdate: getEmitter('retryUpdate'),
quitAndInstall: getEmitter('quitAndInstall'),
openUrl: getEmitter('openUrl'),
toggleDevTools: getEmitter('toggleDevTools'),
maximize: getEmitter('maximize'),
minimize: getEmitter('minimize'),
restore: getEmitter('restore'),
quit: getEmitter('quit'),
showFolder: getEmitter('showFolder'),
showFile: getEmitter('showFile'),
},
on: {
errorLog: getListenerAdder('errorLog'),
updateError: getListenerAdder('updateError'),
updateAvailable: getListenerAdder('updateAvailable'),
updateProgress: getListenerAdder('updateProgress'),
updateDownloaded: getListenerAdder('updateDownloaded'),
downloadUpdated: getListenerAdder('downloadUpdated'),
queueUpdated: getListenerAdder('queueUpdated'),
maximized: getListenerAdder('maximized'),
minimized: getListenerAdder('minimized'),
},
}
contextBridge.exposeInMainWorld('electron', electronApi)

View File

@@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"files": [
"./main.ts",
"./preload.ts"
],
"include": [
"./**/*.ts"
],
"compilerOptions": {
"target": "ES5",
"module": "CommonJS",
"outDir": "../dist/electron"
}
}