412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { compareVersions } from 'compare-versions'
|
|
import log from 'electron-log'
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import { MessageToWebApp } from '../../Shared/IpcMessages'
|
|
import { AppName } from '../Strings'
|
|
import { Paths } from '../Types/Paths'
|
|
import { timeout } from '../Utils/Utils'
|
|
import { downloadFile, getJSON } from './Networking'
|
|
import { Component, MappingFile, PackageInfo, PackageManagerInterface, SyncTask } from './PackageManagerInterface'
|
|
import { FilesManagerInterface } from '../File/FilesManagerInterface'
|
|
import { FilesManager } from '../File/FilesManager'
|
|
import { FileErrorCodes } from '../File/FileErrorCodes'
|
|
|
|
function logMessage(...message: any) {
|
|
log.info('PackageManager[Info]:', ...message)
|
|
}
|
|
|
|
function logError(...message: any) {
|
|
console.error('PackageManager[Error]:', ...message)
|
|
}
|
|
|
|
/**
|
|
* Safe component mapping manager that queues its disk writes
|
|
*/
|
|
class MappingFileHandler {
|
|
static async create() {
|
|
let mapping: MappingFile
|
|
const filesManager = new FilesManager()
|
|
|
|
try {
|
|
const result = await filesManager.readJSONFile<MappingFile>(Paths.extensionsMappingJson)
|
|
mapping = result || {}
|
|
} catch (error: any) {
|
|
/**
|
|
* Mapping file might be absent (first start, corrupted data)
|
|
*/
|
|
if (error.code === FileErrorCodes.FileDoesNotExist) {
|
|
await filesManager.ensureDirectoryExists(path.dirname(Paths.extensionsMappingJson))
|
|
} else {
|
|
logError(error)
|
|
}
|
|
mapping = {}
|
|
}
|
|
|
|
return new MappingFileHandler(mapping, filesManager)
|
|
}
|
|
|
|
constructor(
|
|
private mapping: MappingFile,
|
|
private filesManager: FilesManagerInterface,
|
|
) {}
|
|
|
|
get = (componendId: string) => {
|
|
return this.mapping[componendId]
|
|
}
|
|
|
|
set = (componentId: string, location: string, version: string) => {
|
|
this.mapping[componentId] = {
|
|
location,
|
|
version,
|
|
}
|
|
|
|
this.filesManager.debouncedJSONDiskWriter(100, Paths.extensionsMappingJson, () => this.mapping)
|
|
}
|
|
|
|
remove = (componentId: string) => {
|
|
delete this.mapping[componentId]
|
|
|
|
this.filesManager.debouncedJSONDiskWriter(100, Paths.extensionsMappingJson, () => this.mapping)
|
|
}
|
|
|
|
getInstalledVersionForComponent = async (component: Component): Promise<string> => {
|
|
const version = this.get(component.uuid)?.version
|
|
if (version) {
|
|
return version
|
|
}
|
|
|
|
/**
|
|
* If the mapping has no version (pre-3.5 installs) check the component's
|
|
* package.json file
|
|
*/
|
|
const paths = pathsForComponent(component)
|
|
const packagePath = path.join(paths.absolutePath, 'package.json')
|
|
const response = await this.filesManager.readJSONFile<{ version: string }>(packagePath)
|
|
if (!response) {
|
|
return ''
|
|
}
|
|
this.set(component.uuid, paths.relativePath, response.version)
|
|
return response.version
|
|
}
|
|
}
|
|
|
|
export async function initializePackageManager(webContents: Electron.WebContents): Promise<PackageManagerInterface> {
|
|
const syncTasks: SyncTask[] = []
|
|
let isRunningTasks = false
|
|
|
|
const mapping = await MappingFileHandler.create()
|
|
|
|
return {
|
|
syncComponents: async (components: Component[]) => {
|
|
logMessage(
|
|
'received sync event for:',
|
|
components
|
|
.map(
|
|
({ content, deleted }) =>
|
|
// eslint-disable-next-line camelcase
|
|
`${content?.name} (${content?.package_info?.version}) ` + `(deleted: ${deleted})`,
|
|
)
|
|
.join(', '),
|
|
)
|
|
syncTasks.push({ components })
|
|
|
|
if (isRunningTasks) {
|
|
return
|
|
}
|
|
|
|
isRunningTasks = true
|
|
await runTasks(webContents, mapping, syncTasks)
|
|
isRunningTasks = false
|
|
},
|
|
}
|
|
}
|
|
|
|
async function runTasks(webContents: Electron.WebContents, mapping: MappingFileHandler, tasks: SyncTask[]) {
|
|
while (tasks.length > 0) {
|
|
try {
|
|
const oppositeTask = await runTask(webContents, mapping, tasks[0], tasks.slice(1))
|
|
if (oppositeTask) {
|
|
tasks.splice(tasks.indexOf(oppositeTask), 1)
|
|
}
|
|
} catch (error) {
|
|
logError(error)
|
|
} finally {
|
|
/** Remove the task from the queue. */
|
|
tasks.splice(0, 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param nextTasks the tasks that follow this one. Useful to see if we
|
|
* need to run it at all (for example in the case of a succession of
|
|
* install/uninstall)
|
|
* @returns If a task opposite to this one was found, returns that tas without
|
|
* doing anything. Otherwise undefined.
|
|
*/
|
|
async function runTask(
|
|
webContents: Electron.WebContents,
|
|
mapping: MappingFileHandler,
|
|
task: SyncTask,
|
|
nextTasks: SyncTask[],
|
|
): Promise<SyncTask | undefined> {
|
|
const maxTries = 3
|
|
/** Try to execute the task with up to three tries. */
|
|
for (let tries = 1; tries <= maxTries; tries++) {
|
|
try {
|
|
if (task.components.length === 1 && nextTasks.length > 0) {
|
|
/**
|
|
* This is a single-component task, AKA an installation or
|
|
* deletion
|
|
*/
|
|
const component = task.components[0]
|
|
/**
|
|
* See if there is a task opposite to this one, to avoid doing
|
|
* unnecessary processing
|
|
*/
|
|
const oppositeTask = nextTasks.find((otherTask) => {
|
|
if (otherTask.components.length > 1) {
|
|
/** Only check single-component tasks. */
|
|
return false
|
|
}
|
|
const otherComponent = otherTask.components[0]
|
|
return component.uuid === otherComponent.uuid && component.deleted !== otherComponent.deleted
|
|
})
|
|
if (oppositeTask) {
|
|
/** Found an opposite task. return it to the caller and do nothing */
|
|
return oppositeTask
|
|
}
|
|
}
|
|
await syncComponents(webContents, mapping, task.components)
|
|
/** Everything went well, leave the loop */
|
|
return
|
|
} catch (error) {
|
|
if (tries < maxTries) {
|
|
continue
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function syncComponents(webContents: Electron.WebContents, mapping: MappingFileHandler, components: Component[]) {
|
|
/**
|
|
* Incoming `components` are what should be installed. For every component,
|
|
* check the filesystem and see if that component is installed. If not,
|
|
* install it.
|
|
*/
|
|
await Promise.all(
|
|
components.map(async (component) => {
|
|
if (component.deleted) {
|
|
/** Uninstall */
|
|
logMessage(`Uninstalling ${component.content?.name}`)
|
|
await uninstallComponent(mapping, component.uuid)
|
|
return
|
|
}
|
|
|
|
// eslint-disable-next-line camelcase
|
|
if (!component.content?.package_info) {
|
|
logMessage('Package info is null, skipping')
|
|
return
|
|
}
|
|
|
|
const paths = pathsForComponent(component)
|
|
const version = component.content.package_info.version
|
|
if (!component.content.local_url) {
|
|
/**
|
|
* We have a component but it is not mapped to anything on the file system
|
|
*/
|
|
await installComponent(webContents, mapping, component, component.content.package_info, version)
|
|
} else {
|
|
try {
|
|
/** Will trigger an error if the directory does not exist. */
|
|
await fs.promises.lstat(paths.absolutePath)
|
|
if (!component.content.autoupdateDisabled) {
|
|
await checkForUpdate(webContents, mapping, component)
|
|
}
|
|
} catch (error: any) {
|
|
if (error.code === FileErrorCodes.FileDoesNotExist) {
|
|
/** We have a component but no content. Install the component */
|
|
await installComponent(webContents, mapping, component, component.content.package_info, version)
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
async function checkForUpdate(webContents: Electron.WebContents, mapping: MappingFileHandler, component: Component) {
|
|
const installedVersion = await mapping.getInstalledVersionForComponent(component)
|
|
|
|
const latestUrl = component.content?.package_info?.latest_url
|
|
if (!latestUrl) {
|
|
return
|
|
}
|
|
|
|
const latestJson = await getJSON<PackageInfo>(latestUrl)
|
|
if (!latestJson) {
|
|
return
|
|
}
|
|
|
|
const latestVersion = latestJson.version
|
|
logMessage(
|
|
`Checking for update for ${component.content?.name}\n` +
|
|
`Latest: ${latestVersion} | Installed: ${installedVersion}`,
|
|
)
|
|
|
|
if (compareVersions(latestVersion, installedVersion) === 1) {
|
|
/** Latest version is greater than installed version */
|
|
logMessage('Downloading new version', latestVersion)
|
|
await installComponent(webContents, mapping, component, latestJson, latestVersion)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Some plugin zips may be served directly from GitHub's Releases feature, which automatically generates a zip file.
|
|
* However, the actual assets end up being nested inside a root level folder within that zip.
|
|
* We can detect if we have a legacy nested structure if after unzipping the zip we end up with
|
|
* only 1 entry and that entry is a folder.
|
|
*/
|
|
async function usesLegacyNestedFolderStructure(dir: string) {
|
|
const fileNames = await fs.promises.readdir(dir)
|
|
if (fileNames.length > 1) {
|
|
return false
|
|
}
|
|
|
|
const stat = fs.lstatSync(path.join(dir, fileNames[0]))
|
|
return stat.isDirectory()
|
|
}
|
|
|
|
async function unnestLegacyStructure(dir: string) {
|
|
const fileNames = await fs.promises.readdir(dir)
|
|
const sourceDir = path.join(dir, fileNames[0])
|
|
const destDir = dir
|
|
|
|
await new FilesManager().moveDirContents(sourceDir, destDir)
|
|
}
|
|
|
|
async function installComponent(
|
|
webContents: Electron.WebContents,
|
|
mapping: MappingFileHandler,
|
|
component: Component,
|
|
packageInfo: PackageInfo,
|
|
version: string,
|
|
) {
|
|
if (!component.content) {
|
|
return
|
|
}
|
|
const downloadUrl = packageInfo.download_url
|
|
if (!downloadUrl) {
|
|
return
|
|
}
|
|
const name = component.content.name
|
|
|
|
logMessage('Installing ', name, downloadUrl)
|
|
|
|
const sendInstalledMessage = (component: Component, error?: { message: string; tag: string }) => {
|
|
if (error) {
|
|
logError(`Error when installing component ${name}: ` + error.message)
|
|
return
|
|
}
|
|
|
|
logMessage(`Installed component ${name} (${version})`)
|
|
|
|
webContents.send(MessageToWebApp.InstallComponentComplete, {
|
|
component,
|
|
})
|
|
}
|
|
|
|
const paths = pathsForComponent(component)
|
|
try {
|
|
logMessage(`Downloading from ${downloadUrl}`)
|
|
/** Download the zip and clear the component's directory in parallel */
|
|
await Promise.all([
|
|
downloadFile(downloadUrl, paths.downloadPath),
|
|
(async () => {
|
|
/** Clear the component's directory before extracting the zip. */
|
|
const filesManager = new FilesManager()
|
|
await filesManager.ensureDirectoryExists(paths.absolutePath)
|
|
await filesManager.deleteDirContents(paths.absolutePath)
|
|
})(),
|
|
])
|
|
|
|
logMessage('Extracting', paths.downloadPath, 'to', paths.absolutePath)
|
|
const filesManager = new FilesManager()
|
|
await filesManager.extractZip(paths.downloadPath, paths.absolutePath)
|
|
|
|
const legacyStructure = await usesLegacyNestedFolderStructure(paths.absolutePath)
|
|
if (legacyStructure) {
|
|
await unnestLegacyStructure(paths.absolutePath)
|
|
}
|
|
|
|
let main = 'index.html'
|
|
try {
|
|
/** Try to read 'sn.main' field from 'package.json' file */
|
|
const packageJsonPath = path.join(paths.absolutePath, 'package.json')
|
|
const packageJson = await filesManager.readJSONFile<{
|
|
sn?: { main?: string }
|
|
version?: string
|
|
}>(packageJsonPath)
|
|
|
|
if (packageJson?.sn?.main) {
|
|
main = packageJson.sn.main
|
|
}
|
|
} catch (error) {
|
|
logError(error)
|
|
}
|
|
|
|
component.content.local_url = 'sn://' + paths.relativePath + '/' + main
|
|
component.content.package_info.download_url = packageInfo.download_url
|
|
component.content.package_info.latest_url = packageInfo.latest_url
|
|
component.content.package_info.url = packageInfo.url
|
|
component.content.package_info.version = packageInfo.version
|
|
mapping.set(component.uuid, paths.relativePath, version)
|
|
|
|
sendInstalledMessage(component)
|
|
} catch (error: any) {
|
|
logMessage(`Error while installing ${component.content.name}`, error.message)
|
|
|
|
/**
|
|
* Waiting five seconds prevents clients from spamming install requests
|
|
* of faulty components
|
|
*/
|
|
const fiveSeconds = 5000
|
|
await timeout(fiveSeconds)
|
|
|
|
sendInstalledMessage(component, {
|
|
message: error.message,
|
|
tag: 'error-downloading',
|
|
})
|
|
}
|
|
}
|
|
|
|
function pathsForComponent(component: Pick<Component, 'content'>) {
|
|
const relativePath = path.join(Paths.extensionsDirRelative, component.content!.package_info.identifier)
|
|
const absolutePath = path.join(Paths.userDataDir, relativePath)
|
|
const downloadPath = path.join(Paths.tempDir, AppName, 'downloads', component.content!.name + '.zip')
|
|
|
|
return {
|
|
relativePath,
|
|
absolutePath,
|
|
downloadPath,
|
|
}
|
|
}
|
|
|
|
async function uninstallComponent(mapping: MappingFileHandler, uuid: string) {
|
|
const componentMapping = mapping.get(uuid)
|
|
if (!componentMapping || !componentMapping.location) {
|
|
/** No mapping for component */
|
|
return
|
|
}
|
|
const result = await new FilesManager().deleteDir(path.join(Paths.userDataDir, componentMapping.location))
|
|
if (!result.isFailed()) {
|
|
mapping.remove(uuid)
|
|
}
|
|
}
|