import * as Ajv from 'ajv' ; import { ipcMain } from 'electron' ; import * as fs from 'fs' ; import * as Koa from 'koa' ; import * as Router from 'koa-router' ; import * as path from 'path' ; import * as semver from 'semver' ; import { appDataDir , Logger } from './global' ; import { MajsoulPlus } from './majsoul_plus' ; import { fillObject , removeDirSync , unzipDir , zipDir } from './utils' ; import { ManagerWindow } from './windows/manager' ; export default abstract class BaseManager { protected name : string ; protected configPath : string ; protected defaultObject : MajsoulPlus . Metadata ; protected schema : { } ; protected loadedMap : Map < string , MajsoulPlus . Metadata > = new Map ( ) ; protected loadedDetails : { [ pack ] : { enabled : boolean ; sequence : number ; errors : Array < string | string [ ] > ; metadata : MajsoulPlus . Metadata ; } ; } = { } ; private enabled : string [ ] ; constructor ( name , configPath , defaultObj , schema ) { this . name = name ; this . configPath = configPath ; this . defaultObject = defaultObj ; this . schema = schema ; ipcMain . on ( `get-${ name }-details` , event => { ManagerWindow . webContents . send ( `get-${ name }-details-response` , this . getDetails ( ) ) ; } ) ; ipcMain . on ( `change-${ name }-enability` , ( event , id , enabled ) => this . changeEnable ( event , id , enabled ) ) ; ipcMain . on ( `save-${ name }-enabled` , event => { this . save ( ) ; event . returnValue = 0 ; } ) ; ipcMain . on ( `import-${ name }` , ( event , file ) => { unzipDir ( file , path . resolve ( appDataDir , this . name ) ) . then ( ( ) => { event . returnValue = 0 ; } ) . catch ( err => { Logger . error ( `Failed to import ${ name }: ${ err }` ) ; event . returnValue = 0 ; } ) ; } ) ; ipcMain . on ( `export-${ name }` , ( event , id , pathToSave ) => { const resp = { err : '' } ; try { const folder = path . resolve ( appDataDir , this . name , id ) ; zipDir ( path . resolve ( appDataDir , this . name , id ) , `${ pathToSave }` ) ; } catch ( e ) { resp . err = ( e as Error ) . message ; } event . returnValue = resp ; } ) ; ipcMain . on ( `remove-${ name }` , ( event , id ) => this . removePack ( event , id ) ) ; } loadEnabled ( ) { this . loadedMap . set ( 'majsoul_plus' , this . defaultObject ) ; try { this . enabled = JSON . parse ( fs . readFileSync ( this . configPath , { encoding : 'utf-8' } ) ) ; } catch ( e ) { Logger . error ( this . configPath ) ; Logger . error ( `Failed to load enabled ${ this . name }: ${ e }` ) ; this . enabled = [ ] ; } } use ( id , callback = ( ) => { } ) { if ( ! id . match ( /^[_a-zA-Z0-9]+$/ ) ) { Logger . debug ( `invalid ${ this . name } id： ${ id }` ) ; return this ; } const folder = path . resolve ( appDataDir , this . name , id ) ; const cfg = path . resolve ( folder , `${ this . name }.json` ) ; if ( ! fs . existsSync ( folder ) || ! fs . statSync ( folder ) . isDirectory ( ) ) { Logger . debug ( `${ id } folder not found: ${ folder }` ) ; return this ; } if ( ! fs . existsSync ( cfg ) || ! fs . statSync ( cfg ) . isFile ( ) ) { Logger . debug ( `${ id } configuration file not found: ${ cfg }` ) ; return this ; } let pack ; try { pack = JSON . parse ( fs . readFileSync ( cfg , { encoding : 'utf-8' } ) ) ; } catch ( e ) { Logger . error ( `Failed to parse json file ${ cfg }: ${ e }` ) ; return this ; } fillObject ( pack , this . defaultObject ) ; if ( pack . id !== id ) { Logger . debug ( `folder name & id mismatch: folder name is ${ id }, but id is ${ pack . id }` ) ; return this ; } if ( this . loadedMap . has ( pack . id ) ) { Logger . debug ( `${ this . name } already loaded or duplicated id: ${ id }` ) ; return this ; } const ajv = new Ajv ( ) ; const valid = ajv . addSchema ( this . schema , this . name ) . validate ( this . name , pack ) ; if ( ! valid ) { Logger . debug ( `failed to load ${ this . name } ${ id }: json schema failed` ) ; Logger . debug ( JSON . stringify ( ajv . errors , null , 2 ) ) ; return this ; } if ( ! semver . valid ( pack . version ) ) { Logger . debug ( `failed to load ${ this . name } ${ id }: broken version ${ pack . version }` ) ; return this ; } if ( pack . dependencies ) { for ( const dep in pack . dependencies ) { if ( semver . validRange ( pack . dependencies [ dep ] ) === null ) { Logger . debug ( `failed to load ${ this . name } ${ id }: broken dependency version ${ pack . dependencies [ dep ] }` ) ; return this ; } } } callback ( pack ) ; this . loadedMap . set ( id , pack ) ; this . loadedDetails [ id ] = { enabled : false , metadata : pack , sequence : 0 , errors : [ ] } ; return this ; } enableFromConfig ( ) { const validatedEnabled = [ ] ; this . enabled . forEach ( id => { const value = this . loadedDetails [ id ] ; if ( ! value ) { return ; } if ( value . enabled ) { validatedEnabled . push ( id ) ; return ; } const meta = value . metadata ; for ( const dep in meta . dependencies ) { if ( ! this . loadedMap . has ( dep ) ) { Logger . debug ( `dependency of ${ id } not found: ${ dep }` ) ; value . errors . push ( [ 'dependencyNotFound' , dep ] ) ; } else if ( dep !== 'majsoul_plus' && ! this . loadedDetails [ dep ] . enabled ) { Logger . debug ( `dependency of ${ id } not enabled: ${ dep }` ) ; value . errors . push ( [ 'dependencyNotEnabled' , dep ] ) ; } else { const range = new semver . Range ( meta . dependencies [ dep ] ) ; if ( ! semver . satisfies ( this . loadedMap . get ( dep ) . version , range ) ) { Logger . debug ( `dependency version of ${ id } mismatch: loaded ${ dep }:${ this . loadedMap . get ( dep ) . version }, but required ${ dep }:${ meta . dependencies [ dep ] }` ) ; value . errors . push ( [ 'dependencyVersionMismatch' , dep , meta . dependencies [ dep ] , this . loadedMap . get ( dep ) . version ] ) ; } } } if ( value . errors . length === 0 ) { validatedEnabled . push ( id ) ; this . loadedDetails [ id ] . enabled = true ; } } ) ; validatedEnabled . forEach ( ( id , index ) => { this . loadedDetails [ id ] . sequence = index + 1 ; } ) ; this . enabled = validatedEnabled ; } register ( server , router ) { } getDetail ( id ) { return { ... this . loadedDetails [ id ] . metadata } ; } getDetails ( ) { return { ... this . loadedDetails } ; } disable ( id ) { const toDisable = this . loadedDetails [ id ] ; if ( toDisable . enabled ) { const dependents = Object . values ( this . loadedDetails ) . filter ( pack => pack . enabled ? ! ! pack . metadata . dependencies [ id ] : false ) ; dependents . forEach ( dep => this . disable ( dep . metadata . id ) ) ; } toDisable . sequence = 0 ; toDisable . enabled = false ; this . enabled = this . enabled . filter ( item => item !== id ) ; } disableAll ( ) { for ( const id in this . loadedDetails ) { if ( this . loadedDetails [ id ] ) { this . loadedDetails [ id ] . enabled = false ; this . loadedDetails [ id ] . errors = [ ] ; } } this . enabled = [ ] ; } enable ( id ) { this . loadedDetails [ id ] . errors = [ ] ; this . enabled . push ( id ) ; this . enableFromConfig ( ) ; } enableAll ( ) { for ( const id in this . loadedDetails ) { if ( this . loadedDetails [ id ] ) { this . loadedDetails [ id ] . enabled = true ; this . loadedDetails [ id ] . errors = [ ] ; } } } clear ( ) { this . loadedMap = new Map ( ) ; this . loadedDetails = { } ; this . enabled = [ ] ; } save ( ) { fs . writeFileSync ( this . configPath , JSON . stringify ( this . enabled , null , 2 ) , { encoding : 'utf-8' } ) ; } changeEnable ( event , id , enabled ) { enabled ? this . enable ( id ) : this . disable ( id ) ; this . save ( ) ; event . returnValue = this . getDetails ( ) ; } removePack ( event , id ) { this . disable ( id ) ; removeDirSync ( path . resolve ( appDataDir , this . name , id ) ) ; this . save ( ) ; this . clear ( ) ; event . returnValue = 0 ; } }