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:
ThatGuySam 2026-04-04 14:58:25 -05:00
parent 0480c47bbb
commit 689fc0d13d
10 changed files with 869 additions and 657 deletions

View file

@ -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
}

View file

@ -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
View 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
}
}

View file

@ -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
View 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()
}
}

View file

@ -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
View 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 )
}
}

View file

@ -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 () => {
})

View file

@ -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`,

View 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'
})
] ) )
} )
} )