✨refactor: clean up core node packages
This commit is contained in:
parent
b538d57207
commit
c9c1ff1778
@ -9,9 +9,6 @@
|
||||
```js
|
||||
// Web / extension runtime
|
||||
import * as core from '@janhq/core'
|
||||
|
||||
// Node runtime
|
||||
import * as node from '@janhq/core/node'
|
||||
```
|
||||
|
||||
## Build an Extension
|
||||
|
||||
@ -25,9 +25,6 @@
|
||||
"@npmcli/arborist": "^7.1.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/pacote": "^11.1.7",
|
||||
"@types/request": "^2.48.12",
|
||||
"electron": "33.2.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-jest": "^27.9.0",
|
||||
"jest": "^30.0.3",
|
||||
|
||||
@ -15,36 +15,5 @@ export default defineConfig([
|
||||
NODE: JSON.stringify(`${pkgJson.name}/${pkgJson.node}`),
|
||||
VERSION: JSON.stringify(pkgJson.version),
|
||||
},
|
||||
},
|
||||
{
|
||||
input: 'src/node/index.ts',
|
||||
external: [
|
||||
'fs/promises',
|
||||
'path',
|
||||
'pacote',
|
||||
'@types/pacote',
|
||||
'@npmcli/arborist',
|
||||
'ulidx',
|
||||
'fs',
|
||||
'request',
|
||||
'crypto',
|
||||
'url',
|
||||
'http',
|
||||
'os',
|
||||
'util',
|
||||
'child_process',
|
||||
'electron',
|
||||
'request-progress',
|
||||
],
|
||||
output: {
|
||||
format: 'cjs',
|
||||
file: 'dist/node/index.cjs.js',
|
||||
sourcemap: true,
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.ts'],
|
||||
},
|
||||
platform: 'node',
|
||||
},
|
||||
}
|
||||
])
|
||||
|
||||
@ -1,24 +1,5 @@
|
||||
import { SystemInformation } from '../types'
|
||||
|
||||
/**
|
||||
* Execute a extension module function in main process
|
||||
*
|
||||
* @param extension extension name to import
|
||||
* @param method function name to execute
|
||||
* @param args arguments to pass to the function
|
||||
* @returns Promise<any>
|
||||
*
|
||||
*/
|
||||
const executeOnMain: (extension: string, method: string, ...args: any[]) => Promise<any> = (
|
||||
extension,
|
||||
method,
|
||||
...args
|
||||
) => {
|
||||
if ('electronAPI' in window && window.electronAPI)
|
||||
return globalThis.core?.api?.invokeExtensionFunc(extension, method, ...args)
|
||||
return () => {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Jan's data folder path.
|
||||
*
|
||||
@ -127,7 +108,6 @@ export type RegisterExtensionPoint = (
|
||||
* Functions exports
|
||||
*/
|
||||
export {
|
||||
executeOnMain,
|
||||
getJanDataFolderPath,
|
||||
openFileExplorer,
|
||||
getResourcePath,
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { executeOnMain, systemInformation, dirName, joinPath, getJanDataFolderPath } from '../../core'
|
||||
import { events } from '../../events'
|
||||
import { Model, ModelEvent } from '../../../types'
|
||||
import { OAIEngine } from './OAIEngine'
|
||||
@ -29,46 +28,9 @@ export abstract class LocalOAIEngine extends OAIEngine {
|
||||
/**
|
||||
* Load the model.
|
||||
*/
|
||||
async loadModel(model: Model & { file_path?: string }): Promise<void> {
|
||||
if (model.engine.toString() !== this.provider) return
|
||||
const modelFolder = 'file_path' in model && model.file_path ? await dirName(model.file_path) : await this.getModelFilePath(model.id)
|
||||
const systemInfo = await systemInformation()
|
||||
const res = await executeOnMain(
|
||||
this.nodeModule,
|
||||
this.loadModelFunctionName,
|
||||
{
|
||||
modelFolder,
|
||||
model,
|
||||
},
|
||||
systemInfo
|
||||
)
|
||||
|
||||
if (res?.error) {
|
||||
events.emit(ModelEvent.OnModelFail, { error: res.error })
|
||||
return Promise.reject(res.error)
|
||||
} else {
|
||||
this.loadedModel = model
|
||||
events.emit(ModelEvent.OnModelReady, model)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
async loadModel(model: Model & { file_path?: string }): Promise<void> {}
|
||||
/**
|
||||
* Stops the model.
|
||||
*/
|
||||
async unloadModel(model?: Model) {
|
||||
if (model?.engine && model.engine?.toString() !== this.provider) return Promise.resolve()
|
||||
|
||||
this.loadedModel = undefined
|
||||
await executeOnMain(this.nodeModule, this.unloadModelFunctionName).then(() => {
|
||||
events.emit(ModelEvent.OnModelStopped, {})
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy
|
||||
private getModelFilePath = async (
|
||||
id: string,
|
||||
): Promise<string> => {
|
||||
return joinPath([await getJanDataFolderPath(), 'models', id])
|
||||
}
|
||||
///
|
||||
async unloadModel(model?: Model) {}
|
||||
}
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
import { RequestAdapter } from './adapter';
|
||||
|
||||
it('should return undefined for unknown route', () => {
|
||||
const adapter = new RequestAdapter();
|
||||
const route = 'unknownRoute';
|
||||
|
||||
const result = adapter.process(route, 'arg1', 'arg2');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@ -1,37 +0,0 @@
|
||||
import {
|
||||
AppRoute,
|
||||
ExtensionRoute,
|
||||
FileManagerRoute,
|
||||
FileSystemRoute,
|
||||
} from '../../../types/api'
|
||||
import { FileSystem } from '../processors/fs'
|
||||
import { Extension } from '../processors/extension'
|
||||
import { FSExt } from '../processors/fsExt'
|
||||
import { App } from '../processors/app'
|
||||
|
||||
export class RequestAdapter {
|
||||
fileSystem: FileSystem
|
||||
extension: Extension
|
||||
fsExt: FSExt
|
||||
app: App
|
||||
|
||||
constructor(observer?: Function) {
|
||||
this.fileSystem = new FileSystem()
|
||||
this.extension = new Extension()
|
||||
this.fsExt = new FSExt()
|
||||
this.app = new App()
|
||||
}
|
||||
|
||||
// TODO: Clearer Factory pattern here
|
||||
process(route: string, ...args: any) {
|
||||
if (route in FileSystemRoute) {
|
||||
return this.fileSystem.process(route, ...args)
|
||||
} else if (route in ExtensionRoute) {
|
||||
return this.extension.process(route, ...args)
|
||||
} else if (route in FileManagerRoute) {
|
||||
return this.fsExt.process(route, ...args)
|
||||
} else if (route in AppRoute) {
|
||||
return this.app.process(route, ...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import { CoreRoutes } from '../../../types/api';
|
||||
import { RequestHandler } from './handler';
|
||||
import { RequestAdapter } from './adapter';
|
||||
|
||||
it('should not call handler if CoreRoutes is empty', () => {
|
||||
const mockHandler = jest.fn();
|
||||
const mockObserver = jest.fn();
|
||||
const requestHandler = new RequestHandler(mockHandler, mockObserver);
|
||||
|
||||
CoreRoutes.length = 0; // Ensure CoreRoutes is empty
|
||||
|
||||
requestHandler.handle();
|
||||
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should initialize handler and adapter correctly', () => {
|
||||
const mockHandler = jest.fn();
|
||||
const mockObserver = jest.fn();
|
||||
const requestHandler = new RequestHandler(mockHandler, mockObserver);
|
||||
|
||||
expect(requestHandler.handler).toBe(mockHandler);
|
||||
expect(requestHandler.adapter).toBeInstanceOf(RequestAdapter);
|
||||
});
|
||||
@ -1,20 +0,0 @@
|
||||
import { CoreRoutes } from '../../../types/api'
|
||||
import { RequestAdapter } from './adapter'
|
||||
|
||||
export type Handler = (route: string, args: any) => any
|
||||
|
||||
export class RequestHandler {
|
||||
handler: Handler
|
||||
adapter: RequestAdapter
|
||||
|
||||
constructor(handler: Handler, observer?: Function) {
|
||||
this.handler = handler
|
||||
this.adapter = new RequestAdapter(observer)
|
||||
}
|
||||
|
||||
handle() {
|
||||
CoreRoutes.map((route) => {
|
||||
this.handler(route, async (...args: any[]) => this.adapter.process(route, ...args))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from './common/handler'
|
||||
@ -1,6 +0,0 @@
|
||||
|
||||
import { Processor } from './Processor';
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(Processor).toBeDefined();
|
||||
});
|
||||
@ -1,3 +0,0 @@
|
||||
export abstract class Processor {
|
||||
abstract process(key: string, ...args: any[]): any
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
jest.mock('../../helper', () => ({
|
||||
...jest.requireActual('../../helper'),
|
||||
getJanDataFolderPath: () => './app',
|
||||
}))
|
||||
import { App } from './app'
|
||||
|
||||
it('should correctly retrieve basename', () => {
|
||||
const app = new App()
|
||||
const result = app.baseName('/path/to/file.txt')
|
||||
expect(result).toBe('file.txt')
|
||||
})
|
||||
|
||||
it('should correctly identify subdirectories', () => {
|
||||
const app = new App()
|
||||
const basePath = process.platform === 'win32' ? 'C:\\path\\to' : '/path/to'
|
||||
const subPath =
|
||||
process.platform === 'win32' ? 'C:\\path\\to\\subdir' : '/path/to/subdir'
|
||||
const result = app.isSubdirectory(basePath, subPath)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should correctly join multiple paths', () => {
|
||||
const app = new App()
|
||||
const result = app.joinPath(['path', 'to', 'file'])
|
||||
const expectedPath =
|
||||
process.platform === 'win32' ? 'path\\to\\file' : 'path/to/file'
|
||||
expect(result).toBe(expectedPath)
|
||||
})
|
||||
|
||||
it('should call correct function with provided arguments using process method', () => {
|
||||
const app = new App()
|
||||
const mockFunc = jest.fn()
|
||||
app.joinPath = mockFunc
|
||||
app.process('joinPath', ['path1', 'path2'])
|
||||
expect(mockFunc).toHaveBeenCalledWith(['path1', 'path2'])
|
||||
})
|
||||
|
||||
it('should retrieve the directory name from a file path (Unix/Windows)', async () => {
|
||||
const app = new App()
|
||||
const path = 'C:/Users/John Doe/Desktop/file.txt'
|
||||
expect(await app.dirName(path)).toBe('C:/Users/John Doe/Desktop')
|
||||
})
|
||||
|
||||
it('should retrieve the directory name when using file protocol', async () => {
|
||||
const app = new App()
|
||||
const path = 'file:/models/file.txt'
|
||||
expect(await app.dirName(path)).toBe(
|
||||
process.platform === 'win32' ? 'app\\models' : 'app/models'
|
||||
)
|
||||
})
|
||||
@ -1,83 +0,0 @@
|
||||
import { basename, dirname, isAbsolute, join, relative } from 'path'
|
||||
|
||||
import { Processor } from './Processor'
|
||||
import {
|
||||
log as writeLog,
|
||||
getAppConfigurations as appConfiguration,
|
||||
updateAppConfiguration,
|
||||
normalizeFilePath,
|
||||
getJanDataFolderPath,
|
||||
} from '../../helper'
|
||||
import { readdirSync, readFileSync } from 'fs'
|
||||
|
||||
export class App implements Processor {
|
||||
observer?: Function
|
||||
|
||||
constructor(observer?: Function) {
|
||||
this.observer = observer
|
||||
}
|
||||
|
||||
process(key: string, ...args: any[]): any {
|
||||
const instance = this as any
|
||||
const func = instance[key]
|
||||
return func(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins multiple paths together, respect to the current OS.
|
||||
*/
|
||||
joinPath(args: any) {
|
||||
return join(...('args' in args ? args.args : args))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dirname of a file path.
|
||||
* @param path - The file path to retrieve dirname.
|
||||
*/
|
||||
dirName(path: string) {
|
||||
const arg =
|
||||
path.startsWith(`file:/`) || path.startsWith(`file:\\`)
|
||||
? join(getJanDataFolderPath(), normalizeFilePath(path))
|
||||
: path
|
||||
return dirname(arg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given path is a subdirectory of the given directory.
|
||||
*
|
||||
* @param from - The path to check.
|
||||
* @param to - The directory to check against.
|
||||
*/
|
||||
isSubdirectory(from: any, to: any) {
|
||||
const rel = relative(from, to)
|
||||
const isSubdir = rel && !rel.startsWith('..') && !isAbsolute(rel)
|
||||
|
||||
if (isSubdir === '') return false
|
||||
else return isSubdir
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve basename from given path, respect to the current OS.
|
||||
*/
|
||||
baseName(args: any) {
|
||||
return basename(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message to log file.
|
||||
*/
|
||||
log(args: any) {
|
||||
writeLog(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app configurations.
|
||||
*/
|
||||
getAppConfigurations() {
|
||||
return appConfiguration()
|
||||
}
|
||||
|
||||
async updateAppConfiguration(args: any) {
|
||||
await updateAppConfiguration(args)
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import { Extension } from './extension';
|
||||
|
||||
it('should call function associated with key in process method', () => {
|
||||
const mockFunc = jest.fn();
|
||||
const extension = new Extension();
|
||||
(extension as any).testKey = mockFunc;
|
||||
extension.process('testKey', 'arg1', 'arg2');
|
||||
expect(mockFunc).toHaveBeenCalledWith('arg1', 'arg2');
|
||||
});
|
||||
|
||||
|
||||
it('should_handle_empty_extension_list_for_install', async () => {
|
||||
jest.mock('../../extension/store', () => ({
|
||||
installExtensions: jest.fn(() => Promise.resolve([])),
|
||||
}));
|
||||
const extension = new Extension();
|
||||
const result = await extension.installExtension([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
it('should_handle_empty_extension_list_for_update', async () => {
|
||||
jest.mock('../../extension/store', () => ({
|
||||
getExtension: jest.fn(() => ({ update: jest.fn(() => Promise.resolve(true)) })),
|
||||
}));
|
||||
const extension = new Extension();
|
||||
const result = await extension.updateExtension([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
it('should_handle_empty_extension_list', async () => {
|
||||
jest.mock('../../extension/store', () => ({
|
||||
getExtension: jest.fn(() => ({ uninstall: jest.fn(() => Promise.resolve(true)) })),
|
||||
removeExtension: jest.fn(),
|
||||
}));
|
||||
const extension = new Extension();
|
||||
const result = await extension.uninstallExtension([]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@ -1,88 +0,0 @@
|
||||
import { readdirSync } from 'fs'
|
||||
import { join, extname } from 'path'
|
||||
|
||||
import { Processor } from './Processor'
|
||||
import { ModuleManager } from '../../helper/module'
|
||||
import { getJanExtensionsPath as getPath } from '../../helper'
|
||||
import {
|
||||
getActiveExtensions as getExtensions,
|
||||
getExtension,
|
||||
removeExtension,
|
||||
installExtensions,
|
||||
} from '../../extension/store'
|
||||
import { appResourcePath } from '../../helper/path'
|
||||
|
||||
export class Extension implements Processor {
|
||||
observer?: Function
|
||||
|
||||
constructor(observer?: Function) {
|
||||
this.observer = observer
|
||||
}
|
||||
|
||||
process(key: string, ...args: any[]): any {
|
||||
const instance = this as any
|
||||
const func = instance[key]
|
||||
return func(...args)
|
||||
}
|
||||
|
||||
invokeExtensionFunc(modulePath: string, method: string, ...params: any[]) {
|
||||
const module = require(join(getPath(), modulePath))
|
||||
ModuleManager.instance.setModule(modulePath, module)
|
||||
|
||||
if (typeof module[method] === 'function') {
|
||||
return module[method](...params)
|
||||
} else {
|
||||
console.debug(module[method])
|
||||
console.error(`Function "${method}" does not exist in the module.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the paths of the base extensions.
|
||||
* @returns An array of paths to the base extensions.
|
||||
*/
|
||||
async baseExtensions() {
|
||||
const baseExtensionPath = join(appResourcePath(), 'pre-install')
|
||||
return readdirSync(baseExtensionPath)
|
||||
.filter((file) => extname(file) === '.tgz')
|
||||
.map((file) => join(baseExtensionPath, file))
|
||||
}
|
||||
|
||||
/**MARK: Extension Manager handlers */
|
||||
async installExtension(extensions: any) {
|
||||
// Install and activate all provided extensions
|
||||
const installed = await installExtensions(extensions)
|
||||
return JSON.parse(JSON.stringify(installed))
|
||||
}
|
||||
|
||||
// Register IPC route to uninstall a extension
|
||||
async uninstallExtension(extensions: any) {
|
||||
// Uninstall all provided extensions
|
||||
for (const ext of extensions) {
|
||||
const extension = getExtension(ext)
|
||||
await extension.uninstall()
|
||||
if (extension.name) removeExtension(extension.name)
|
||||
}
|
||||
|
||||
// Reload all renderer pages if needed
|
||||
return true
|
||||
}
|
||||
|
||||
// Register IPC route to update a extension
|
||||
async updateExtension(extensions: any) {
|
||||
// Update all provided extensions
|
||||
const updated: any[] = []
|
||||
for (const ext of extensions) {
|
||||
const extension = getExtension(ext)
|
||||
const res = await extension.update()
|
||||
if (res) updated.push(extension)
|
||||
}
|
||||
|
||||
// Reload all renderer pages if needed
|
||||
return JSON.parse(JSON.stringify(updated))
|
||||
}
|
||||
|
||||
getActiveExtensions() {
|
||||
return JSON.parse(JSON.stringify(getExtensions()))
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
import { FileSystem } from './fs';
|
||||
|
||||
it('should throw an error when the route does not exist in process', async () => {
|
||||
const fileSystem = new FileSystem();
|
||||
await expect(fileSystem.process('nonExistentRoute', 'arg1')).rejects.toThrow();
|
||||
});
|
||||
|
||||
|
||||
it('should throw an error for invalid argument in mkdir', async () => {
|
||||
const fileSystem = new FileSystem();
|
||||
expect(() => fileSystem.mkdir(123)).toThrow('mkdir error: Invalid argument [123]');
|
||||
});
|
||||
|
||||
|
||||
it('should throw an error for invalid argument in rm', async () => {
|
||||
const fileSystem = new FileSystem();
|
||||
expect(() => fileSystem.rm(123)).toThrow('rm error: Invalid argument [123]');
|
||||
});
|
||||
@ -1,94 +0,0 @@
|
||||
import { join, resolve } from 'path'
|
||||
import { normalizeFilePath } from '../../helper/path'
|
||||
import { getJanDataFolderPath } from '../../helper'
|
||||
import { Processor } from './Processor'
|
||||
import fs from 'fs'
|
||||
|
||||
export class FileSystem implements Processor {
|
||||
observer?: Function
|
||||
private static moduleName = 'fs'
|
||||
|
||||
constructor(observer?: Function) {
|
||||
this.observer = observer
|
||||
}
|
||||
|
||||
process(route: string, ...args: any): any {
|
||||
const instance = this as any
|
||||
const func = instance[route]
|
||||
if (func) {
|
||||
return func(...args)
|
||||
} else {
|
||||
return import(FileSystem.moduleName).then((mdl) =>
|
||||
mdl[route](
|
||||
...args.map((arg: any, index: number) => {
|
||||
const arg0 = args[0]
|
||||
if ('args' in arg0) arg = arg0.args
|
||||
if (Array.isArray(arg)) arg = arg[0]
|
||||
if (index !== 0) {
|
||||
return arg
|
||||
}
|
||||
if (index === 0 && typeof arg !== 'string') {
|
||||
throw new Error(`Invalid argument ${JSON.stringify(args)}`)
|
||||
}
|
||||
const path =
|
||||
arg.startsWith(`file:/`) || arg.startsWith(`file:\\`)
|
||||
? join(getJanDataFolderPath(), normalizeFilePath(arg))
|
||||
: arg
|
||||
|
||||
if (path.startsWith(`http://`) || path.startsWith(`https://`)) {
|
||||
return path
|
||||
}
|
||||
const absolutePath = resolve(path)
|
||||
return absolutePath
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
rm(...args: any): Promise<void> {
|
||||
if (typeof args[0] !== 'string') {
|
||||
throw new Error(`rm error: Invalid argument ${JSON.stringify(args)}`)
|
||||
}
|
||||
|
||||
let path = args[0]
|
||||
if (path.startsWith(`file:/`) || path.startsWith(`file:\\`)) {
|
||||
path = join(getJanDataFolderPath(), normalizeFilePath(path))
|
||||
}
|
||||
|
||||
const absolutePath = resolve(path)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.rm(absolutePath, { recursive: true, force: true }, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
mkdir(...args: any): Promise<void> {
|
||||
if (typeof args[0] !== 'string') {
|
||||
throw new Error(`mkdir error: Invalid argument ${JSON.stringify(args)}`)
|
||||
}
|
||||
|
||||
let path = args[0]
|
||||
if (path.startsWith(`file:/`) || path.startsWith(`file:\\`)) {
|
||||
path = join(getJanDataFolderPath(), normalizeFilePath(path))
|
||||
}
|
||||
|
||||
const absolutePath = resolve(path)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdir(absolutePath, { recursive: true }, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import { FSExt } from './fsExt';
|
||||
import { defaultAppConfig } from '../../helper';
|
||||
|
||||
it('should handle errors in writeBlob', () => {
|
||||
const fsExt = new FSExt();
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
fsExt.writeBlob('invalid-path', 'data');
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should call correct function in process method', () => {
|
||||
const fsExt = new FSExt();
|
||||
const mockFunction = jest.fn();
|
||||
(fsExt as any).mockFunction = mockFunction;
|
||||
fsExt.process('mockFunction', 'arg1', 'arg2');
|
||||
expect(mockFunction).toHaveBeenCalledWith('arg1', 'arg2');
|
||||
});
|
||||
|
||||
|
||||
it('should return correct user home path', () => {
|
||||
const fsExt = new FSExt();
|
||||
const userHomePath = fsExt.getUserHomePath();
|
||||
expect(userHomePath).toBe(defaultAppConfig().data_folder);
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('should return empty array when no files are provided', async () => {
|
||||
const fsExt = new FSExt();
|
||||
const result = await fsExt.getGgufFiles([]);
|
||||
expect(result.supportedFiles).toEqual([]);
|
||||
expect(result.unsupportedFiles).toEqual([]);
|
||||
});
|
||||
@ -1,130 +0,0 @@
|
||||
import { basename, join } from 'path'
|
||||
import fs, { readdirSync } from 'fs'
|
||||
import { appResourcePath, normalizeFilePath } from '../../helper/path'
|
||||
import { defaultAppConfig, getJanDataFolderPath, getJanDataFolderPath as getPath } from '../../helper'
|
||||
import { Processor } from './Processor'
|
||||
import { FileStat } from '../../../types'
|
||||
|
||||
export class FSExt implements Processor {
|
||||
observer?: Function
|
||||
|
||||
constructor(observer?: Function) {
|
||||
this.observer = observer
|
||||
}
|
||||
|
||||
process(key: string, ...args: any): any {
|
||||
const instance = this as any
|
||||
const func = instance[key]
|
||||
return func(...args)
|
||||
}
|
||||
|
||||
// Handles the 'getJanDataFolderPath' IPC event. This event is triggered to get the user space path.
|
||||
getJanDataFolderPath() {
|
||||
return Promise.resolve(getPath())
|
||||
}
|
||||
|
||||
// Handles the 'getResourcePath' IPC event. This event is triggered to get the resource path.
|
||||
getResourcePath() {
|
||||
return appResourcePath()
|
||||
}
|
||||
|
||||
// Handles the 'getUserHomePath' IPC event. This event is triggered to get the user app data path.
|
||||
// CAUTION: This would not return OS home path but the app data path.
|
||||
getUserHomePath() {
|
||||
return defaultAppConfig().data_folder
|
||||
}
|
||||
|
||||
// handle fs is directory here
|
||||
fileStat(path: string, outsideJanDataFolder?: boolean) {
|
||||
const normalizedPath = normalizeFilePath(path)
|
||||
|
||||
const fullPath = outsideJanDataFolder
|
||||
? normalizedPath
|
||||
: join(getJanDataFolderPath(), normalizedPath)
|
||||
const isExist = fs.existsSync(fullPath)
|
||||
if (!isExist) return undefined
|
||||
|
||||
const isDirectory = fs.lstatSync(fullPath).isDirectory()
|
||||
const size = fs.statSync(fullPath).size
|
||||
|
||||
const fileStat: FileStat = {
|
||||
isDirectory,
|
||||
size,
|
||||
}
|
||||
|
||||
return fileStat
|
||||
}
|
||||
|
||||
writeBlob(path: string, data: any) {
|
||||
try {
|
||||
const normalizedPath = normalizeFilePath(path)
|
||||
|
||||
const dataBuffer = Buffer.from(data, 'base64')
|
||||
const writePath = join(getJanDataFolderPath(), normalizedPath)
|
||||
fs.writeFileSync(writePath, dataBuffer)
|
||||
} catch (err) {
|
||||
console.error(`writeFile ${path} result: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
copyFile(src: string, dest: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.copyFile(src, dest, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getGgufFiles(paths: string[]) {
|
||||
const sanitizedFilePaths: {
|
||||
path: string
|
||||
name: string
|
||||
size: number
|
||||
}[] = []
|
||||
for (const filePath of paths) {
|
||||
const normalizedPath = normalizeFilePath(filePath)
|
||||
|
||||
const isExist = fs.existsSync(normalizedPath)
|
||||
if (!isExist) continue
|
||||
const fileStats = fs.statSync(normalizedPath)
|
||||
if (!fileStats) continue
|
||||
if (!fileStats.isDirectory()) {
|
||||
const fileName = await basename(normalizedPath)
|
||||
sanitizedFilePaths.push({
|
||||
path: normalizedPath,
|
||||
name: fileName,
|
||||
size: fileStats.size,
|
||||
})
|
||||
} else {
|
||||
// allowing only one level of directory
|
||||
const files = await readdirSync(normalizedPath)
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = await join(normalizedPath, file)
|
||||
const fileStats = await fs.statSync(fullPath)
|
||||
if (!fileStats || fileStats.isDirectory()) continue
|
||||
|
||||
sanitizedFilePaths.push({
|
||||
path: fullPath,
|
||||
name: file,
|
||||
size: fileStats.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const unsupportedFiles = sanitizedFilePaths.filter(
|
||||
(file) => !file.path.endsWith('.gguf')
|
||||
)
|
||||
const supportedFiles = sanitizedFilePaths.filter((file) =>
|
||||
file.path.endsWith('.gguf')
|
||||
)
|
||||
return {
|
||||
unsupportedFiles,
|
||||
supportedFiles,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,122 +0,0 @@
|
||||
import Extension from './extension';
|
||||
import { join } from 'path';
|
||||
import 'pacote';
|
||||
|
||||
it('should set active and call emitUpdate', () => {
|
||||
const extension = new Extension();
|
||||
extension.emitUpdate = jest.fn();
|
||||
|
||||
extension.setActive(true);
|
||||
|
||||
expect(extension._active).toBe(true);
|
||||
expect(extension.emitUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should return correct specifier', () => {
|
||||
const origin = 'test-origin';
|
||||
const options = { version: '1.0.0' };
|
||||
const extension = new Extension(origin, options);
|
||||
|
||||
expect(extension.specifier).toBe('test-origin@1.0.0');
|
||||
});
|
||||
|
||||
|
||||
it('should set origin and installOptions in constructor', () => {
|
||||
const origin = 'test-origin';
|
||||
const options = { someOption: true };
|
||||
const extension = new Extension(origin, options);
|
||||
|
||||
expect(extension.origin).toBe(origin);
|
||||
expect(extension.installOptions.someOption).toBe(true);
|
||||
expect(extension.installOptions.fullMetadata).toBe(true); // default option
|
||||
});
|
||||
|
||||
it('should install extension and set url', async () => {
|
||||
const origin = 'test-origin';
|
||||
const options = {};
|
||||
const extension = new Extension(origin, options);
|
||||
|
||||
const mockManifest = {
|
||||
name: 'test-name',
|
||||
productName: 'Test Product',
|
||||
version: '1.0.0',
|
||||
main: 'index.js',
|
||||
description: 'Test description'
|
||||
};
|
||||
|
||||
jest.mock('pacote', () => ({
|
||||
manifest: jest.fn().mockResolvedValue(mockManifest),
|
||||
extract: jest.fn().mockResolvedValue(null)
|
||||
}));
|
||||
|
||||
extension.emitUpdate = jest.fn();
|
||||
await extension._install();
|
||||
|
||||
expect(extension.url).toBe('extension://test-name/index.js');
|
||||
expect(extension.emitUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should call all listeners in emitUpdate', () => {
|
||||
const extension = new Extension();
|
||||
const callback1 = jest.fn();
|
||||
const callback2 = jest.fn();
|
||||
|
||||
extension.subscribe('listener1', callback1);
|
||||
extension.subscribe('listener2', callback2);
|
||||
|
||||
extension.emitUpdate();
|
||||
|
||||
expect(callback1).toHaveBeenCalledWith(extension);
|
||||
expect(callback2).toHaveBeenCalledWith(extension);
|
||||
});
|
||||
|
||||
|
||||
it('should remove listener in unsubscribe', () => {
|
||||
const extension = new Extension();
|
||||
const callback = jest.fn();
|
||||
|
||||
extension.subscribe('testListener', callback);
|
||||
extension.unsubscribe('testListener');
|
||||
|
||||
expect(extension.listeners['testListener']).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
it('should add listener in subscribe', () => {
|
||||
const extension = new Extension();
|
||||
const callback = jest.fn();
|
||||
|
||||
extension.subscribe('testListener', callback);
|
||||
|
||||
expect(extension.listeners['testListener']).toBe(callback);
|
||||
});
|
||||
|
||||
|
||||
it('should set properties from manifest', async () => {
|
||||
const origin = 'test-origin';
|
||||
const options = {};
|
||||
const extension = new Extension(origin, options);
|
||||
|
||||
const mockManifest = {
|
||||
name: 'test-name',
|
||||
productName: 'Test Product',
|
||||
version: '1.0.0',
|
||||
main: 'index.js',
|
||||
description: 'Test description'
|
||||
};
|
||||
|
||||
jest.mock('pacote', () => ({
|
||||
manifest: jest.fn().mockResolvedValue(mockManifest)
|
||||
}));
|
||||
|
||||
await extension.getManifest();
|
||||
|
||||
expect(extension.name).toBe('test-name');
|
||||
expect(extension.productName).toBe('Test Product');
|
||||
expect(extension.version).toBe('1.0.0');
|
||||
expect(extension.main).toBe('index.js');
|
||||
expect(extension.description).toBe('Test description');
|
||||
});
|
||||
|
||||
@ -1,209 +0,0 @@
|
||||
import { rmdirSync } from 'fs'
|
||||
import { resolve, join } from 'path'
|
||||
import { ExtensionManager } from './manager'
|
||||
|
||||
/**
|
||||
* An NPM package that can be used as an extension.
|
||||
* Used to hold all the information and functions necessary to handle the extension lifecycle.
|
||||
*/
|
||||
export default class Extension {
|
||||
/**
|
||||
* @property {string} origin Original specification provided to fetch the package.
|
||||
* @property {Object} installOptions Options provided to pacote when fetching the manifest.
|
||||
* @property {name} name The name of the extension as defined in the manifest.
|
||||
* @property {name} productName The display name of the extension as defined in the manifest.
|
||||
* @property {string} url Electron URL where the package can be accessed.
|
||||
* @property {string} version Version of the package as defined in the manifest.
|
||||
* @property {string} main The entry point as defined in the main entry of the manifest.
|
||||
* @property {string} description The description of extension as defined in the manifest.
|
||||
*/
|
||||
origin?: string
|
||||
installOptions: any
|
||||
name?: string
|
||||
productName?: string
|
||||
url?: string
|
||||
version?: string
|
||||
main?: string
|
||||
description?: string
|
||||
|
||||
/** @private */
|
||||
_active = false
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @property {Object.<string, Function>} #listeners A list of callbacks to be executed when the Extension is updated.
|
||||
*/
|
||||
listeners: Record<string, (obj: any) => void> = {}
|
||||
|
||||
/**
|
||||
* Set installOptions with defaults for options that have not been provided.
|
||||
* @param {string} [origin] Original specification provided to fetch the package.
|
||||
* @param {Object} [options] Options provided to pacote when fetching the manifest.
|
||||
*/
|
||||
constructor(origin?: string, options = {}) {
|
||||
const Arborist = require('@npmcli/arborist')
|
||||
const defaultOpts = {
|
||||
version: false,
|
||||
fullMetadata: true,
|
||||
Arborist,
|
||||
}
|
||||
|
||||
this.origin = origin
|
||||
this.installOptions = { ...defaultOpts, ...options }
|
||||
}
|
||||
|
||||
/**
|
||||
* Package name with version number.
|
||||
* @type {string}
|
||||
*/
|
||||
get specifier() {
|
||||
return (
|
||||
this.origin +
|
||||
(this.installOptions.version ? '@' + this.installOptions.version : '')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the extension should be registered with its activation points.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get active() {
|
||||
return this._active
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Package details based on it's manifest
|
||||
* @returns {Promise.<Boolean>} Resolves to true when the action completed
|
||||
*/
|
||||
async getManifest() {
|
||||
// Get the package's manifest (package.json object)
|
||||
try {
|
||||
const pacote = require('pacote')
|
||||
return pacote
|
||||
.manifest(this.specifier, this.installOptions)
|
||||
.then((mnf: any) => {
|
||||
// set the Package properties based on the it's manifest
|
||||
this.name = mnf.name
|
||||
this.productName = mnf.productName as string | undefined
|
||||
this.version = mnf.version
|
||||
this.main = mnf.main
|
||||
this.description = mnf.description
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Package ${this.origin} does not contain a valid manifest: ${error}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract extension to extensions folder.
|
||||
* @returns {Promise.<Extension>} This extension
|
||||
* @private
|
||||
*/
|
||||
async _install() {
|
||||
try {
|
||||
// import the manifest details
|
||||
await this.getManifest()
|
||||
|
||||
// Install the package in a child folder of the given folder
|
||||
const pacote = require('pacote')
|
||||
await pacote.extract(
|
||||
this.specifier,
|
||||
join(
|
||||
ExtensionManager.instance.getExtensionsPath() ?? '',
|
||||
this.name ?? ''
|
||||
),
|
||||
this.installOptions
|
||||
)
|
||||
|
||||
// Set the url using the custom extensions protocol
|
||||
this.url = `extension://${this.name}/${this.main}`
|
||||
|
||||
this.emitUpdate()
|
||||
} catch (err) {
|
||||
// Ensure the extension is not stored and the folder is removed if the installation fails
|
||||
this.setActive(false)
|
||||
throw err
|
||||
}
|
||||
|
||||
return [this]
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to updates of this extension
|
||||
* @param {string} name name of the callback to register
|
||||
* @param {callback} cb The function to execute on update
|
||||
*/
|
||||
subscribe(name: string, cb: () => void) {
|
||||
this.listeners[name] = cb
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove subscription
|
||||
* @param {string} name name of the callback to remove
|
||||
*/
|
||||
unsubscribe(name: string) {
|
||||
delete this.listeners[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute listeners
|
||||
*/
|
||||
emitUpdate() {
|
||||
for (const cb in this.listeners) {
|
||||
this.listeners[cb].call(null, this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates and install if available.
|
||||
* @param {string} version The version to update to.
|
||||
* @returns {boolean} Whether an update was performed.
|
||||
*/
|
||||
async update(version = false) {
|
||||
if (await this.isUpdateAvailable()) {
|
||||
this.installOptions.version = version
|
||||
await this._install()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a new version of the extension is available at the origin.
|
||||
* @returns the latest available version if a new version is available or false if not.
|
||||
*/
|
||||
async isUpdateAvailable() {
|
||||
const pacote = require('pacote')
|
||||
if (this.origin) {
|
||||
return pacote.manifest(this.origin).then((mnf: any) => {
|
||||
return mnf.version !== this.version ? mnf.version : false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove extension and refresh renderers.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async uninstall(): Promise<void> {
|
||||
const path = ExtensionManager.instance.getExtensionsPath()
|
||||
const extPath = resolve(path ?? '', this.name ?? '')
|
||||
rmdirSync(extPath, { recursive: true })
|
||||
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a extension's active state. This determines if a extension should be loaded on initialisation.
|
||||
* @param {boolean} active State to set _active to
|
||||
* @returns {Extension} This extension
|
||||
*/
|
||||
setActive(active: boolean) {
|
||||
this._active = active
|
||||
this.emitUpdate()
|
||||
return this
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
|
||||
|
||||
import { useExtensions } from './index'
|
||||
|
||||
test('testUseExtensionsMissingPath', () => {
|
||||
expect(() => useExtensions(undefined as any)).toThrowError('A path to the extensions folder is required to use extensions')
|
||||
})
|
||||
@ -1,136 +0,0 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { normalize } from 'path'
|
||||
|
||||
import Extension from './extension'
|
||||
import {
|
||||
getAllExtensions,
|
||||
removeExtension,
|
||||
persistExtensions,
|
||||
installExtensions,
|
||||
getExtension,
|
||||
getActiveExtensions,
|
||||
addExtension,
|
||||
} from './store'
|
||||
import { ExtensionManager } from './manager'
|
||||
|
||||
export function init(options: any) {
|
||||
// Create extensions protocol to serve extensions to renderer
|
||||
registerExtensionProtocol()
|
||||
|
||||
// perform full setup if extensionsPath is provided
|
||||
if (options.extensionsPath) {
|
||||
return useExtensions(options.extensionsPath)
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create extensions protocol to provide extensions to renderer
|
||||
* @private
|
||||
* @returns {boolean} Whether the protocol registration was successful
|
||||
*/
|
||||
async function registerExtensionProtocol() {
|
||||
let electron: any = undefined
|
||||
|
||||
try {
|
||||
const moduleName = 'electron'
|
||||
electron = await import(moduleName)
|
||||
} catch (err) {
|
||||
console.error('Electron is not available')
|
||||
}
|
||||
const extensionPath = ExtensionManager.instance.getExtensionsPath()
|
||||
if (electron && electron.protocol) {
|
||||
return electron.protocol?.registerFileProtocol('extension', (request: any, callback: any) => {
|
||||
const entry = request.url.substr('extension://'.length - 1)
|
||||
|
||||
const url = normalize(extensionPath + entry)
|
||||
callback({ path: url })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set extensions up to run from the extensionPath folder if it is provided and
|
||||
* load extensions persisted in that folder.
|
||||
* @param {string} extensionsPath Path to the extensions folder. Required if not yet set up.
|
||||
* @returns {extensionManager} A set of functions used to manage the extension lifecycle.
|
||||
*/
|
||||
export function useExtensions(extensionsPath: string) {
|
||||
if (!extensionsPath) throw Error('A path to the extensions folder is required to use extensions')
|
||||
// Store the path to the extensions folder
|
||||
ExtensionManager.instance.setExtensionsPath(extensionsPath)
|
||||
|
||||
// Remove any registered extensions
|
||||
for (const extension of getAllExtensions()) {
|
||||
if (extension.name) removeExtension(extension.name, false)
|
||||
}
|
||||
|
||||
// Read extension list from extensions folder
|
||||
const extensions = JSON.parse(
|
||||
readFileSync(ExtensionManager.instance.getExtensionsFile(), 'utf-8')
|
||||
)
|
||||
try {
|
||||
// Create and store a Extension instance for each extension in list
|
||||
for (const p in extensions) {
|
||||
loadExtension(extensions[p])
|
||||
}
|
||||
persistExtensions()
|
||||
} catch (error) {
|
||||
// Throw meaningful error if extension loading fails
|
||||
throw new Error(
|
||||
'Could not successfully rebuild list of installed extensions.\n' +
|
||||
error +
|
||||
'\nPlease check the extensions.json file in the extensions folder.'
|
||||
)
|
||||
}
|
||||
|
||||
// Return the extension lifecycle functions
|
||||
return getStore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the given extension object. If it is marked for uninstalling, the extension files are removed.
|
||||
* Otherwise a Extension instance for the provided object is created and added to the store.
|
||||
* @private
|
||||
* @param {Object} ext Extension info
|
||||
*/
|
||||
function loadExtension(ext: any) {
|
||||
// Create new extension, populate it with ext details and save it to the store
|
||||
const extension = new Extension()
|
||||
|
||||
for (const key in ext) {
|
||||
if (Object.prototype.hasOwnProperty.call(ext, key)) {
|
||||
// Use Object.defineProperty to set the properties as writable
|
||||
Object.defineProperty(extension, key, {
|
||||
value: ext[key],
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
addExtension(extension, false)
|
||||
extension.subscribe('pe-persist', persistExtensions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the publicly available store functions.
|
||||
* @returns {extensionManager} A set of functions used to manage the extension lifecycle.
|
||||
*/
|
||||
export function getStore() {
|
||||
if (!ExtensionManager.instance.getExtensionsFile()) {
|
||||
throw new Error(
|
||||
'The extension path has not yet been set up. Please run useExtensions before accessing the store'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
installExtensions,
|
||||
getExtension,
|
||||
getAllExtensions,
|
||||
getActiveExtensions,
|
||||
removeExtension,
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import { join } from 'path';
|
||||
import { ExtensionManager } from './manager';
|
||||
|
||||
it('should throw an error when an invalid path is provided', () => {
|
||||
const manager = new ExtensionManager();
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
expect(() => manager.setExtensionsPath('')).toThrow('Invalid path provided to the extensions folder');
|
||||
});
|
||||
|
||||
|
||||
it('should return an empty string when extensionsPath is not set', () => {
|
||||
const manager = new ExtensionManager();
|
||||
expect(manager.getExtensionsFile()).toBe(join('', 'extensions.json'));
|
||||
});
|
||||
|
||||
|
||||
it('should return undefined if no path is set', () => {
|
||||
const manager = new ExtensionManager();
|
||||
expect(manager.getExtensionsPath()).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
it('should return the singleton instance', () => {
|
||||
const instance1 = new ExtensionManager();
|
||||
const instance2 = new ExtensionManager();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
@ -1,45 +0,0 @@
|
||||
import { join, resolve } from 'path'
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
|
||||
/**
|
||||
* Manages extension installation and migration.
|
||||
*/
|
||||
|
||||
export class ExtensionManager {
|
||||
public static instance: ExtensionManager = new ExtensionManager()
|
||||
|
||||
private extensionsPath: string | undefined
|
||||
|
||||
constructor() {
|
||||
if (ExtensionManager.instance) {
|
||||
return ExtensionManager.instance
|
||||
}
|
||||
}
|
||||
|
||||
getExtensionsPath(): string | undefined {
|
||||
return this.extensionsPath
|
||||
}
|
||||
|
||||
setExtensionsPath(extPath: string) {
|
||||
// Create folder if it does not exist
|
||||
let extDir
|
||||
try {
|
||||
extDir = resolve(extPath)
|
||||
if (extDir.length < 2) throw new Error()
|
||||
|
||||
if (!existsSync(extDir)) mkdirSync(extDir)
|
||||
|
||||
const extensionsJson = join(extDir, 'extensions.json')
|
||||
if (!existsSync(extensionsJson)) writeFileSync(extensionsJson, '{}')
|
||||
|
||||
this.extensionsPath = extDir
|
||||
} catch (error) {
|
||||
throw new Error('Invalid path provided to the extensions folder')
|
||||
}
|
||||
}
|
||||
|
||||
getExtensionsFile() {
|
||||
return join(this.extensionsPath ?? '', 'extensions.json')
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
import { getAllExtensions } from './store';
|
||||
import { getActiveExtensions } from './store';
|
||||
import { getExtension } from './store';
|
||||
|
||||
test('should return empty array when no extensions added', () => {
|
||||
expect(getAllExtensions()).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
test('should throw error when extension does not exist', () => {
|
||||
expect(() => getExtension('nonExistentExtension')).toThrow('Extension nonExistentExtension does not exist');
|
||||
});
|
||||
|
||||
import { addExtension } from './store';
|
||||
import Extension from './extension';
|
||||
|
||||
test('should return all extensions when multiple extensions added', () => {
|
||||
const ext1 = new Extension('ext1');
|
||||
ext1.name = 'ext1';
|
||||
const ext2 = new Extension('ext2');
|
||||
ext2.name = 'ext2';
|
||||
|
||||
addExtension(ext1, false);
|
||||
addExtension(ext2, false);
|
||||
|
||||
expect(getAllExtensions()).toEqual([ext1, ext2]);
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('should return only active extensions', () => {
|
||||
const ext1 = new Extension('ext1');
|
||||
ext1.name = 'ext1';
|
||||
ext1.setActive(true);
|
||||
const ext2 = new Extension('ext2');
|
||||
ext2.name = 'ext2';
|
||||
ext2.setActive(false);
|
||||
|
||||
addExtension(ext1, false);
|
||||
addExtension(ext2, false);
|
||||
|
||||
expect(getActiveExtensions()).toEqual([ext1]);
|
||||
});
|
||||
@ -1,125 +0,0 @@
|
||||
import { writeFileSync } from 'fs'
|
||||
import Extension from './extension'
|
||||
import { ExtensionManager } from './manager'
|
||||
|
||||
/**
|
||||
* @module store
|
||||
* @private
|
||||
*/
|
||||
|
||||
/**
|
||||
* Register of installed extensions
|
||||
* @type {Object.<string, Extension>} extension - List of installed extensions
|
||||
*/
|
||||
const extensions: Record<string, Extension> = {}
|
||||
|
||||
/**
|
||||
* Get a extension from the stored extensions.
|
||||
* @param {string} name Name of the extension to retrieve
|
||||
* @returns {Extension} Retrieved extension
|
||||
* @alias extensionManager.getExtension
|
||||
*/
|
||||
export function getExtension(name: string) {
|
||||
if (!Object.prototype.hasOwnProperty.call(extensions, name)) {
|
||||
throw new Error(`Extension ${name} does not exist`)
|
||||
}
|
||||
|
||||
return extensions[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all extension objects.
|
||||
* @returns {Array.<Extension>} All extension objects
|
||||
* @alias extensionManager.getAllExtensions
|
||||
*/
|
||||
export function getAllExtensions() {
|
||||
return Object.values(extensions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of active extension objects.
|
||||
* @returns {Array.<Extension>} Active extension objects
|
||||
* @alias extensionManager.getActiveExtensions
|
||||
*/
|
||||
export function getActiveExtensions() {
|
||||
return Object.values(extensions).filter((extension) => extension.active)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove extension from store and maybe save stored extensions to file
|
||||
* @param {string} name Name of the extension to remove
|
||||
* @param {boolean} persist Whether to save the changes to extensions to file
|
||||
* @returns {boolean} Whether the delete was successful
|
||||
* @alias extensionManager.removeExtension
|
||||
*/
|
||||
export function removeExtension(name: string, persist = true) {
|
||||
const del = delete extensions[name]
|
||||
if (persist) persistExtensions()
|
||||
return del
|
||||
}
|
||||
|
||||
/**
|
||||
* Add extension to store and maybe save stored extensions to file
|
||||
* @param {Extension} extension Extension to add to store
|
||||
* @param {boolean} persist Whether to save the changes to extensions to file
|
||||
* @returns {void}
|
||||
*/
|
||||
export function addExtension(extension: Extension, persist = true) {
|
||||
if (extension.name) extensions[extension.name] = extension
|
||||
if (persist) {
|
||||
persistExtensions()
|
||||
extension.subscribe('pe-persist', persistExtensions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save stored extensions to file
|
||||
* @returns {void}
|
||||
*/
|
||||
export function persistExtensions() {
|
||||
const persistData: Record<string, Extension> = {}
|
||||
for (const name in extensions) {
|
||||
persistData[name] = extensions[name]
|
||||
}
|
||||
writeFileSync(ExtensionManager.instance.getExtensionsFile(), JSON.stringify(persistData))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and install a new extension for the given specifier.
|
||||
* @param {Array.<installOptions | string>} extensions A list of NPM specifiers, or installation configuration objects.
|
||||
* @param {boolean} [store=true] Whether to store the installed extensions in the store
|
||||
* @returns {Promise.<Array.<Extension>>} New extension
|
||||
* @alias extensionManager.installExtensions
|
||||
*/
|
||||
export async function installExtensions(extensions: any) {
|
||||
const installed: Extension[] = []
|
||||
const installations = extensions.map((ext: any): Promise<void> => {
|
||||
const isObject = typeof ext === 'object'
|
||||
const spec = isObject ? [ext.specifier, ext] : [ext]
|
||||
const activate = isObject ? ext.activate !== false : true
|
||||
|
||||
// Install and possibly activate extension
|
||||
const extension = new Extension(...spec)
|
||||
if (!extension.origin) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return extension._install().then(() => {
|
||||
if (activate) extension.setActive(true)
|
||||
// Add extension to store if needed
|
||||
addExtension(extension)
|
||||
installed.push(extension)
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.all(installations)
|
||||
|
||||
// Return list of all installed extensions
|
||||
return installed
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object.<string, any>} installOptions The {@link https://www.npmjs.com/package/pacote|pacote}
|
||||
* options used to install the extension with some extra options.
|
||||
* @param {string} specifier the NPM specifier that identifies the package.
|
||||
* @param {boolean} [activate] Whether this extension should be activated after installation. Defaults to true.
|
||||
*/
|
||||
@ -1,19 +0,0 @@
|
||||
import { getAppConfigurations, defaultAppConfig } from './config'
|
||||
|
||||
import { getJanExtensionsPath, getJanDataFolderPath } from './config'
|
||||
|
||||
it('should return default config when CI is e2e', () => {
|
||||
process.env.CI = 'e2e'
|
||||
const config = getAppConfigurations()
|
||||
expect(config).toEqual(defaultAppConfig())
|
||||
})
|
||||
|
||||
it('should return extensions path when retrieved successfully', () => {
|
||||
const extensionsPath = getJanExtensionsPath()
|
||||
expect(extensionsPath).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return data folder path when retrieved successfully', () => {
|
||||
const dataFolderPath = getJanDataFolderPath()
|
||||
expect(dataFolderPath).not.toBeUndefined()
|
||||
})
|
||||
@ -1,91 +0,0 @@
|
||||
import { AppConfiguration } from '../../types'
|
||||
import { join, resolve } from 'path'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
const configurationFileName = 'settings.json'
|
||||
|
||||
/**
|
||||
* Getting App Configurations.
|
||||
*
|
||||
* @returns {AppConfiguration} The app configurations.
|
||||
*/
|
||||
export const getAppConfigurations = (): AppConfiguration => {
|
||||
const appDefaultConfiguration = defaultAppConfig()
|
||||
if (process.env.CI === 'e2e') return appDefaultConfiguration
|
||||
// Retrieve Application Support folder path
|
||||
// Fallback to user home directory if not found
|
||||
const configurationFile = getConfigurationFilePath()
|
||||
|
||||
if (!fs.existsSync(configurationFile)) {
|
||||
// create default app config if we don't have one
|
||||
console.debug(`App config not found, creating default config at ${configurationFile}`)
|
||||
fs.writeFileSync(configurationFile, JSON.stringify(appDefaultConfiguration))
|
||||
return appDefaultConfiguration
|
||||
}
|
||||
|
||||
try {
|
||||
const appConfigurations: AppConfiguration = JSON.parse(
|
||||
fs.readFileSync(configurationFile, 'utf-8')
|
||||
)
|
||||
return appConfigurations
|
||||
} catch (err) {
|
||||
console.error(`Failed to read app config, return default config instead! Err: ${err}`)
|
||||
return defaultAppConfig()
|
||||
}
|
||||
}
|
||||
|
||||
const getConfigurationFilePath = () =>
|
||||
join(
|
||||
global.core?.appPath() || process.env[process.platform == 'win32' ? 'USERPROFILE' : 'HOME'],
|
||||
configurationFileName
|
||||
)
|
||||
|
||||
export const updateAppConfiguration = ({
|
||||
configuration,
|
||||
}: {
|
||||
configuration: AppConfiguration
|
||||
}): Promise<void> => {
|
||||
const configurationFile = getConfigurationFilePath()
|
||||
|
||||
fs.writeFileSync(configurationFile, JSON.stringify(configuration))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get data folder path
|
||||
*
|
||||
* @returns {string} The data folder path.
|
||||
*/
|
||||
export const getJanDataFolderPath = (): string => {
|
||||
const appConfigurations = getAppConfigurations()
|
||||
return appConfigurations.data_folder
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get extension path
|
||||
*
|
||||
* @returns {string} The extensions path.
|
||||
*/
|
||||
export const getJanExtensionsPath = (): string => {
|
||||
const appConfigurations = getAppConfigurations()
|
||||
return join(appConfigurations.data_folder, 'extensions')
|
||||
}
|
||||
|
||||
/**
|
||||
* Default app configurations
|
||||
* App Data Folder default to Electron's userData
|
||||
* %APPDATA% on Windows
|
||||
* $XDG_CONFIG_HOME or ~/.config on Linux
|
||||
* ~/Library/Application Support on macOS
|
||||
*/
|
||||
export const defaultAppConfig = (): AppConfiguration => {
|
||||
const { app } = require('electron')
|
||||
const defaultJanDataFolder = join(app?.getPath('userData') ?? os?.homedir() ?? '', 'data')
|
||||
return {
|
||||
data_folder:
|
||||
process.env.CI === 'e2e'
|
||||
? process.env.APP_CONFIG_PATH ?? resolve('./test-data')
|
||||
: defaultJanDataFolder,
|
||||
quick_ask: false,
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export * from './config'
|
||||
export * from './logger'
|
||||
export * from './module'
|
||||
export * from './path'
|
||||
export * from './resource'
|
||||
@ -1,47 +0,0 @@
|
||||
import { Logger, LoggerManager } from './logger';
|
||||
|
||||
it('should flush queued logs to registered loggers', () => {
|
||||
class TestLogger extends Logger {
|
||||
name = 'testLogger';
|
||||
log(args: any): void {
|
||||
console.log(args);
|
||||
}
|
||||
}
|
||||
const loggerManager = new LoggerManager();
|
||||
const testLogger = new TestLogger();
|
||||
loggerManager.register(testLogger);
|
||||
const logSpy = jest.spyOn(testLogger, 'log');
|
||||
loggerManager.log('test log');
|
||||
expect(logSpy).toHaveBeenCalledWith('test log');
|
||||
});
|
||||
|
||||
|
||||
it('should unregister a logger', () => {
|
||||
class TestLogger extends Logger {
|
||||
name = 'testLogger';
|
||||
log(args: any): void {
|
||||
console.log(args);
|
||||
}
|
||||
}
|
||||
const loggerManager = new LoggerManager();
|
||||
const testLogger = new TestLogger();
|
||||
loggerManager.register(testLogger);
|
||||
loggerManager.unregister('testLogger');
|
||||
const retrievedLogger = loggerManager.get('testLogger');
|
||||
expect(retrievedLogger).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
it('should register and retrieve a logger', () => {
|
||||
class TestLogger extends Logger {
|
||||
name = 'testLogger';
|
||||
log(args: any): void {
|
||||
console.log(args);
|
||||
}
|
||||
}
|
||||
const loggerManager = new LoggerManager();
|
||||
const testLogger = new TestLogger();
|
||||
loggerManager.register(testLogger);
|
||||
const retrievedLogger = loggerManager.get('testLogger');
|
||||
expect(retrievedLogger).toBe(testLogger);
|
||||
});
|
||||
@ -1,81 +0,0 @@
|
||||
// Abstract Logger class that all loggers should extend.
|
||||
export abstract class Logger {
|
||||
// Each logger must have a unique name.
|
||||
abstract name: string
|
||||
|
||||
/**
|
||||
* Log message to log file.
|
||||
* This method should be overridden by subclasses to provide specific logging behavior.
|
||||
*/
|
||||
abstract log(args: any): void
|
||||
}
|
||||
|
||||
// LoggerManager is a singleton class that manages all registered loggers.
|
||||
export class LoggerManager {
|
||||
// Map of registered loggers, keyed by their names.
|
||||
public loggers = new Map<string, Logger>()
|
||||
|
||||
// Array to store logs that are queued before the loggers are registered.
|
||||
queuedLogs: any[] = []
|
||||
|
||||
// Flag to indicate whether flushLogs is currently running.
|
||||
private isFlushing = false
|
||||
|
||||
// Register a new logger. If a logger with the same name already exists, it will be replaced.
|
||||
register(logger: Logger) {
|
||||
this.loggers.set(logger.name, logger)
|
||||
}
|
||||
// Unregister a logger by its name.
|
||||
unregister(name: string) {
|
||||
this.loggers.delete(name)
|
||||
}
|
||||
|
||||
get(name: string) {
|
||||
return this.loggers.get(name)
|
||||
}
|
||||
|
||||
// Flush queued logs to all registered loggers.
|
||||
flushLogs() {
|
||||
// If flushLogs is already running, do nothing.
|
||||
if (this.isFlushing) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isFlushing = true
|
||||
|
||||
while (this.queuedLogs.length > 0 && this.loggers.size > 0) {
|
||||
const log = this.queuedLogs.shift()
|
||||
this.loggers.forEach((logger) => {
|
||||
logger.log(log)
|
||||
})
|
||||
}
|
||||
|
||||
this.isFlushing = false
|
||||
}
|
||||
|
||||
// Log message using all registered loggers.
|
||||
log(args: any) {
|
||||
this.queuedLogs.push(args)
|
||||
|
||||
this.flushLogs()
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance of the logger.
|
||||
* If an instance doesn't exist, it creates a new one.
|
||||
* This ensures that there is only one LoggerManager instance at any time.
|
||||
*/
|
||||
static instance(): LoggerManager {
|
||||
let instance: LoggerManager | undefined = global.core?.logger
|
||||
if (!instance) {
|
||||
instance = new LoggerManager()
|
||||
if (!global.core) global.core = {}
|
||||
global.core.logger = instance
|
||||
}
|
||||
return instance
|
||||
}
|
||||
}
|
||||
|
||||
export const log = (...args: any) => {
|
||||
LoggerManager.instance().log(args)
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import { ModuleManager } from './module';
|
||||
|
||||
it('should clear all imported modules', () => {
|
||||
const moduleManager = new ModuleManager();
|
||||
moduleManager.setModule('module1', { key: 'value1' });
|
||||
moduleManager.setModule('module2', { key: 'value2' });
|
||||
moduleManager.clearImportedModules();
|
||||
expect(moduleManager.requiredModules).toEqual({});
|
||||
});
|
||||
|
||||
|
||||
it('should set a module correctly', () => {
|
||||
const moduleManager = new ModuleManager();
|
||||
moduleManager.setModule('testModule', { key: 'value' });
|
||||
expect(moduleManager.requiredModules['testModule']).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
|
||||
it('should return the singleton instance', () => {
|
||||
const instance1 = new ModuleManager();
|
||||
const instance2 = new ModuleManager();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Manages imported modules.
|
||||
*/
|
||||
export class ModuleManager {
|
||||
public requiredModules: Record<string, any> = {}
|
||||
public cleaningResource = false
|
||||
|
||||
public static instance: ModuleManager = new ModuleManager()
|
||||
|
||||
constructor() {
|
||||
if (ModuleManager.instance) {
|
||||
return ModuleManager.instance
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a module.
|
||||
* @param {string} moduleName - The name of the module.
|
||||
* @param {any | undefined} nodule - The module to set, or undefined to clear the module.
|
||||
*/
|
||||
setModule(moduleName: string, nodule: any | undefined) {
|
||||
this.requiredModules[moduleName] = nodule
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all imported modules.
|
||||
*/
|
||||
clearImportedModules() {
|
||||
this.requiredModules = {}
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
import { normalizeFilePath } from './path'
|
||||
|
||||
import { jest } from '@jest/globals'
|
||||
describe('Test file normalize', () => {
|
||||
test('returns no file protocol prefix on Unix', async () => {
|
||||
expect(normalizeFilePath('file://test.txt')).toBe('test.txt')
|
||||
expect(normalizeFilePath('file:/test.txt')).toBe('test.txt')
|
||||
})
|
||||
test('returns no file protocol prefix on Windows', async () => {
|
||||
expect(normalizeFilePath('file:\\\\test.txt')).toBe('test.txt')
|
||||
expect(normalizeFilePath('file:\\test.txt')).toBe('test.txt')
|
||||
})
|
||||
|
||||
test('returns correct path when Electron is available and app is not packaged', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
getAppPath: jest.fn().mockReturnValue('/mocked/path'),
|
||||
isPackaged: false,
|
||||
},
|
||||
protocol: {},
|
||||
}
|
||||
jest.mock('electron', () => electronMock)
|
||||
|
||||
const { appResourcePath } = require('./path')
|
||||
|
||||
const expectedPath = process.platform === 'win32' ? '\\mocked\\path' : '/mocked/path'
|
||||
expect(appResourcePath()).toBe(expectedPath)
|
||||
})
|
||||
})
|
||||
@ -1,37 +0,0 @@
|
||||
import { join } from 'path'
|
||||
|
||||
/**
|
||||
* Normalize file path
|
||||
* Remove all file protocol prefix
|
||||
* @param path
|
||||
* @returns
|
||||
*/
|
||||
export function normalizeFilePath(path: string): string {
|
||||
return path.replace(/^(file:[\\/]+)([^:\s]+)$/, '$2')
|
||||
}
|
||||
|
||||
/**
|
||||
* App resources path
|
||||
* Returns string - The current application directory.
|
||||
*/
|
||||
export function appResourcePath() {
|
||||
try {
|
||||
const electron = require('electron')
|
||||
// electron
|
||||
if (electron && electron.protocol) {
|
||||
let appPath = join(electron.app.getAppPath(), '..', 'app.asar.unpacked')
|
||||
|
||||
if (!electron.app.isPackaged) {
|
||||
// for development mode
|
||||
appPath = join(electron.app.getAppPath())
|
||||
}
|
||||
return appPath
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Electron is not available')
|
||||
}
|
||||
|
||||
// server
|
||||
return join(global.core.appPath(), '../../..')
|
||||
}
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
import { getSystemResourceInfo } from './resource'
|
||||
|
||||
it('should return the correct system resource information with a valid CPU count', async () => {
|
||||
const result = await getSystemResourceInfo()
|
||||
|
||||
expect(result).toEqual({
|
||||
memAvailable: 0,
|
||||
})
|
||||
})
|
||||
@ -1,7 +0,0 @@
|
||||
import { SystemResourceInfo } from '../../types'
|
||||
|
||||
export const getSystemResourceInfo = async (): Promise<SystemResourceInfo> => {
|
||||
return {
|
||||
memAvailable: 0, // TODO: this should not be 0
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
export * from './extension/index'
|
||||
export * from './extension/extension'
|
||||
export * from './extension/manager'
|
||||
export * from './extension/store'
|
||||
export * from './api'
|
||||
export * from './helper'
|
||||
export * from './../types'
|
||||
export * from '../types/api'
|
||||
@ -6,5 +6,4 @@ interface Core {
|
||||
}
|
||||
interface Window {
|
||||
core?: Core | undefined
|
||||
electronAPI?: any | undefined
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user