mirror of
https://github.com/ThatGuySam/doesitarm.git
synced 2026-05-15 06:35:20 -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'
|
import parseMacho from './macho/index.js'
|
||||||
|
|
||||||
// Vite Web Workers - https://vitejs.dev/guide/features.html#web-workers
|
// 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 = (() => {
|
const scannerVersion = (() => {
|
||||||
// If there's no window
|
// If there's no window
|
||||||
|
|
@ -341,6 +341,10 @@ export default class AppFilesScanner {
|
||||||
.then( response => response.data )
|
.then( response => response.data )
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|
||||||
|
return {
|
||||||
|
supportedVersionNumber: null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
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 ) {
|
async scanFile ( file, scanIndex ) {
|
||||||
|
|
||||||
// If we've already scanned this
|
// If we've already scanned this
|
||||||
|
|
@ -553,26 +598,10 @@ export default class AppFilesScanner {
|
||||||
|
|
||||||
console.log('supportedVersionNumber', supportedVersionNumber)
|
console.log('supportedVersionNumber', supportedVersionNumber)
|
||||||
|
|
||||||
let finishedStatusMessage = ''
|
this.finishFileScan( file, scanIndex, {
|
||||||
|
binarySupportsNative,
|
||||||
if ( binarySupportsNative ) {
|
supportedVersionNumber
|
||||||
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'
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -618,20 +647,45 @@ export default class AppFilesScanner {
|
||||||
console.log( 'scannerVersion', scannerVersion )
|
console.log( 'scannerVersion', scannerVersion )
|
||||||
|
|
||||||
if ( scannerVersion === '2' ) {
|
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 => {
|
if ( isString( messageDetails.status ) ) {
|
||||||
console.log( 'messageDetails', messageDetails )
|
file.status = messageDetails.status
|
||||||
|
}
|
||||||
|
} )
|
||||||
|
|
||||||
file.statusMessage = messageDetails.message
|
this.applyWorkerScanData( file, scan )
|
||||||
file.status = messageDetails.status
|
|
||||||
} )
|
|
||||||
|
|
||||||
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
|
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 { LocalFileData } from 'get-file-object-from-local-path'
|
||||||
import { Zip } from 'zip-lib'
|
import { Zip } from 'zip-lib'
|
||||||
|
|
||||||
import { runScanWorker } from '~/helpers/scanner/client.mjs'
|
import { runScanWorker } from '~/helpers/scanner/client'
|
||||||
|
|
||||||
|
|
||||||
const appGlobOptions = {
|
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 archiveBuffer = await readFile( archivePath )
|
||||||
|
|
||||||
|
const archiveArrayBuffer = new Uint8Array( archiveBuffer ).slice().buffer
|
||||||
|
|
||||||
return {
|
return {
|
||||||
arrayBuffer: archiveBuffer.buffer.slice(
|
arrayBuffer: archiveArrayBuffer,
|
||||||
archiveBuffer.byteOffset,
|
|
||||||
archiveBuffer.byteOffset + archiveBuffer.byteLength
|
|
||||||
),
|
|
||||||
buffer: archiveBuffer,
|
buffer: archiveBuffer,
|
||||||
mimeType: 'application/zip',
|
mimeType: 'application/zip',
|
||||||
name: `${ appName }.app.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