mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* feat(mobile): add UI localization infrastructure Mobile follows the phone's language (or an in-app choice under Settings > Appearance) across the same 11 languages as desktop, switching live like the theme does. - i18next with desktop's translate(key, fallback, options) call shape; catalogs in mobile/src/i18n/locales load lazily, English fallbacks stay inline - Hermes lacks Intl.PluralRules: load the FormatJS polyfill with the 11 languages' rules - OTA pages get the shell's language through an optional bridge init field - iOS permission prompts localized; iOS and Android 13+ per-app language pickers - verify:mobile-localization and a coverage ratchet run in lint, check:changed and CI * fix(mobile): mount pages after the newest language request; tighten localization gates - Wait for the latest page language activation, not a superseded one - Keep literal fallbacks when a key also has a dynamic one - Count headerTitle as visible copy; select the mobile check when the runtime catalog generator changes * feat(mobile): repaint themed components on language changes Components that read the theme already subscribe for repaint; a language switch repaints them too, so labels built by helpers during render follow it. RPC recordings load the real i18next and JSON catalogs. * fix(mobile): scope the translate() gate to render bodies and memos; prune stale keys on sync Handlers and effects may call translate() when they run. sync:mobile-localization now removes keys no code references from every catalog. * fix(mobile): use node: imports in the locale config plugin; pass uiLocale in bridge hook test * chore(mobile): refresh the localization coverage baseline * fix(mobile): follow render callbacks in the translate() gate; keep used plural siblings on prune Also let anonymous default-exported screens count as components, flag any plural family a locale started, and mark JSON modules as ES modules in the recording loader. * fix(mobile): treat async and named local callbacks as handlers in the translate() gate * chore(mobile): refresh the localization coverage baseline * chore(mobile): refresh the localization coverage baseline --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
250 lines
10 KiB
JavaScript
250 lines
10 KiB
JavaScript
import { mkdtemp, rm } from 'node:fs/promises'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import * as esbuild from 'esbuild'
|
||
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
|
||
import { isDirectInvocation } from './script-entry-detection.mjs'
|
||
import { assertNoCarriageReturnsInSource } from './mobile-web-source-line-endings.mjs'
|
||
import {
|
||
MOBILE_WEB_BUNDLE_DIR as defaultBundleDir,
|
||
assertMobileWebBundleBuilt
|
||
} from './verify-packaged-mobile-web-bundle.cjs'
|
||
|
||
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
||
const manifestContract = join(
|
||
projectDir,
|
||
'src',
|
||
'shared',
|
||
'mobile-web-bundle',
|
||
'manifest-contract.ts'
|
||
)
|
||
|
||
// Derive asset ceiling as chunks+images+document so chunk overflow reports splitting cause before generic count; flat ceiling drifts as routes grow.
|
||
export function mobileWebAppBundleMaxAssets(routeCount, imageCount, catalogChunkCount = 0) {
|
||
return mobileWebAppBundleMaxChunks(routeCount) + catalogChunkCount + imageCount + 1
|
||
}
|
||
|
||
// UI-language catalogs have their own allowance, like the font: one chunk per non-English locale,
|
||
// fetched only for the active language, never before the first route. Code keeps the 9 MiB fence.
|
||
export const MOBILE_WEB_APP_BUNDLE_MAX_CATALOG_CHUNKS = 10
|
||
export const MOBILE_WEB_APP_BUNDLE_MAX_CATALOG_BYTES = 3 * 1024 * 1024
|
||
|
||
// Keep total app byte budget below phone’s per-asset contract ceiling to fail at build; splitting alone cannot shrink code size.
|
||
export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
|
||
|
||
// Only this licensed desktop-identical asset has its own allowance; code retains the 9 MiB fence.
|
||
export const MOBILE_WEB_APP_TERMINAL_FONT_MAX_BYTES = 1200 * 1024
|
||
const TERMINAL_FONT_SHA256 = '8efa6ba89f0a1f3eefde028f36aa64a13e36282e15ea0ca6929c664501037467'
|
||
const TERMINAL_FONT_BYTES = 1_177_916
|
||
|
||
export function mobileWebAppBundleBudgetBytes(manifest) {
|
||
const fonts = manifest.assets.filter(
|
||
(asset) => asset.path.endsWith('.woff2') || asset.contentType.startsWith('font/')
|
||
)
|
||
if (fonts.length > 1) {
|
||
fail('only one licensed terminal font may use the independent font allowance')
|
||
}
|
||
const font = fonts[0]
|
||
if (!font) {
|
||
return manifest.totalBytes
|
||
}
|
||
if (
|
||
font.path !== `assets/${TERMINAL_FONT_SHA256}.woff2` ||
|
||
font.sha256 !== TERMINAL_FONT_SHA256 ||
|
||
font.contentType !== 'font/woff2' ||
|
||
font.byteLength !== TERMINAL_FONT_BYTES ||
|
||
font.byteLength > MOBILE_WEB_APP_TERMINAL_FONT_MAX_BYTES
|
||
) {
|
||
fail(
|
||
'terminal font allowance requires the approved hash, path, MIME type and bounded byte length'
|
||
)
|
||
}
|
||
return manifest.totalBytes - font.byteLength
|
||
}
|
||
|
||
// Measure sorted route prefixes with routes.slice(0,n): distinct importer sets determine chunks, not route count.
|
||
// Re-measure table when routes change; it is envelope’s only input, and pins must fail rather than permit arbitrary ceiling bumps.
|
||
export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [
|
||
['./_layout.tsx', 3],
|
||
['./h/[hostId]/[...page].tsx', 7],
|
||
['./h/[hostId]/accounts.tsx', 11],
|
||
['./h/[hostId]/agent-history/[worktreeId].tsx', 16],
|
||
['./h/[hostId]/edit.tsx', 21],
|
||
['./h/[hostId]/files/[worktreeId].tsx', 25],
|
||
['./h/[hostId]/files/preview/[worktreeId].tsx', 32],
|
||
['./h/[hostId]/history/[worktreeId].tsx', 35],
|
||
['./h/[hostId]/index.tsx', 42],
|
||
['./h/[hostId]/new-workspace.tsx', 51],
|
||
['./h/[hostId]/pr/[worktreeId].tsx', 52],
|
||
['./h/[hostId]/review/[worktreeId].tsx', 60],
|
||
['./h/[hostId]/session/[worktreeId].tsx', 72],
|
||
['./h/[hostId]/source-control/[worktreeId].tsx', 77],
|
||
['./h/[hostId]/tasks.tsx', 91],
|
||
['./h/_layout.tsx', 95]
|
||
]
|
||
|
||
const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts)
|
||
|
||
// Marginal script cost per added route defines measured envelope’s worst-case slope.
|
||
export const MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD = sweptScripts
|
||
.slice(1)
|
||
.map((scripts, index) => scripts - sweptScripts[index])
|
||
|
||
// Allow four chunks of measured refactor-only drift per head, never cumulative; larger change requires fresh sweep rather than inflated margin.
|
||
export const MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN = 4
|
||
|
||
// Ceiling is swept maximum+margin, flat below sweep length, growing by worst measured route beyond it; test both excess and unused headroom.
|
||
// Route count remains sole term: deferred engine must be one artifact, not admit mermaid’s 103 diagram chunks through a new allowance. Entry closure budget below catches collapsed splitting.
|
||
export function mobileWebAppBundleMaxChunks(routeCount) {
|
||
const beyondTheSweep = Math.max(0, routeCount - MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.length)
|
||
return (
|
||
sweptScripts.at(-1) +
|
||
MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN +
|
||
Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD) * beyondTheSweep
|
||
)
|
||
}
|
||
|
||
// Bound entry/static-import closure before first paint: dropping splitting or statically importing a route can ship slow first-open.
|
||
// Expo-router needs synchronous unstable_settings only on layouts; re-measure budget before making another route static, not as a per-route escape hatch.
|
||
export const MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES = 3 * 1024 * 1024
|
||
|
||
/** Every tree whose bytes reach the buildId, so a CRLF checkout cannot fork it. */
|
||
export const MOBILE_WEB_APP_SOURCE_DIRS = [
|
||
join(projectDir, 'mobile', 'web-entry'),
|
||
join(projectDir, 'mobile', 'app'),
|
||
join(projectDir, 'mobile', 'src')
|
||
]
|
||
|
||
class VerificationError extends Error {}
|
||
|
||
function fail(message) {
|
||
throw new VerificationError(message)
|
||
}
|
||
|
||
// Load phone’s manifest asset ceiling from contract through esbuild, which resolves extensionless TS imports; never copy native shell count.
|
||
export async function readMobileWebBundleMaxAssets() {
|
||
const { outputFiles } = await esbuild.build({
|
||
entryPoints: [manifestContract],
|
||
bundle: true,
|
||
write: false,
|
||
format: 'esm',
|
||
platform: 'node',
|
||
logLevel: 'silent'
|
||
})
|
||
const source = Buffer.from(outputFiles[0].contents).toString('base64')
|
||
const { MOBILE_WEB_BUNDLE_MAX_ASSETS: ceiling } = await import(
|
||
`data:text/javascript;base64,${source}`
|
||
)
|
||
if (typeof ceiling !== 'number') {
|
||
fail(`${manifestContract} exports no MOBILE_WEB_BUNDLE_MAX_ASSETS to bound the build with`)
|
||
}
|
||
return ceiling
|
||
}
|
||
|
||
// Reject derived asset allowance beyond phone’s map limit: shells reject whole oversized manifest, not just extras; re-sweep shared routes to tighten envelope.
|
||
export function assertAssetCeilingFitsShell(
|
||
routeCount,
|
||
imageCount,
|
||
shellMaxAssets,
|
||
catalogChunkCount = 0
|
||
) {
|
||
const ceiling = mobileWebAppBundleMaxAssets(routeCount, imageCount, catalogChunkCount)
|
||
if (ceiling > shellMaxAssets) {
|
||
fail(
|
||
`the ceiling derived for ${String(routeCount)} route(s) and ${String(imageCount)} image(s) ` +
|
||
`is ${String(ceiling)} assets, over the ${String(shellMaxAssets)} the shell will load`
|
||
)
|
||
}
|
||
return ceiling
|
||
}
|
||
|
||
async function buildIntoScratch() {
|
||
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-verify-'))
|
||
try {
|
||
return await buildMobileWebAppBundle({ outDir: join(scratch, 'mobile-web') })
|
||
} finally {
|
||
await rm(scratch, { recursive: true, force: true })
|
||
}
|
||
}
|
||
|
||
// bundleDir is a seam for the tests, which verify a scratch build; the script always verifies out/.
|
||
export async function verifyMobileWebAppBundle({ bundleDir = defaultBundleDir } = {}) {
|
||
for (const directory of MOBILE_WEB_APP_SOURCE_DIRS) {
|
||
await assertNoCarriageReturnsInSource(directory)
|
||
}
|
||
|
||
const manifest = assertMobileWebBundleBuilt(bundleDir)
|
||
|
||
const first = await buildIntoScratch()
|
||
const second = await buildIntoScratch()
|
||
if (first.manifest.buildId !== second.manifest.buildId) {
|
||
fail(`buildId is not reproducible: ${first.manifest.buildId} then ${second.manifest.buildId}`)
|
||
}
|
||
if (first.manifest.buildId !== manifest.buildId) {
|
||
fail(
|
||
`${bundleDir} is stale: it carries buildId ${manifest.buildId}, a fresh build produces ${first.manifest.buildId}`
|
||
)
|
||
}
|
||
if (
|
||
first.catalogChunkCount > MOBILE_WEB_APP_BUNDLE_MAX_CATALOG_CHUNKS ||
|
||
first.catalogBytes > MOBILE_WEB_APP_BUNDLE_MAX_CATALOG_BYTES
|
||
) {
|
||
fail(
|
||
`${String(first.catalogChunkCount)} locale catalog chunk(s) of ${String(first.catalogBytes)} bytes exceed ` +
|
||
`the catalog allowance of ${String(MOBILE_WEB_APP_BUNDLE_MAX_CATALOG_CHUNKS)} chunks and ` +
|
||
`${String(MOBILE_WEB_APP_BUNDLE_MAX_CATALOG_BYTES)} bytes`
|
||
)
|
||
}
|
||
const budgetBytes = mobileWebAppBundleBudgetBytes(manifest) - first.catalogBytes
|
||
if (budgetBytes > MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) {
|
||
fail(
|
||
`bundle excluding the approved font and locale catalogs is ${String(budgetBytes)} bytes, over the Phase C budget of ` +
|
||
`${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)}`
|
||
)
|
||
}
|
||
// Read chunk/entry bounds and image count from fresh verified build: they are not manifest fields, while buildId binds on-disk bytes.
|
||
const maxAssets = assertAssetCeilingFitsShell(
|
||
first.routeKeys.length,
|
||
first.imageCount,
|
||
await readMobileWebBundleMaxAssets(),
|
||
first.catalogChunkCount
|
||
)
|
||
if (manifest.assets.length > maxAssets) {
|
||
fail(
|
||
`bundle has ${String(manifest.assets.length)} assets, over the Phase C budget of ` +
|
||
`${String(maxAssets)} for ${String(first.routeKeys.length)} route(s) and ` +
|
||
`${String(first.imageCount)} image(s)`
|
||
)
|
||
}
|
||
const maxChunks = mobileWebAppBundleMaxChunks(first.routeKeys.length)
|
||
if (first.chunkCount > maxChunks) {
|
||
fail(
|
||
`bundle is cut into ${String(first.chunkCount)} chunks, over the Phase C budget of ` +
|
||
`${String(maxChunks)} for ${String(first.routeKeys.length)} route(s)`
|
||
)
|
||
}
|
||
if (first.entryStaticBytes > MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES) {
|
||
fail(
|
||
`${String(first.entryStaticBytes)} bytes load before the first route, over the Phase C ` +
|
||
`budget of ${String(MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES)}`
|
||
)
|
||
}
|
||
return manifest
|
||
}
|
||
|
||
if (isDirectInvocation(import.meta.url, process.argv[1])) {
|
||
try {
|
||
const manifest = await verifyMobileWebAppBundle()
|
||
console.log(
|
||
`[verify-mobile-web-app-bundle] OK — ${String(manifest.assets.length)} asset(s), ` +
|
||
`${String(manifest.totalBytes)} total bytes, ` +
|
||
`${String(mobileWebAppBundleBudgetBytes(manifest))}/${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)} bytes excluding the approved font, ` +
|
||
`reproducible buildId ${manifest.buildId}`
|
||
)
|
||
} catch (error) {
|
||
console.error(`[verify-mobile-web-app-bundle] ${error.message}`)
|
||
process.exit(1)
|
||
}
|
||
}
|