feat: add desktop repo (#1071)
This commit is contained in:
75
packages/desktop/app/javascripts/Main/Packages/Networking.ts
Normal file
75
packages/desktop/app/javascripts/Main/Packages/Networking.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { IncomingMessage, net } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { pipeline as pipelineFn } from 'stream'
|
||||
import { promisify } from 'util'
|
||||
import { MessageType } from '../../../../test/TestIpcMessage'
|
||||
import { ensureDirectoryExists } from '../Utils/FileUtils'
|
||||
import { handleTestMessage } from '../Utils/Testing'
|
||||
import { isTesting } from '../Utils/Utils'
|
||||
|
||||
const pipeline = promisify(pipelineFn)
|
||||
|
||||
if (isTesting()) {
|
||||
handleTestMessage(MessageType.GetJSON, getJSON)
|
||||
handleTestMessage(MessageType.DownloadFile, downloadFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file to the specified destination.
|
||||
* @param filePath path to the saved file (will be created if it does
|
||||
* not exist)
|
||||
*/
|
||||
export async function downloadFile(url: string, filePath: string): Promise<void> {
|
||||
await ensureDirectoryExists(path.dirname(filePath))
|
||||
const response = await get(url)
|
||||
await pipeline(
|
||||
/**
|
||||
* IncomingMessage doesn't implement *every* property of ReadableStream
|
||||
* but still all the ones that pipeline needs
|
||||
* @see https://www.electronjs.org/docs/api/incoming-message
|
||||
*/
|
||||
response as any,
|
||||
fs.createWriteStream(filePath),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getJSON<T>(url: string): Promise<T | undefined> {
|
||||
const response = await get(url)
|
||||
let data = ''
|
||||
return new Promise((resolve, reject) => {
|
||||
response
|
||||
.on('data', (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
.on('error', reject)
|
||||
.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
resolve(parsed)
|
||||
} catch (error) {
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function get(url: string): Promise<IncomingMessage> {
|
||||
const enum Method {
|
||||
Get = 'GET',
|
||||
}
|
||||
const enum RedirectMode {
|
||||
Follow = 'follow',
|
||||
}
|
||||
|
||||
return new Promise<IncomingMessage>((resolve, reject) => {
|
||||
const request = net.request({
|
||||
url,
|
||||
method: Method.Get,
|
||||
redirect: RedirectMode.Follow,
|
||||
})
|
||||
request.on('response', resolve)
|
||||
request.on('error', reject)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
374
packages/desktop/app/javascripts/Main/Packages/PackageManager.ts
Normal file
374
packages/desktop/app/javascripts/Main/Packages/PackageManager.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import compareVersions from 'compare-versions'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { MessageToWebApp } from '../../Shared/IpcMessages'
|
||||
import {
|
||||
debouncedJSONDiskWriter,
|
||||
deleteDir,
|
||||
deleteDirContents,
|
||||
ensureDirectoryExists,
|
||||
extractNestedZip,
|
||||
FileDoesNotExist,
|
||||
readJSONFile,
|
||||
} from '../Utils/FileUtils'
|
||||
import { downloadFile, getJSON } from './Networking'
|
||||
import { Paths } from '../Types/Paths'
|
||||
import { AppName } from '../Strings'
|
||||
import { timeout } from '../Utils/Utils'
|
||||
import log from 'electron-log'
|
||||
import { Component, MappingFile, PackageManagerInterface, SyncTask, PackageInfo } from './PackageManagerInterface'
|
||||
|
||||
function logMessage(...message: any) {
|
||||
log.info('PackageManager:', ...message)
|
||||
}
|
||||
|
||||
function logError(...message: any) {
|
||||
console.error('PackageManager:', ...message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe component mapping manager that queues its disk writes
|
||||
*/
|
||||
class MappingFileHandler {
|
||||
static async create() {
|
||||
let mapping: MappingFile
|
||||
|
||||
try {
|
||||
const result = await readJSONFile<MappingFile>(Paths.extensionsMappingJson)
|
||||
mapping = result || {}
|
||||
} catch (error: any) {
|
||||
/**
|
||||
* Mapping file might be absent (first start, corrupted data)
|
||||
*/
|
||||
if (error.code === FileDoesNotExist) {
|
||||
await ensureDirectoryExists(path.dirname(Paths.extensionsMappingJson))
|
||||
} else {
|
||||
logError(error)
|
||||
}
|
||||
mapping = {}
|
||||
}
|
||||
|
||||
return new MappingFileHandler(mapping)
|
||||
}
|
||||
|
||||
constructor(private mapping: MappingFile) {}
|
||||
|
||||
get = (componendId: string) => {
|
||||
return this.mapping[componendId]
|
||||
}
|
||||
|
||||
set = (componentId: string, location: string, version: string) => {
|
||||
this.mapping[componentId] = {
|
||||
location,
|
||||
version,
|
||||
}
|
||||
this.writeToDisk()
|
||||
}
|
||||
|
||||
remove = (componentId: string) => {
|
||||
delete this.mapping[componentId]
|
||||
this.writeToDisk()
|
||||
}
|
||||
|
||||
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 readJSONFile<{ version: string }>(packagePath)
|
||||
if (!response) {
|
||||
return ''
|
||||
}
|
||||
this.set(component.uuid, paths.relativePath, response.version)
|
||||
return response.version
|
||||
}
|
||||
|
||||
private writeToDisk = debouncedJSONDiskWriter(100, Paths.extensionsMappingJson, () => this.mapping)
|
||||
}
|
||||
|
||||
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 === 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
} else {
|
||||
logMessage(`Installed component ${name} (${version})`)
|
||||
}
|
||||
webContents.send(MessageToWebApp.InstallComponentComplete, {
|
||||
component,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
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. */
|
||||
await ensureDirectoryExists(paths.absolutePath)
|
||||
await deleteDirContents(paths.absolutePath)
|
||||
})(),
|
||||
])
|
||||
|
||||
logMessage('Extracting', paths.downloadPath, 'to', paths.absolutePath)
|
||||
await extractNestedZip(paths.downloadPath, 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 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)
|
||||
return {
|
||||
relativePath,
|
||||
absolutePath: path.join(Paths.userDataDir, relativePath),
|
||||
downloadPath: path.join(Paths.tempDir, AppName, 'downloads', component.content!.name + '.zip'),
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallComponent(mapping: MappingFileHandler, uuid: string) {
|
||||
const componentMapping = mapping.get(uuid)
|
||||
if (!componentMapping || !componentMapping.location) {
|
||||
/** No mapping for component */
|
||||
return
|
||||
}
|
||||
await deleteDir(path.join(Paths.userDataDir, componentMapping.location))
|
||||
mapping.remove(uuid)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface PackageManagerInterface {
|
||||
syncComponents(components: Component[]): Promise<void>
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
uuid: string
|
||||
deleted: boolean
|
||||
content?: {
|
||||
name?: string
|
||||
autoupdateDisabled: boolean
|
||||
local_url?: string
|
||||
package_info: PackageInfo
|
||||
}
|
||||
}
|
||||
|
||||
export type PackageInfo = {
|
||||
identifier: string
|
||||
version: string
|
||||
download_url: string
|
||||
latest_url: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface SyncTask {
|
||||
components: Component[]
|
||||
}
|
||||
|
||||
export interface MappingFile {
|
||||
[key: string]: Readonly<ComponentMapping> | undefined
|
||||
}
|
||||
|
||||
export interface ComponentMapping {
|
||||
location: string
|
||||
version?: string
|
||||
}
|
||||
Reference in New Issue
Block a user