From 689fc0d13da30f4a75673fdeb711031da01d5ce3 Mon Sep 17 00:00:00 2001 From: ThatGuySam Date: Sat, 4 Apr 2026 14:58:25 -0500 Subject: [PATCH] refactor(scanner): type the worker path and align app-test results Move the worker scanner surface into TypeScript, add a direct worker regression, and make the version=2 app-test path populate the same visible result data and final status as the legacy scanner. This keeps the refactor bounded while making the worker route safe to exercise. Constraint: Must preserve the existing Apple Silicon app-test behavior while changing the worker internals Rejected: Flip production to the worker path immediately | still needs the normal deploy path and broader production soak Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep the version=2 adapter using the shared finishFileScan path until the legacy scanner can be removed entirely Tested: pnpm run typecheck; pnpm exec vitest run test/scanner/client.test.ts; pnpm run test:browser (original workspace); netlify build --context deploy-preview (original workspace) Not-tested: Browser suite from the clean clone environment (local Astro dev server startup timed out there) --- helpers/app-files-scanner.js | 112 +++- helpers/scanner/client.mjs | 104 ---- helpers/scanner/client.ts | 124 ++++ helpers/scanner/scan.mjs | 475 ---------------- helpers/scanner/scan.ts | 538 ++++++++++++++++++ helpers/scanner/worker.mjs | 43 -- helpers/scanner/worker.ts | 72 +++ test/_disabled/scanner/client.test.mjs | 3 +- .../playwright/support/app-archive-fixture.ts | 7 +- test/scanner/client.test.ts | 48 ++ 10 files changed, 869 insertions(+), 657 deletions(-) delete mode 100644 helpers/scanner/client.mjs create mode 100644 helpers/scanner/client.ts delete mode 100644 helpers/scanner/scan.mjs create mode 100644 helpers/scanner/scan.ts delete mode 100644 helpers/scanner/worker.mjs create mode 100644 helpers/scanner/worker.ts create mode 100644 test/scanner/client.test.ts diff --git a/helpers/app-files-scanner.js b/helpers/app-files-scanner.js index f817a12..3f4e555 100644 --- a/helpers/app-files-scanner.js +++ b/helpers/app-files-scanner.js @@ -7,7 +7,7 @@ import { isString } from './check-types.js' import parseMacho from './macho/index.js' // Vite Web Workers - https://vitejs.dev/guide/features.html#web-workers -import { runScanWorker } from '~/helpers/scanner/client.mjs' +import { runScanWorker } from '~/helpers/scanner/client' const scannerVersion = (() => { // If there's no window @@ -341,6 +341,10 @@ export default class AppFilesScanner { .then( response => response.data ) .catch(function (error) { console.error(error) + + return { + supportedVersionNumber: null + } }) return { @@ -348,6 +352,47 @@ export default class AppFilesScanner { } } + getFinishedStatusMessage ({ + binarySupportsNative, + supportedVersionNumber + }) { + if ( binarySupportsNative ) { + return '✅ This app is natively compatible with Apple Silicon!' + } + + if ( supportedVersionNumber != null ) { + return [ + '✅ A native version of this has been reported', + ( isString( supportedVersionNumber ) && supportedVersionNumber.length > 0 ) ? `as of v${ supportedVersionNumber }` : null + ].filter( Boolean ).join(' ') + } + + return `đŸ”ļ This app file is not natively compatible with Apple Silicon and may only run via Rosetta 2 translation, however, software vendors will sometimes will ship separate install files for Intel and ARM instead of a single one. ` + } + + finishFileScan ( file, scanIndex, { + binarySupportsNative, + supportedVersionNumber + } ) { + file.statusMessage = this.getFinishedStatusMessage({ + binarySupportsNative, + supportedVersionNumber + }) + file.status = 'finished' + + if ( binarySupportsNative ) { + this.files.unshift( this.files.splice( scanIndex, 1 )[0] ) + } + } + + applyWorkerScanData ( file, scan ) { + file.appVersion = scan.appVersion || null + file.displayName = scan.displayName || file.displayName + file.details = Array.isArray( scan.details ) ? scan.details : [] + file.displayBinarySize = scan.displayBinarySize || null + file.binarySize = typeof scan.binarySize === 'number' ? scan.binarySize : null + } + async scanFile ( file, scanIndex ) { // If we've already scanned this @@ -553,26 +598,10 @@ export default class AppFilesScanner { console.log('supportedVersionNumber', supportedVersionNumber) - let finishedStatusMessage = '' - - if ( binarySupportsNative ) { - finishedStatusMessage = '✅ This app is natively compatible with Apple Silicon!' - - // Shift this scan to the top - this.files.unshift( this.files.splice( scanIndex, 1 )[0] ) - } else if ( supportedVersionNumber !== null ) { - - finishedStatusMessage = [ - '✅ A native version of this has been reported', - (supportedVersionNumber.length > 0) ? `as of v${supportedVersionNumber}` : null - ].join(' ') - - } else { - finishedStatusMessage = `đŸ”ļ This app file is not natively compatible with Apple Silicon and may only run via Rosetta 2 translation, however, software vendors will sometimes will ship separate install files for Intel and ARM instead of a single one. ` - } - - file.statusMessage = finishedStatusMessage - file.status = 'finished' + this.finishFileScan( file, scanIndex, { + binarySupportsNative, + supportedVersionNumber + } ) return } @@ -618,20 +647,45 @@ export default class AppFilesScanner { console.log( 'scannerVersion', scannerVersion ) if ( scannerVersion === '2' ) { + try { + const { scan } = await runScanWorker( file.instance, messageDetails => { + console.log( 'messageDetails', messageDetails ) + if ( isString( messageDetails.message ) ) { + file.statusMessage = messageDetails.message + } - const { scan } = await runScanWorker( file.instance, messageDetails => { - console.log( 'messageDetails', messageDetails ) + if ( isString( messageDetails.status ) ) { + file.status = messageDetails.status + } + } ) - file.statusMessage = messageDetails.message - file.status = messageDetails.status - } ) + this.applyWorkerScanData( file, scan ) - console.log('scan', scan) + const { supportedVersionNumber } = await this.submitScanInfo({ + filename: scan.info?.filename || file.name, + appVersion: scan.info?.appVersion || file.appVersion, + result: scan.info?.result || ( scan.binarySupportsNative ? '✅' : 'đŸ”ļ' ), + machoMeta: scan.info?.machoMeta || null, + infoPlist: scan.info?.infoPlist || null + }) - clearTimeout(timer) + this.finishFileScan( file, scanIndex, { + binarySupportsNative: Boolean( scan.binarySupportsNative ), + supportedVersionNumber + } ) - resolve() + clearTimeout(timer) + + resolve() + } catch ( error ) { + file.statusMessage = `❔ ${ error.message }` + file.status = 'finished' + + clearTimeout(timer) + + resolve() + } return } diff --git a/helpers/scanner/client.mjs b/helpers/scanner/client.mjs deleted file mode 100644 index 5dacdda..0000000 --- a/helpers/scanner/client.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import AppScanWorker from './worker.mjs?worker' - -const noop = () => {} - -function getArrayBufferFromFileData ( file ) { - return new Promise( ( resolve, reject ) => { - - // If it has a .arrayBuffer function - // then return that - // (Likely a browser File blob) - if ( typeof file.arrayBuffer === 'function' ) { - file.arrayBuffer().then( resolve ) - - return - } - - // If it has a truthy .arrayBuffer property - // then return that - // (Likely a node File object) - if ( !!file?.arrayBuffer ) { - resolve( file.arrayBuffer ) - return - } - - // Assume it's a Node Buffer from fs.readFile - - - resolve( file.buffer ) - - // const hasFileReader = typeof FileReader !== 'undefined' - // const reader = hasFileReader ? new FileReader() : new FileApi.FileReader() - - // reader.onerror = function onerror ( readerEvent ) { - // reject( readerEvent.target.error ) - // } - - // reader.onload = function onload ( readerEvent ) { - // resolve( readerEvent.target.result ) - // } - - // reader.readAsArrayBuffer( file ) - }) -} - -export async function runScanWorker ( file, messageReceiver = noop ) { - // console.log( 'file', file ) - - const appScanWorker = new AppScanWorker() - - const fileArrayBuffer = ( typeof file.arrayBuffer === 'function' ) ? (await file.arrayBuffer()) : file.arrayBuffer - - if ( !fileArrayBuffer ) { - throw new Error( 'No fileArrayBuffer' ) - } - - const scan = await new Promise( ( resolve, reject ) => { - // Set up the worker message handler - appScanWorker.onmessage = async (event) => { - // console.log( 'Main received message', event ) - - const { status } = event.data - - messageReceiver( event.data ) - - // Resolves promise on finished status - if ( status === 'finished' ) { - const { scan } = event.data - resolve( scan ) - } - } - - // Set up the worker error handler - appScanWorker.onerror = async ( errorEvent ) => { - console.error( 'Error received from App Scan Worker', errorEvent ) - reject() - } - - - // Start the worker - // https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage - appScanWorker.postMessage( { - status: 'start', - options: { - file: { - ...file, - // We put it into an array - // so that it's iterable for Blob - arrayBuffer: [ fileArrayBuffer ] - } - } - }, [ - // This array is our transferrable objects - // so that the App Scan Worker is allowed - // to use existing data from the main thread - // and we don't have to clone the data from scratch - fileArrayBuffer - ] ) - }) - - return { - scan, - appScanWorker - } -} diff --git a/helpers/scanner/client.ts b/helpers/scanner/client.ts new file mode 100644 index 0000000..ac3c993 --- /dev/null +++ b/helpers/scanner/client.ts @@ -0,0 +1,124 @@ +import AppScanWorker from './worker?worker' + +import type { + AppScanSnapshot, + ScanFileLike, + ScanMessage +} from './scan' + +const noop = () => {} + +type ScanMessageReceiver = ( details: ScanMessage ) => void + +interface WorkerScanFile extends ScanFileLike { + arrayBuffer: ArrayBuffer +} + +interface WorkerFinishedMessage extends ScanMessage { + error?: { + message?: string + } + scan?: AppScanSnapshot + status: 'finished' +} + +function toArrayBuffer ( value: ArrayBuffer | ArrayBufferView ) { + if ( value instanceof ArrayBuffer ) { + return value + } + + return new Uint8Array( + value.buffer, + value.byteOffset, + value.byteLength + ).slice().buffer +} + +function isWorkerFinishedMessage ( details: ScanMessage | WorkerFinishedMessage ): details is WorkerFinishedMessage { + return details.status === 'finished' +} + +async function getArrayBufferFromFileData ( file: ScanFileLike ) { + if ( typeof file.arrayBuffer === 'function' ) { + return await file.arrayBuffer() + } + + if ( file.arrayBuffer instanceof ArrayBuffer ) { + return file.arrayBuffer + } + + if ( file.buffer instanceof ArrayBuffer ) { + return file.buffer + } + + if ( ArrayBuffer.isView( file.buffer ) ) { + return toArrayBuffer( file.buffer ) + } + + throw new Error( 'No fileArrayBuffer' ) +} + +function makeWorkerFile ( file: ScanFileLike, arrayBuffer: ArrayBuffer ): WorkerScanFile { + return { + arrayBuffer, + name: file.name, + size: file.size ?? arrayBuffer.byteLength, + type: file.type ?? file.mimeType ?? '' + } +} + +export async function runScanWorker ( + file: ScanFileLike, + messageReceiver: ScanMessageReceiver = noop +) { + const AppScanWorkerConstructor = AppScanWorker as unknown as { new (): Worker } + const appScanWorker = new AppScanWorkerConstructor() + const fileArrayBuffer = await getArrayBufferFromFileData( file ) + const workerFile = makeWorkerFile( file, fileArrayBuffer ) + + const scan = await new Promise( ( resolve, reject ) => { + const cleanup = () => { + appScanWorker.onmessage = null + appScanWorker.onerror = null + appScanWorker.terminate() + } + + appScanWorker.onmessage = ( event: MessageEvent ) => { + const details = event.data + + messageReceiver( details ) + + if ( !isWorkerFinishedMessage( details ) ) { + return + } + + cleanup() + + if ( details.scan ) { + resolve( details.scan ) + return + } + + reject( new Error( details.error?.message || details.message || 'Worker finished without a scan result.' ) ) + } + + appScanWorker.onerror = ( errorEvent: ErrorEvent ) => { + cleanup() + reject( new Error( errorEvent.message || 'Error received from App Scan Worker' ) ) + } + + appScanWorker.postMessage( { + status: 'start', + options: { + file: workerFile + } + }, [ + fileArrayBuffer + ] ) + } ) + + return { + appScanWorker, + scan + } +} diff --git a/helpers/scanner/scan.mjs b/helpers/scanner/scan.mjs deleted file mode 100644 index 7b23ce9..0000000 --- a/helpers/scanner/scan.mjs +++ /dev/null @@ -1,475 +0,0 @@ -import { Buffer } from 'buffer/index.js' -import prettyBytes from 'pretty-bytes' -import * as zip from '@zip.js/zip.js' - -import * as FileApi from './file-api.js' -import { isString, isNonEmptyString } from '../check-types.js' -import { parsePlistBuffer } from './parsers/plist.js' -import { extractMachoMeta } from './parsers/macho.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 -}) - - -function makeNodeFileBuffer ( buffer ) { - const fileBuffer = new Buffer.alloc( buffer.byteLength ) - - for (var i = 0; i < buffer.length; i++) - fileBuffer[i] = buffer[i]; - - // console.log( 'this.machoFileInstance', this.machoFileInstance.buffer.byteLength ) - - return fileBuffer -} - -export class AppScan { - constructor ({ - fileLoader, - messageReceiver - }) { - - this.fileLoader = fileLoader - this.messageReceiver = messageReceiver - - this.status = 'idle' - this.file = null - this.bundleFileEntries = [] - this.infoPlist = {} - this.machoExcutables = [] - - // Data that is derived after we've read the files and pulled out the infoPlist - this.appVersion = '' - this.displayName = '' - this.details = [] - this.bundleExecutable = null - this.displayBinarySize = '' - this.binarySize = 0 - this.machoMeta = {} - this.binarySupportsNative = undefined - - this.info = {} - } - - 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 hasMachoMeta () { - return Object.keys( this.machoMeta ).length > 0 - } - - get hasInfo () { - return Object.keys( this.info ).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 }` - } - - get supportedArchitectures () { - if ( !this.hasMachoMeta ) return [] - - return this.machoMeta.architectures.filter( architecture => architecture.processorType !== 0 ) - } - - async readFileEntryData ( fileEntry, Writer = zip.TextWriter ) { - // 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 Writer()//zip.TextWriter(), - ) - } - - async loadFile () { - // If fileLoader is no a function - // then try to load the file - if ( typeof( this.fileLoader ) !== 'function' ) { - return this.fileLoader - } - - const file = this.fileLoader() - - // Check if our file is a Promise - // if it is then await it - if ( file instanceof Promise || typeof file?.then === 'function' ) { - return await file - } - - return file - } - - getZipFileReader ( FileInstance ) { - // Check if file is a Blob, typically in the Browser - // otherwise convert it to a Blob, like in Node - // Both Browser and Node have Blob - // Node/Our File Polyfill references .arrayBuffer as a property - // Browser currently references .arrayBuffer as an async method - if ( FileInstance instanceof Blob ) { - return new zip.BlobReader( FileInstance ) - } - - if ( FileInstance instanceof ArrayBuffer ) { - return new zip.Uint8ArrayReader( FileInstance ) - } - - // return new zip.Uint8ArrayReader( new Uint8Array( FileInstance.arrayBuffer ) ) - // const FileBlob = FileInstance instanceof Blob ? FileInstance : new Blob( FileInstance.arrayBuffer ) - - throw new Error( 'FileInstance is not a known format' ) - } - - async readFileBlob ( FileInstance ) { - return new Promise( async ( resolve, reject ) => { - - console.log( 'FileInstance', FileInstance ) - - const binaryReader = this.getZipFileReader( FileInstance ) //new zip.BlobReader( FileBlob ) - - // https://gildas-lormeau.github.io/zip.js/core-api.html#zip-reading - const zipReader = new zip.ZipReader( binaryReader ) - - zipReader - .getEntries() - .then( entries => { - - // do something on entries - this.sendMessage({ - message: '📖 Reading file complete. Entries found', - status: 'read' - }) - - resolve( entries ) - }) - .catch( error => { - reject( error ) - }) - - }) - } - - classifyBinaryEntryArchitecture ( binaryEntry ) { - // Find an ARM Architecture - const armArchitecture = binaryEntry.architectures.find( architecture => { - // if ( architecture.processorType === 0 ) return false - - // If processorType not a string - // then return false - if ( !isString(architecture.processorType) ) return false - - return architecture.processorType.toLowerCase().includes('arm') - }) - - // Was an ARM Architecture found - return (armArchitecture !== undefined) - } - - matchesMachoExecutable ( entry ) { - // Skip files that are deeper than 3 folders - if ( entry.filename.split('/').length > 4 ) return false - - // Skip folders - // if ( !!entry.directory ) 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/`, - `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.matchesMachoExecutable( fileEntry ) ) return 'machoExecutable' - - 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 infoUint8Array = await this.readFileEntryData( fileEntry, zip.Uint8ArrayWriter ) - // console.log( 'infoUint8Array', infoUint8Array ) - - const infoNodeBuffer = makeNodeFileBuffer( infoUint8Array ) - - // Parse the Info.plist data - this.infoPlist = await parsePlistBuffer( infoNodeBuffer ) - - this.sendMessage({ - message: 'â„šī¸ Found Info.plist', - // data: this.infoPlist - }) - } - - storeMachoExecutable = ( fileEntry ) => { - this.machoExcutables.push( fileEntry ) - - this.sendMessage({ - message: 'đŸĨŠ Found a Macho executable', - // data: machoExecutable, - }) - } - - storeResultInfo () { - this.info = { - filename: this.file.name, - appVersion: this.appVersion, - result: this.binarySupportsNative ? '✅' : 'đŸ”ļ', - machoMeta: { - ...this.machoMeta, - file: undefined, - architectures: this.machoMeta.architectures.map( architecture => { - return { - bits: architecture.bits, - fileType: architecture.fileType, - header: architecture.header, - loadCommandsInfo: architecture.loadCommandsInfo, - magic: architecture.magic, - offset: architecture.offset, - processorSubType: architecture.processorSubType, - processorType: architecture.processorType, - } - }) - }, - infoPlist: this.infoPlist, - } - } - - storeMachoMeta = async ( fileEntry ) => { - // Throw if we have more than one target file - if ( this.hasMachoMeta ) { - throw new Error( 'More than one primary Macho executable found' ) - } - - // Get zip as Uint8Array - const bundleExecutableUint8Array = await this.readFileEntryData( fileEntry, zip.Uint8ArrayWriter ) - - const machoFileInstance = new FileApi.File({ - name: this.bundleExecutable.filename, - type: 'application/x-mach-binary', - buffer: Buffer.from( bundleExecutableUint8Array ) - }) - - // Get zip as blob - // so we can use it in for the File API when we're in the browser context - // https://gildas-lormeau.github.io/zip.js/core-api.html#zip-entry - machoFileInstance.blob = await this.readFileEntryData( fileEntry, zip.BlobWriter ) - - this.machoMeta = await extractMachoMeta({ - machoFileInstance, - FileApi - }) - - // console.log( 'this.machoMeta', this.machoMeta ) - - } - - - targetFiles = { - rootInfoPlist: { - method: this.storeInfoPlist - }, - machoExecutable: { - method: this.storeMachoExecutable, - } - } - - findMainExecutable () { - - // Now that we have the info.plist Determine our entry Macho Executable from the list of Macho Executables - const bundleExecutables = this.machoExcutables.filter( machoEntry => { - - if ( machoEntry.filename.includes( this.bundleExecutablePath ) ) { - return true - } - - return this.bundleExecutablePath.includes( machoEntry.filename ) - }) - - // Warn if Bundle Executable doesn't look right - if ( bundleExecutables.length > 1) { - throw new Error('More than one root bundleExecutable found', bundleExecutables) - } else if ( bundleExecutables.length === 0 ) { - throw new Error('No root bundleExecutable found', bundleExecutables) - } - - return bundleExecutables[ 0 ] - } - - 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 ] ) { - // console.log( 'fileEntry', type, fileEntry.filename ) - - // Call the target file method - await this.targetFiles[ type ].method( fileEntry ) - } - - // console.log( 'File Entry Type:', type ) - } - - // Now that we have the info.plist Determine our entry Macho Executable from the list of Macho Executables - - // Find valid app version that is a string but not empty - this.appVersion = ([ - this.infoPlist.CFBundleShortVersionString, - this.infoPlist.CFBundleVersion - ]).find( isNonEmptyString )[0] - - // Find Display Name that is a string but not empty - this.displayName = ([ - this.infoPlist.CFBundleDisplayName, - this.infoPlist.CFBundleName, - this.infoPlist.CFBundleExecutable, - ]).find( isNonEmptyString )[0] - - // We loop through possible details and add them to the details array - // if they are not empty - ;([ - [ 'Version', this.infoPlist.CFBundleShortVersionString ], - [ 'Bundle Identifier', this.infoPlist.CFBundleIdentifier ], - [ 'File Mime Type', this.file.type ], - [ 'Copyright', this.infoPlist.NSHumanReadableCopyright ], - // [ 'Version', info.CFBundleShortVersionString ], - ]).forEach( ([ label, value ]) => { - if ( !value || value.length === 0 ) return - - this.details.push({ - label, - value, - }) - } ) - - // Set the bundleExecutable - this.bundleExecutable = this.findMainExecutable() - - console.log('Parsing ', this.bundleExecutable.filename, this.bundleExecutable.uncompressedSize / 1000 ) - - this.displayBinarySize = prettyBytes( this.bundleExecutable.uncompressedSize ) - this.binarySize = this.bundleExecutable.uncompressedSize - - - await this.storeMachoMeta( this.bundleExecutable ) - - this.binarySupportsNative = this.classifyBinaryEntryArchitecture( this.machoMeta ) - } - - async runScan () { - // Load in the file - this.sendMessage({ - message: '🚛 Loading file...', - status: 'loading' - }) - - - this.file = await this.loadFile() - - this.sendMessage({ - message: '📚 Extracting from archive...', - status: 'scanning', - data: this.file - }) - - this.bundleFileEntries = await this.readFileBlob( this.file ) - - this.sendMessage({ - message: 'đŸŽŦ Starting scan', - status: 'scanning' - }) - - await this.findTargetFiles() - - this.storeResultInfo() - - this.sendMessage({ - message: '🔎 Checking online for native versions...', - status: 'checking' - }) - - // Sleep for 3 seconds - // await new Promise( resolve => setTimeout( resolve, 3000 ) ) - - this.sendMessage({ - message: '🏁 Scan complete! ', - status: 'finished' - }) - } - - async start () { - - try { - - await this.runScan() - - } catch ( error ) { - this.sendMessage({ - message: 'đŸšĢ Error: ' + error.message, - status: 'finished', - error - }) - } - - } -} diff --git a/helpers/scanner/scan.ts b/helpers/scanner/scan.ts new file mode 100644 index 0000000..bbe494d --- /dev/null +++ b/helpers/scanner/scan.ts @@ -0,0 +1,538 @@ +import { Buffer } from 'buffer/index.js' +import prettyBytes from 'pretty-bytes' +import * as zip from '@zip.js/zip.js' + +import * as FileApi from './file-api.js' +import { isNonEmptyString, isString } from '../check-types.js' +import { extractMachoMeta } from './parsers/macho.js' +import { parsePlistBuffer } from './parsers/plist.js' + +zip.configure({ + useWebWorkers: !import.meta.env.SSR +}) + +type MaybePromise = Promise | T + +type ScanStatus = 'idle' | 'loading' | 'read' | 'scanning' | 'checking' | 'finished' + +type FileArrayBuffer = ArrayBuffer + +export interface ScanFileLike { + arrayBuffer?: FileArrayBuffer | (() => Promise) + blob?: Blob + buffer?: ArrayBuffer | ArrayBufferView + mimeType?: string + name: string + size?: number + type?: string +} + +export interface ScanDetail { + label: string + value: string +} + +export interface ScanArchitecture { + bits?: unknown + fileType?: unknown + header?: unknown + loadCommandsInfo?: unknown + magic?: unknown + offset?: unknown + processorSubType?: unknown + processorType?: unknown +} + +export interface ScanMachoMeta { + architectures: ScanArchitecture[] + [ key: string ]: unknown +} + +export interface ScanInfo { + appVersion: string + filename: string + infoPlist: Record + machoMeta: ScanMachoMeta | null + result: '✅' | 'đŸ”ļ' +} + +export interface AppScanSnapshot { + appVersion: string + binarySize: number + binarySupportsNative: boolean + details: ScanDetail[] + displayBinarySize: string + displayName: string + hasInfo: boolean + hasInfoPlist: boolean + hasMachoMeta: boolean + info: ScanInfo + infoPlist: Record + machoMeta: ScanMachoMeta + status: ScanStatus + supportedArchitectures: ScanArchitecture[] +} + +export interface ScanMessage { + data?: unknown + error?: unknown + message?: string + status: ScanStatus +} + +interface ScanFileEntry { + directory?: boolean + filename: string + getData: ( writer: unknown ) => Promise + uncompressedSize: number +} + +interface ScanMachoFileInstance { + blob?: Blob + buffer: Buffer + name: string + type: string +} + +interface AppScanOptions { + fileLoader: (() => MaybePromise) | ArrayBuffer | Blob | ScanFileLike + messageReceiver?: ( details: ScanMessage ) => void +} + +function makeNodeFileBuffer ( buffer: Uint8Array ) { + const fileBuffer = Buffer.alloc( buffer.byteLength ) + + for ( let index = 0; index < buffer.length; index += 1 ) { + fileBuffer[ index ] = buffer[ index ] + } + + return fileBuffer +} + +function toArrayBuffer ( value: ArrayBuffer | ArrayBufferView ) { + if ( value instanceof ArrayBuffer ) { + return value + } + + return value.buffer.slice( + value.byteOffset, + value.byteOffset + value.byteLength + ) +} + +function isBlob ( value: unknown ): value is Blob { + return typeof Blob === 'function' && value instanceof Blob +} + +function firstNonEmptyString ( values: unknown[] ) { + const match = values.find( value => isNonEmptyString( value ) ) + + return typeof match === 'string' ? match : '' +} + +function isPromiseLike ( value: unknown ): value is PromiseLike { + return Boolean( value ) && typeof ( value as PromiseLike ).then === 'function' +} + +export class AppScan { + fileLoader: AppScanOptions['fileLoader'] + messageReceiver?: ( details: ScanMessage ) => void + status: ScanStatus + file: ArrayBuffer | Blob | ScanFileLike | null + bundleFileEntries: ScanFileEntry[] + infoPlist: Record + machoExcutables: ScanFileEntry[] + appVersion: string + displayName: string + details: ScanDetail[] + bundleExecutable: ScanFileEntry | null + displayBinarySize: string + binarySize: number + machoMeta: ScanMachoMeta + binarySupportsNative: boolean + info: ScanInfo + + constructor ( { + fileLoader, + messageReceiver + }: AppScanOptions ) { + this.fileLoader = fileLoader + this.messageReceiver = messageReceiver + + this.status = 'idle' + this.file = null + this.bundleFileEntries = [] + this.infoPlist = {} + this.machoExcutables = [] + + this.appVersion = '' + this.displayName = '' + this.details = [] + this.bundleExecutable = null + this.displayBinarySize = '' + this.binarySize = 0 + this.machoMeta = { + architectures: [] + } + this.binarySupportsNative = false + + this.info = { + appVersion: '', + filename: '', + infoPlist: {}, + machoMeta: null, + result: 'đŸ”ļ' + } + } + + sendMessage ( details: ScanMessage ) { + 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 hasMachoMeta () { + return this.machoMeta.architectures.length > 0 + } + + get hasInfo () { + return this.info.filename.length > 0 + } + + get bundleExecutablePath () { + const bundleExecutable = this.infoPlist.CFBundleExecutable + + if ( !isNonEmptyString( bundleExecutable ) ) return '' + + const executablePath = String( bundleExecutable ) + + if ( executablePath.includes( '/' ) ) return `/Contents/${ executablePath }` + + return `/Contents/MacOS/${ executablePath }` + } + + get supportedArchitectures () { + return this.machoMeta.architectures.filter( architecture => architecture.processorType !== 0 ) + } + + async readFileEntryData ( fileEntry: ScanFileEntry, Writer: new () => T = zip.TextWriter as new () => T ) { + return await fileEntry.getData( + new Writer() + ) + } + + async loadFile (): Promise { + if ( typeof this.fileLoader !== 'function' ) { + return this.fileLoader + } + + const file = this.fileLoader() + + if ( file instanceof Promise || isPromiseLike( file ) ) { + return await file as ArrayBuffer | Blob | ScanFileLike + } + + return file + } + + async getZipFileReader ( fileInstance: ArrayBuffer | Blob | ScanFileLike ) { + if ( isBlob( fileInstance ) ) { + return new zip.BlobReader( fileInstance ) + } + + if ( fileInstance instanceof ArrayBuffer ) { + return new zip.Uint8ArrayReader( new Uint8Array( fileInstance ) ) + } + + if ( isBlob( fileInstance.blob ) ) { + return new zip.BlobReader( fileInstance.blob ) + } + + if ( typeof fileInstance.arrayBuffer === 'function' ) { + return new zip.Uint8ArrayReader( new Uint8Array( await fileInstance.arrayBuffer() ) ) + } + + if ( fileInstance.arrayBuffer instanceof ArrayBuffer ) { + return new zip.Uint8ArrayReader( new Uint8Array( fileInstance.arrayBuffer ) ) + } + + if ( fileInstance.buffer instanceof ArrayBuffer ) { + return new zip.Uint8ArrayReader( new Uint8Array( fileInstance.buffer ) ) + } + + if ( ArrayBuffer.isView( fileInstance.buffer ) ) { + return new zip.Uint8ArrayReader( new Uint8Array( toArrayBuffer( fileInstance.buffer ) ) ) + } + + throw new Error( 'FileInstance is not a known format' ) + } + + async readFileBlob ( fileInstance: ArrayBuffer | Blob | ScanFileLike ) { + const binaryReader = await this.getZipFileReader( fileInstance ) + const zipReader = new zip.ZipReader( binaryReader ) + const entries = await zipReader.getEntries() + + this.sendMessage({ + message: '📖 Reading file complete. Entries found', + status: 'read' + }) + + return entries as ScanFileEntry[] + } + + classifyBinaryEntryArchitecture ( binaryEntry: ScanMachoMeta ) { + const armArchitecture = binaryEntry.architectures.find( architecture => { + if ( !isString( architecture.processorType ) ) return false + + return architecture.processorType.toLowerCase().includes( 'arm' ) + } ) + + return armArchitecture !== undefined + } + + matchesMachoExecutable ( entry: ScanFileEntry ) { + if ( entry.filename.split( '/' ).length > 4 ) return false + + return [ + 'Contents/MacOS/' + ].some( pathToMatch => { + return entry.filename.includes( pathToMatch ) + } ) + } + + matchesRootInfoPlist ( entry: ScanFileEntry ) { + if ( entry.filename.split( '/' ).length > 3 ) return false + if ( entry.filename.endsWith( '/' ) ) return false + if ( entry.filename === 'Contents/Info.plist' ) return true + + return [ + '.app/Contents/Info.plist', + '.zip/Contents/Info.plist' + ].some( pathToMatch => { + return entry.filename.endsWith( pathToMatch ) + } ) + } + + fileEntryType ( fileEntry: ScanFileEntry ) { + if ( fileEntry.directory ) return 'directory' + if ( this.matchesMachoExecutable( fileEntry ) ) return 'machoExecutable' + if ( this.matchesRootInfoPlist( fileEntry ) ) return 'rootInfoPlist' + + return 'unknown' + } + + storeInfoPlist = async ( fileEntry: ScanFileEntry ) => { + if ( this.hasInfoPlist ) { + throw new Error( 'More than one root info.plist found' ) + } + + const infoUint8Array = await this.readFileEntryData>( fileEntry, zip.Uint8ArrayWriter as new () => InstanceType ) as Uint8Array + const infoNodeBuffer = makeNodeFileBuffer( infoUint8Array ) + + this.infoPlist = await parsePlistBuffer( infoNodeBuffer ) as Record + + this.sendMessage({ + message: 'â„šī¸ Found Info.plist', + status: this.status + }) + } + + storeMachoExecutable = ( fileEntry: ScanFileEntry ) => { + this.machoExcutables.push( fileEntry ) + + this.sendMessage({ + message: 'đŸĨŠ Found a Macho executable', + status: this.status + }) + } + + storeResultInfo () { + this.info = { + appVersion: this.appVersion, + filename: this.file && 'name' in this.file && typeof this.file.name === 'string' ? this.file.name : '', + infoPlist: this.infoPlist, + machoMeta: this.hasMachoMeta ? { + ...this.machoMeta, + architectures: this.machoMeta.architectures.map( architecture => { + return { + bits: architecture.bits, + fileType: architecture.fileType, + header: architecture.header, + loadCommandsInfo: architecture.loadCommandsInfo, + magic: architecture.magic, + offset: architecture.offset, + processorSubType: architecture.processorSubType, + processorType: architecture.processorType + } + }) + } : null, + result: this.binarySupportsNative ? '✅' : 'đŸ”ļ' + } + } + + storeMachoMeta = async ( fileEntry: ScanFileEntry ) => { + if ( this.hasMachoMeta ) { + throw new Error( 'More than one primary Macho executable found' ) + } + + if ( !this.bundleExecutable ) { + throw new Error( 'No root bundleExecutable found' ) + } + + const bundleExecutableUint8Array = await this.readFileEntryData>( fileEntry, zip.Uint8ArrayWriter as new () => InstanceType ) as Uint8Array + + const machoFileInstance = new FileApi.File({ + buffer: Buffer.from( bundleExecutableUint8Array ), + name: this.bundleExecutable.filename, + type: 'application/x-mach-binary' + }) as ScanMachoFileInstance + + machoFileInstance.blob = await this.readFileEntryData>( fileEntry, zip.BlobWriter as new () => InstanceType ) as Blob + + const machoMeta = await extractMachoMeta({ + FileApi, + machoFileInstance + }) as ScanMachoMeta | null + + if ( !machoMeta || !Array.isArray( machoMeta.architectures ) ) { + throw new Error( 'Unable to read Mach-O metadata' ) + } + + this.machoMeta = machoMeta + } + + targetFiles = { + machoExecutable: { + method: this.storeMachoExecutable + }, + rootInfoPlist: { + method: this.storeInfoPlist + } + } + + findMainExecutable () { + const bundleExecutables = this.machoExcutables.filter( machoEntry => { + if ( machoEntry.filename.includes( this.bundleExecutablePath ) ) { + return true + } + + return this.bundleExecutablePath.includes( machoEntry.filename ) + } ) + + if ( bundleExecutables.length > 1 ) { + throw new Error( 'More than one root bundleExecutable found' ) + } + + if ( bundleExecutables.length === 0 ) { + throw new Error( 'No root bundleExecutable found' ) + } + + return bundleExecutables[ 0 ] + } + + async findTargetFiles () { + for ( const fileEntry of this.bundleFileEntries ) { + const type = this.fileEntryType( fileEntry ) as keyof typeof this.targetFiles | 'directory' | 'unknown' + + if ( type in this.targetFiles ) { + await this.targetFiles[ type as keyof typeof this.targetFiles ].method( fileEntry ) + } + } + + this.appVersion = firstNonEmptyString( [ + this.infoPlist.CFBundleShortVersionString, + this.infoPlist.CFBundleVersion + ] ) + + this.displayName = firstNonEmptyString( [ + this.infoPlist.CFBundleDisplayName, + this.infoPlist.CFBundleName, + this.infoPlist.CFBundleExecutable + ] ) + + ;([ + [ 'Version', this.infoPlist.CFBundleShortVersionString ], + [ 'Bundle Identifier', this.infoPlist.CFBundleIdentifier ], + [ 'File Mime Type', this.file && 'type' in this.file ? this.file.type : '' ], + [ 'Copyright', this.infoPlist.NSHumanReadableCopyright ] + ] as Array<[ string, unknown ]>).forEach( ( [ label, value ] ) => { + if ( !isNonEmptyString( value ) ) return + + this.details.push({ + label, + value: String( value ) + }) + } ) + + this.bundleExecutable = this.findMainExecutable() + + this.displayBinarySize = prettyBytes( this.bundleExecutable.uncompressedSize ) + this.binarySize = this.bundleExecutable.uncompressedSize + + await this.storeMachoMeta( this.bundleExecutable ) + + this.binarySupportsNative = this.classifyBinaryEntryArchitecture( this.machoMeta ) + } + + async runScan () { + this.sendMessage({ + message: '🚛 Loading file...', + status: 'loading' + }) + + this.file = await this.loadFile() + + this.sendMessage({ + data: this.file, + message: '📚 Extracting from archive...', + status: 'scanning' + }) + + this.bundleFileEntries = await this.readFileBlob( this.file ) + + this.sendMessage({ + message: 'đŸŽŦ Starting scan', + status: 'scanning' + }) + + await this.findTargetFiles() + + this.storeResultInfo() + + this.sendMessage({ + message: '🔎 Checking online for native versions...', + status: 'checking' + }) + } + + toSnapshot (): AppScanSnapshot { + return { + appVersion: this.appVersion, + binarySize: this.binarySize, + binarySupportsNative: this.binarySupportsNative, + details: this.details, + displayBinarySize: this.displayBinarySize, + displayName: this.displayName, + hasInfo: this.hasInfo, + hasInfoPlist: this.hasInfoPlist, + hasMachoMeta: this.hasMachoMeta, + info: this.info, + infoPlist: this.infoPlist, + machoMeta: this.machoMeta, + status: 'finished', + supportedArchitectures: this.supportedArchitectures + } + } + + async start () { + await this.runScan() + } +} diff --git a/helpers/scanner/worker.mjs b/helpers/scanner/worker.mjs deleted file mode 100644 index a743f2a..0000000 --- a/helpers/scanner/worker.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import { AppScan } from './scan.mjs' - -self.onmessage = ( event ) => { - - console.log( 'Worker received message', event ) - - const { status } = event.data - - // https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage - // self.postMessage( event ) - - - if ( status === 'start' ) { - // Get Scan Options - const { options } = event.data - - // console.log( 'options', options ) - - const scan = new AppScan({ - fileLoader: options.file, - // Use self.postMessage as the message callback - messageReceiver: ( details ) => { - self.postMessage( details ) - } - }) - - scan.start() - .then( () => { - self.postMessage( { - status: 'finished', - // Convert App Scan instance to a more primitive Object - // so that it's clonneable for our worker - scan: JSON.parse(JSON.stringify( scan )) - }) - }) - - return - } - - - self.postMessage( { status: 'finished' } ) - return -} diff --git a/helpers/scanner/worker.ts b/helpers/scanner/worker.ts new file mode 100644 index 0000000..ccd1215 --- /dev/null +++ b/helpers/scanner/worker.ts @@ -0,0 +1,72 @@ +/// + +import { + AppScan, + type AppScanSnapshot, + type ScanFileLike, + type ScanMessage +} from './scan' + +type WorkerRequest = + | { + options: { + file: ScanFileLike + } + status: 'start' + } + | { + status: string + } + +type WorkerResponse = + | ScanMessage + | { + error?: { + message?: string + } + message?: string + scan?: AppScanSnapshot + status: 'finished' + } + +const workerScope = self as unknown as DedicatedWorkerGlobalScope + +function isStartRequest ( request: WorkerRequest ): request is Extract { + return request.status === 'start' +} + +workerScope.onmessage = async ( event: MessageEvent ) => { + if ( !isStartRequest( event.data ) ) { + workerScope.postMessage( { + status: 'finished' + } satisfies WorkerResponse ) + return + } + + const { options } = event.data + const scan = new AppScan({ + fileLoader: options.file, + messageReceiver: ( details ) => { + workerScope.postMessage( details satisfies WorkerResponse ) + } + }) + + try { + await scan.start() + + workerScope.postMessage( { + scan: scan.toSnapshot(), + status: 'finished' + } satisfies WorkerResponse ) + } catch ( error ) { + const message = error instanceof Error ? error.message : String( error ) + + workerScope.postMessage( { + error: { + message + }, + message: `đŸšĢ Error: ${ message }`, + status: 'finished' + } satisfies WorkerResponse ) + } +} diff --git a/test/_disabled/scanner/client.test.mjs b/test/_disabled/scanner/client.test.mjs index 8304082..d9513ca 100644 --- a/test/_disabled/scanner/client.test.mjs +++ b/test/_disabled/scanner/client.test.mjs @@ -15,7 +15,7 @@ import glob from 'fast-glob' import { LocalFileData } from 'get-file-object-from-local-path' import { Zip } from 'zip-lib' -import { runScanWorker } from '~/helpers/scanner/client.mjs' +import { runScanWorker } from '~/helpers/scanner/client' const appGlobOptions = { @@ -142,4 +142,3 @@ describe.concurrent('Apps', async () => { }) - diff --git a/test/playwright/support/app-archive-fixture.ts b/test/playwright/support/app-archive-fixture.ts index be0ff47..14b132a 100644 --- a/test/playwright/support/app-archive-fixture.ts +++ b/test/playwright/support/app-archive-fixture.ts @@ -58,11 +58,10 @@ export async function createNativeAppArchive ( appName = 'Playwright Native App' const archiveBuffer = await readFile( archivePath ) + const archiveArrayBuffer = new Uint8Array( archiveBuffer ).slice().buffer + return { - arrayBuffer: archiveBuffer.buffer.slice( - archiveBuffer.byteOffset, - archiveBuffer.byteOffset + archiveBuffer.byteLength - ), + arrayBuffer: archiveArrayBuffer, buffer: archiveBuffer, mimeType: 'application/zip', name: `${ appName }.app.zip`, diff --git a/test/scanner/client.test.ts b/test/scanner/client.test.ts new file mode 100644 index 0000000..57894b9 --- /dev/null +++ b/test/scanner/client.test.ts @@ -0,0 +1,48 @@ +import { + describe, + expect, + it +} from 'vitest' +import '@vitest/web-worker' + +import { runScanWorker } from '~/helpers/scanner/client' + +import { createNativeAppArchive } from '../playwright/support/app-archive-fixture' + +describe( 'scanner worker client', () => { + it( 'extracts app metadata from a zipped native app fixture', async () => { + const progressMessages: string[] = [] + const archiveFile = await createNativeAppArchive() + + const { scan } = await runScanWorker( archiveFile, details => { + if ( typeof details.message === 'string' ) { + progressMessages.push( details.message ) + } + } ) + + expect( progressMessages ).toContain( 'â„šī¸ Found Info.plist' ) + expect( scan.status ).toBe( 'finished' ) + expect( scan.displayName ).toBe( 'Playwright Native App' ) + expect( scan.appVersion ).toBe( '1.0.0' ) + expect( scan.binarySupportsNative ).toBe( true ) + expect( scan.displayBinarySize.length ).toBeGreaterThan( 0 ) + expect( scan.details ).toEqual( expect.arrayContaining( [ + expect.objectContaining({ + label: 'Bundle Identifier', + value: 'com.doesitarm.playwright-native-app' + }), + expect.objectContaining({ + label: 'Version', + value: '1.0.0' + }) + ] ) ) + expect( scan.info.filename ).toBe( 'Playwright Native App.app.zip' ) + expect( scan.info.result ).toBe( '✅' ) + expect( scan.info.infoPlist.CFBundleIdentifier ).toBe( 'com.doesitarm.playwright-native-app' ) + expect( scan.info.machoMeta.architectures ).toEqual( expect.arrayContaining( [ + expect.objectContaining({ + processorType: 'ARM64' + }) + ] ) ) + } ) +} )