mirror of
https://github.com/ThatGuySam/doesitarm.git
synced 2026-05-15 06:35:20 -07:00
Madge treated the local plist parser module name as a circular dependency during the Netlify build lane after the TypeScript refactor. Rename the local module to plist-parser so the internal file no longer collides with the external plist package name, while keeping parser behavior unchanged. Constraint: Must restore the deploy gate without changing parser semantics Rejected: Disable the circular-dependency check | would hide a useful guard instead of fixing the naming conflict that triggered it Confidence: high Scope-risk: narrow Reversibility: clean Directive: Avoid naming local modules after direct external package imports when the repo relies on static dependency graph tooling Tested: pnpm run typecheck; pnpm exec vitest run test/scanner/plist.test.ts test/scanner/file-api.test.ts test/scanner/client.test.ts test/prebuild/load-sitemap-endpoints.test.ts; pnpm run test-prebuild Not-tested: Full production deploy completion before push
539 lines
16 KiB
TypeScript
539 lines
16 KiB
TypeScript
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'
|
||
import type { NodeFile } from './file-api'
|
||
import { isNonEmptyString, isString } from '../check-types.js'
|
||
import { extractMachoMeta } from './parsers/macho.js'
|
||
import { parsePlistBuffer } from './parsers/plist-parser'
|
||
|
||
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: NodeFile['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 ) as unknown as NodeFile['buffer'],
|
||
name: this.bundleExecutable.filename,
|
||
type: 'application/x-mach-binary'
|
||
}) as unknown 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()
|
||
}
|
||
}
|