mirror of
https://github.com/ThatGuySam/doesitarm.git
synced 2026-05-18 06:44:46 -07:00
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)
This commit is contained in:
parent
0480c47bbb
commit
689fc0d13d
10 changed files with 869 additions and 657 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
124
helpers/scanner/client.ts
Normal file
124
helpers/scanner/client.ts
Normal file
|
|
@ -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<AppScanSnapshot>( ( resolve, reject ) => {
|
||||
const cleanup = () => {
|
||||
appScanWorker.onmessage = null
|
||||
appScanWorker.onerror = null
|
||||
appScanWorker.terminate()
|
||||
}
|
||||
|
||||
appScanWorker.onmessage = ( event: MessageEvent<ScanMessage | WorkerFinishedMessage> ) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
538
helpers/scanner/scan.ts
Normal file
538
helpers/scanner/scan.ts
Normal file
|
|
@ -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<T> = Promise<T> | T
|
||||
|
||||
type ScanStatus = 'idle' | 'loading' | 'read' | 'scanning' | 'checking' | 'finished'
|
||||
|
||||
type FileArrayBuffer = ArrayBuffer
|
||||
|
||||
export interface ScanFileLike {
|
||||
arrayBuffer?: FileArrayBuffer | (() => Promise<FileArrayBuffer>)
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<unknown>
|
||||
uncompressedSize: number
|
||||
}
|
||||
|
||||
interface ScanMachoFileInstance {
|
||||
blob?: Blob
|
||||
buffer: Buffer
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface AppScanOptions {
|
||||
fileLoader: (() => MaybePromise<ArrayBuffer | Blob | ScanFileLike>) | 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<T> ( value: unknown ): value is PromiseLike<T> {
|
||||
return Boolean( value ) && typeof ( value as PromiseLike<T> ).then === 'function'
|
||||
}
|
||||
|
||||
export class AppScan {
|
||||
fileLoader: AppScanOptions['fileLoader']
|
||||
messageReceiver?: ( details: ScanMessage ) => void
|
||||
status: ScanStatus
|
||||
file: ArrayBuffer | Blob | ScanFileLike | null
|
||||
bundleFileEntries: ScanFileEntry[]
|
||||
infoPlist: Record<string, unknown>
|
||||
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<T> ( fileEntry: ScanFileEntry, Writer: new () => T = zip.TextWriter as new () => T ) {
|
||||
return await fileEntry.getData(
|
||||
new Writer()
|
||||
)
|
||||
}
|
||||
|
||||
async loadFile (): Promise<ArrayBuffer | Blob | ScanFileLike> {
|
||||
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<InstanceType<typeof zip.Uint8ArrayWriter>>( fileEntry, zip.Uint8ArrayWriter as new () => InstanceType<typeof zip.Uint8ArrayWriter> ) as Uint8Array
|
||||
const infoNodeBuffer = makeNodeFileBuffer( infoUint8Array )
|
||||
|
||||
this.infoPlist = await parsePlistBuffer( infoNodeBuffer ) as Record<string, unknown>
|
||||
|
||||
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<InstanceType<typeof zip.Uint8ArrayWriter>>( fileEntry, zip.Uint8ArrayWriter as new () => InstanceType<typeof zip.Uint8ArrayWriter> ) 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<InstanceType<typeof zip.BlobWriter>>( fileEntry, zip.BlobWriter as new () => InstanceType<typeof zip.BlobWriter> ) 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
72
helpers/scanner/worker.ts
Normal file
72
helpers/scanner/worker.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/// <reference lib="webworker" />
|
||||
|
||||
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<WorkerRequest, { status: 'start' }> {
|
||||
return request.status === 'start'
|
||||
}
|
||||
|
||||
workerScope.onmessage = async ( event: MessageEvent<WorkerRequest> ) => {
|
||||
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 )
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () => {
|
|||
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
48
test/scanner/client.test.ts
Normal file
48
test/scanner/client.test.ts
Normal file
|
|
@ -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'
|
||||
})
|
||||
] ) )
|
||||
} )
|
||||
} )
|
||||
Loading…
Add table
Add a link
Reference in a new issue