mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix(sidebar): phone feed list leftovers before the flip (dark) Bump the feed's semantics version for P11's desktop facts; mark switch-on phone page bundles so packaging and orcad staging refuse them; show the page's last-listed rows before the first feed read; draw the host's pending creations; pass the listed host's own label into host sections instead of the running device's platform. * fix(sidebar): review fixes for the phone feed leftovers (dark) A dark orcad may stage the dark page (only a marker of an off switch is refused); the bundle tree writer leaves the marker; the pre-feed list keeps the view's filters; a creation names its project only outside that project's header and survives search when its project matches; the feed view and row labels read one resolved desktop platform. * fix(sidebar): second review fixes for the phone feed leftovers (dark) Test the off-switch marker check across switches; resolve the desktop platform once for rows and headers; a section search narrows keeps only matching rows.
201 lines
7.6 KiB
JavaScript
201 lines
7.6 KiB
JavaScript
const { createHash } = require('node:crypto')
|
|
const { readFileSync, readdirSync, statSync } = require('node:fs')
|
|
const { join, resolve } = require('node:path')
|
|
|
|
const { darkBuildMarkerFileNames } = require('../dark-build-switches.cjs')
|
|
|
|
const projectDir = resolve(__dirname, '..', '..')
|
|
const MOBILE_WEB_BUNDLE_DIR = join(projectDir, 'out', 'mobile-web')
|
|
const REMEDY = 'Run pnpm build:mobile-web (build:desktop and build:release already do).'
|
|
const ENTRYPOINT = 'index.html'
|
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/
|
|
|
|
function failure(message) {
|
|
return new Error(`[verify-packaged-mobile-web-bundle] ${message}`)
|
|
}
|
|
|
|
function assertSafeRelativePath(path) {
|
|
if (typeof path !== 'string' || path.length === 0) {
|
|
throw failure('manifest asset has a missing or empty path')
|
|
}
|
|
const segments = path.split('/')
|
|
if (
|
|
path.includes('\\') ||
|
|
path.startsWith('/') ||
|
|
/^[a-zA-Z]:/.test(path) ||
|
|
segments.some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
) {
|
|
throw failure(`manifest asset path is not a safe relative path: ${path}`)
|
|
}
|
|
}
|
|
|
|
function assertInteger(value, field) {
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw failure(`manifest field ${field} is not a non-negative integer: ${String(value)}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Canonical serialization of the asset list. Must stay byte-identical to
|
|
* serializeMobileWebBundleAssets in config/scripts/mobile-web-bundle-manifest.mjs; a divergence here
|
|
* would reject every honest bundle, so the two move together.
|
|
*/
|
|
function serializeAssets(assets) {
|
|
return JSON.stringify(
|
|
[...assets]
|
|
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
|
.map(({ path, sha256, byteLength, contentType }) => ({
|
|
path,
|
|
sha256,
|
|
byteLength,
|
|
contentType
|
|
}))
|
|
)
|
|
}
|
|
|
|
function parseManifest(bundleDir) {
|
|
const manifestPath = join(bundleDir, 'manifest.json')
|
|
let raw
|
|
try {
|
|
raw = readFileSync(manifestPath, 'utf8')
|
|
} catch (error) {
|
|
throw failure(
|
|
`no bundle manifest at ${manifestPath} (${error.code ?? error.message}). ${REMEDY}`
|
|
)
|
|
}
|
|
let manifest
|
|
try {
|
|
manifest = JSON.parse(raw)
|
|
} catch (error) {
|
|
throw failure(`${manifestPath} is not valid JSON: ${error.message}. ${REMEDY}`)
|
|
}
|
|
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
|
|
throw failure(`${manifestPath} is not a JSON object. ${REMEDY}`)
|
|
}
|
|
if (manifest.schemaVersion !== 1) {
|
|
throw failure(`unsupported manifest schemaVersion: ${String(manifest.schemaVersion)}`)
|
|
}
|
|
if (typeof manifest.buildId !== 'string' || !SHA256_PATTERN.test(manifest.buildId)) {
|
|
throw failure(`manifest buildId is not a sha256 digest: ${String(manifest.buildId)}`)
|
|
}
|
|
if (typeof manifest.desktopVersion !== 'string' || manifest.desktopVersion.length === 0) {
|
|
throw failure('manifest desktopVersion is missing')
|
|
}
|
|
assertInteger(manifest.minCompatibleRuntimeProtocolVersion, 'minCompatibleRuntimeProtocolVersion')
|
|
assertInteger(manifest.runtimeProtocolVersion, 'runtimeProtocolVersion')
|
|
assertInteger(manifest.totalBytes, 'totalBytes')
|
|
if (manifest.entrypoint !== ENTRYPOINT) {
|
|
throw failure(`manifest entrypoint must be ${ENTRYPOINT}, got ${String(manifest.entrypoint)}`)
|
|
}
|
|
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
|
|
throw failure('manifest lists no assets')
|
|
}
|
|
for (const asset of manifest.assets) {
|
|
if (typeof asset !== 'object' || asset === null) {
|
|
throw failure('manifest asset entry is not an object')
|
|
}
|
|
assertSafeRelativePath(asset.path)
|
|
if (typeof asset.sha256 !== 'string' || !SHA256_PATTERN.test(asset.sha256)) {
|
|
throw failure(`manifest asset ${asset.path} has no sha256 digest`)
|
|
}
|
|
assertInteger(asset.byteLength, `assets[${asset.path}].byteLength`)
|
|
if (typeof asset.contentType !== 'string' || asset.contentType.length === 0) {
|
|
throw failure(`manifest asset ${asset.path} has no contentType`)
|
|
}
|
|
}
|
|
if (!manifest.assets.some((asset) => asset.path === manifest.entrypoint)) {
|
|
throw failure(`manifest entrypoint ${manifest.entrypoint} is not one of its assets`)
|
|
}
|
|
const declaredTotal = manifest.assets.reduce((total, asset) => total + asset.byteLength, 0)
|
|
if (declaredTotal !== manifest.totalBytes) {
|
|
throw failure(
|
|
`manifest totalBytes is ${String(manifest.totalBytes)}, its assets sum to ${String(declaredTotal)}`
|
|
)
|
|
}
|
|
const recomputed = createHash('sha256')
|
|
.update(serializeAssets(manifest.assets), 'utf8')
|
|
.digest('hex')
|
|
if (recomputed !== manifest.buildId) {
|
|
throw failure(
|
|
`manifest buildId ${manifest.buildId} does not match its asset list (expected ${recomputed}). ${REMEDY}`
|
|
)
|
|
}
|
|
return manifest
|
|
}
|
|
|
|
/** Every file under the bundle directory, as a manifest-shaped relative path. */
|
|
function listBundleFiles(directory, prefix = '') {
|
|
const found = []
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const relativePath = prefix === '' ? entry.name : `${prefix}/${entry.name}`
|
|
if (entry.isDirectory()) {
|
|
found.push(...listBundleFiles(join(directory, entry.name), relativePath))
|
|
} else {
|
|
found.push(relativePath)
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
/**
|
|
* Nothing in the bundle directory may be unaccounted for. An asset dropped from the manifest but
|
|
* left on disk by an interrupted build ships inside asar, unreachable and unverified, and grows
|
|
* the installer; content-addressed names mean stale copies never get overwritten.
|
|
*/
|
|
function assertNoUnlistedFiles(bundleDir, manifest) {
|
|
// A dark build's marker is accounted for here; packaging refuses it through assertNotDarkBuild.
|
|
const listed = new Set([
|
|
'manifest.json',
|
|
...darkBuildMarkerFileNames(),
|
|
...manifest.assets.map((asset) => asset.path)
|
|
])
|
|
const strays = listBundleFiles(bundleDir).filter((path) => !listed.has(path))
|
|
if (strays.length > 0) {
|
|
throw failure(
|
|
`${bundleDir} holds ${String(strays.length)} file(s) the manifest does not list: ` +
|
|
`${strays.sort().join(', ')}. ${REMEDY}`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Packaging guard: electron-builder only warns about a missing input, so without this a release
|
|
* would ship an app that advertises the bundle capability and then errors on every request. The
|
|
* hash check, not the existence check, is what catches a half-written or stale out/.
|
|
*/
|
|
function assertMobileWebBundleBuilt(bundleDir = MOBILE_WEB_BUNDLE_DIR) {
|
|
const manifest = parseManifest(bundleDir)
|
|
assertNoUnlistedFiles(bundleDir, manifest)
|
|
for (const asset of manifest.assets) {
|
|
const assetPath = join(bundleDir, asset.path)
|
|
let size
|
|
try {
|
|
size = statSync(assetPath).size
|
|
} catch (error) {
|
|
throw failure(
|
|
`manifest lists ${asset.path}, which is missing from ${bundleDir} (${error.code ?? error.message}). ${REMEDY}`
|
|
)
|
|
}
|
|
if (size !== asset.byteLength) {
|
|
throw failure(
|
|
`${asset.path} is ${String(size)} bytes on disk, manifest says ${String(asset.byteLength)}. ${REMEDY}`
|
|
)
|
|
}
|
|
const sha256 = createHash('sha256').update(readFileSync(assetPath)).digest('hex')
|
|
if (sha256 !== asset.sha256) {
|
|
throw failure(
|
|
`${asset.path} hashes to ${sha256} on disk, manifest says ${asset.sha256}. ${REMEDY}`
|
|
)
|
|
}
|
|
}
|
|
console.log(
|
|
`[verify-packaged-mobile-web-bundle] OK — buildId ${manifest.buildId}, ` +
|
|
`${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes`
|
|
)
|
|
return manifest
|
|
}
|
|
|
|
// serializeAssets is exported for the parity test that pins it against the builder's and the
|
|
// contract's serializers; nothing in packaging calls it from outside this module.
|
|
module.exports = { MOBILE_WEB_BUNDLE_DIR, assertMobileWebBundleBuilt, serializeAssets }
|