diff --git a/helpers/scanner/client.js b/helpers/scanner/client.js new file mode 100644 index 0000000..75dae31 --- /dev/null +++ b/helpers/scanner/client.js @@ -0,0 +1,305 @@ +import { Blob } from 'buffer' +import plist from 'plist' +// import zip from '@zip.js/zip.js' + +import { + isValidHttpUrl +} from '~/helpers/check-types.js' + + +// For some reason inline 'import()' works better than 'import from' +// https://gildas-lormeau.github.io/zip.js/ +const zip = await import('@zip.js/zip.js') + +// https://gildas-lormeau.github.io/zip.js/core-api.html#configuration +zip.configure({ + // Disable Web Workers for SSR since Node doesn't support them yet + // https://vitejs.dev/guide/env-and-mode.html#env-variables + useWebWorkers: !import.meta.env.SSR +}) + + +export class AppScan { + constructor ({ + fileLoader, + messageReceiver + }) { + + this.fileLoader = fileLoader + this.messageReceiver = messageReceiver + + this.status = 'idle' + this.file = null + this.bundleFileEntries = [] + this.infoPlist = {} + this.machoMeta = {} + + } + + sendMessage ( details ) { + if ( details?.status ) { + this.status = details.status + } + + if ( typeof( this.messageReceiver ) === 'function' ) { + this.messageReceiver( details ) + } + } + + get hasInfoPlist () { + return Object.keys( this.infoPlist ).length > 0 + } + + // get bundleExecutablePath () { + // if ( !this.hasInfoPlist ) return '' + + // // There our CFBundleExecutable is a path to the executable + // // then use it + // if ( this.infoPlist.CFBundleExecutable.includes('/') ) return `/Contents/${ this.infoPlist.CFBundleExecutable }` + + // // Use default executable path + // return `/Contents/MacOS/${ this.infoPlist.CFBundleExecutable }` + // } + + async readFileEntryData ( fileEntry ) { + // Get blob data from zip + // https://gildas-lormeau.github.io/zip.js/core-api.html#zip-entry + return await fileEntry.getData( + // writer + // https://gildas-lormeau.github.io/zip.js/core-api.html#zip-writing + new zip.TextWriter(), + ) + } + + + async readFileBlob ( File ) { + return new Promise( ( resolve, reject ) => { + const fileReader = new zip.BlobReader( new Blob( File.arrayBuffer ) ) + + // this.sendMessage({ + // message: '📖 Setting up BlobReader', + // status: 'reading', + // data: fileReader + // }) + + fileReader.onload = function () { + // do something on FileReader onload + this.sendMessage({ + message: '📖 Reading file', + status: 'reading' + }) + } + + fileReader.onerror = error => { + // do something on FileReader onload + console.error('File Read Error', error) + + reject( new Error('File Read Error', error) ) + } + + fileReader.onprogress = (data) => { + if (data.lengthComputable) { + const progress = parseInt( ((data.loaded / data.total) * 100), 10 ); + console.log('Read progress', progress) + + // do something on FileReader onload + this.sendMessage({ + message: `📖 Reading file. ${ progress }% read`, + status: 'reading' + }) + } + } + + // https://gildas-lormeau.github.io/zip.js/core-api.html#zip-reading + const zipReader = new zip.ZipReader( fileReader ) + + // zipReader.onload = console.log + + // zipReader.onprogress = console.log + + // zipReader.onerror = console.error + + zipReader + .getEntries() + .then( entries => { + + // do something on entries + this.sendMessage({ + message: '📖 Reading file complete. Entries found', + status: 'read' + }) + + resolve( entries ) + }) + + }) + } + + matchesMacho ( entry ) { + // Skip files that are deeper than 3 folders + if ( entry.filename.split('/').length > 4 ) return false + + // Skip folders + if ( entry.filename.endsWith('/') ) return false + + // `${ appName }.app/Contents/MacOS/${ appName }` + // Does this entry path match any of our wanted paths + return [ + // `${ appName }.app/Contents/MacOS/${ appName }` + `.app/Contents/MacOS/` + ].some( pathToMatch => { + return entry.filename.includes( pathToMatch ) + }) + } + + matchesRootInfoPlist ( entry ) { + // Skip files that are deeper than 2 folders + if ( entry.filename.split('/').length > 3 ) return false + + // Skip folders + if ( entry.filename.endsWith('/') ) return false + + // If this entry matches the root info.plist path exactly + // then we have found the root info.plist + if ( entry.filename === 'Contents/Info.plist' ) return true + + // Does this entry path match any of our wanted paths + return [ + // `zoom.us.app/Contents/Info.plist` + `.app/Contents/Info.plist`, + `.zip/Contents/Info.plist` + ].some( pathToMatch => { + return entry.filename.endsWith( pathToMatch ) + }) + } + + fileEntryType ( fileEntry ) { + if ( !!fileEntry.directory ) return 'directory' + + if ( this.matchesMacho( fileEntry ) ) return 'macho' + + if ( this.matchesRootInfoPlist( fileEntry ) ) return 'rootInfoPlist' + + // getData + + return 'unknown' + } + + storeInfoPlist = async ( fileEntry ) => { + // Throw if we have more than one target file + if ( this.hasInfoPlist ) { + throw new Error( 'More than one root info.plist found' ) + } + + const infoXml = await this.readFileEntryData( fileEntry ) + + // Parse the Info.plist data + this.infoPlist = plist.parse( infoXml ) + + this.sendMessage({ + message: 'â„šī¸ Found Info.plist', + // data: this.infoPlist, + }) + } + + // async storeMachoMeta ( fileEntry ) { + // const machoData = await this.readFileEntryData( fileEntry ) + // } + + + targetFiles = { + rootInfoPlist: { + method: this.storeInfoPlist + }, + // macho: { + // method: this.storeMacho, + // } + } + + async findTargetFiles () { + + for ( const fileEntry of this.bundleFileEntries ) { + const type = this.fileEntryType( fileEntry ) + + // console.log( 'fileEntry', type, fileEntry.filename ) + + // Check if we have a target file + if ( this.targetFiles[ type ] ) { + + // Call the target file method + await this.targetFiles[ type ].method( fileEntry ) + } + + // console.log( 'File Entry Type:', type ) + } + + } + + async start () { + // Load in the file + this.sendMessage({ + message: '🚛 Loading file...', + status: 'loading' + }) + + this.file = await this.fileLoader() + + // console.log( 'File:', this.file ) + + this.bundleFileEntries = await this.readFileBlob( this.file ) + + this.sendMessage({ + message: 'đŸŽŦ Starting scan', + status: 'scanning' + }) + + await this.findTargetFiles() + + + this.sendMessage({ + message: '🏁 Scan complete', + status: 'complete' + }) + } +} + +// export class AppScannerClient { + +// constructor ({ messageReceiver }) { + +// const testResultStore = global.$config.testResultStore + +// if ( !isValidHttpUrl( testResultStore ) ) { +// throw new Error( 'testResultStore is not a valid url' ) +// } + +// this.messageReceiver = messageReceiver +// } + +// scanQueue = [] + +// get unscanned () { +// return this.scanQueue.filter( scan => scan.status === 'idle' ) +// } + +// get nextScan () { + + +// sendMessage ( details ) { +// if( typeof( this.messageReceiver ) === 'function' ) { +// messageReceiver( details ) +// } +// } + +// qeueScan ( File ) { +// // Create a new scan instance +// const scan = new AppScan({ File, messageReceiver: this.sendMessage }) + +// // Add the scan to the queue +// this.scanQueue.push( scan ) +// } + +// start () { + +// } +// } diff --git a/test/scanner/client.test.js b/test/scanner/client.test.js new file mode 100644 index 0000000..9842d41 --- /dev/null +++ b/test/scanner/client.test.js @@ -0,0 +1,71 @@ + +import { + assert, + expect, + test +} from 'vitest' + +// https://github.com/mrmlnc/fast-glob +import glob from 'fast-glob' +import { LocalFileData } from 'get-file-object-from-local-path' +import { Zip } from 'zip-lib' + +import { AppScan } from '~/helpers/scanner/client' + + +const appGlobOptions = { + onlyFiles: false, + deep: 1 +} + +const appsPath = 'test/_artifacts/apps' + +const tempPath = 'test/_artifacts/temp' + +const plainAppBundles = glob.sync( `${ appsPath }/**/*.app`, appGlobOptions ) + + +async function makeZipFromBundlePath ( bundlePath ) { + const archivePath = `${ tempPath }/${ bundlePath.split('/').pop() }.zip` + + // console.log( 'archivePath', archivePath ) + + const zipLib = new Zip() + + // Adds a folder from the file system, putting its contents at the root of archive + zipLib.addFolder( bundlePath ) + + // Generate zip file. + await zipLib.archive( archivePath ) + + // Create a File Object from the zip file + // https://developer.mozilla.org/en-US/docs/Web/API/File/File + const archiveFile = new LocalFileData( archivePath ) + + // console.log( 'archiveFile', archiveFile ) + + return archiveFile +} + +test('Can read info.plist for .app file', async () => { + + // Compress plain app bundles to zipped File Objects + for ( const bundlePath of plainAppBundles ) { + + // Create a new AppScan instance + const scan = new AppScan({ + fileLoader: () => makeZipFromBundlePath( bundlePath ), + messageReceiver: ( details ) => { + console.log( 'Scan message:', details ) + } + }) + + // Scan the archive + await scan.start() + + // console.log( 'infoPlist', scan.infoPlist ) + + expect( scan.hasInfoPlist ).toBe( true ) + } + +}) diff --git a/test/scanner/client.test.ts b/test/scanner/client.test.ts deleted file mode 100644 index 0e326fb..0000000 --- a/test/scanner/client.test.ts +++ /dev/null @@ -1,44 +0,0 @@ - -import { - // assert, - expect, - test -} from 'vitest' - -import { createApp } from 'vue' - - -// import { -// isString -// } from '~/helpers/check-types.js' - -// const cases = [ - -// ] - - -function buildVueInstance () { - return createApp({ - template: '
Hello World
', - data: function () { - return { - appsBeingScanned: [] - } - }, - }) -} - -test('Can initialize AppFilesScanner within Vite context', async () => { - const { default: AppFilesScanner } = await import('~/helpers/app-files-scanner.js') - - const vueInstance:any = buildVueInstance() - - const scanner = new AppFilesScanner({ - observableFilesArray: vueInstance.appsBeingScanned, - testResultStore: global.$config.testResultStore - }) - - // Expect the scanner to be an instance of AppFilesScanner - expect( scanner ).toBeInstanceOf( AppFilesScanner ) - -}) diff --git a/vitest.config.mjs b/vitest.config.mjs index c9af922..ebd2610 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -11,6 +11,7 @@ const vitestConfig = { ...astroConfig.vite, test: { + testTimeout: 60 * 1000, setupFiles: 'tsconfig-paths/register' } }