Skip to content
9 changes: 8 additions & 1 deletion src/api/builder/builder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { build, createBuildTemplate as createCoreBuildTemplate, executeBuildStageTask, queryDefaultBuildConfigByPlatform } from '../../core/builder';
import { build, createBuildTemplate as createCoreBuildTemplate, executeBuildStageTask, queryDefaultBuildConfigByPlatform, verifyBuildOptions } from '../../core/builder';
import { HttpStatusCode, COMMON_STATUS, CommonResultType } from '../base/schema-base';
import { BuildExitCode } from '../../core/builder/@types/protected';
import { description, param, result, title, tool } from '../decorator/decorator';
Expand All @@ -17,6 +17,13 @@ export class BuilderApi {
data: null,
};
try {
const checkFail = await verifyBuildOptions(platform, options as any);
if (checkFail) {
ret.code = COMMON_STATUS.FAIL;
ret.data = checkFail as unknown as TBuildResultData;
ret.reason = checkFail.reason;
return ret;
}
const res = await build(platform, options);
ret.data = res as TBuildResultData;
if (res.code !== BuildExitCode.BUILD_SUCCESS) {
Expand Down
3 changes: 3 additions & 0 deletions src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ export class BuildCommand extends BaseCommand {
if (result.code === BuildExitCode.BUILD_SUCCESS) {
console.log(chalk.green('✓ Build completed successfully! Build Dest: ' + result.dest));
} else {
if (result.reason) {
console.error(chalk.red(result.reason));
}
console.error(chalk.red('✗ Build failed!'));
}
process.exit(result.code);
Expand Down
89 changes: 88 additions & 1 deletion src/core/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readJSONSync } from 'fs-extra';
import i18n from '../base/i18n';
import { BuildExitCode, BuildStageProgressCallback, IBuildCommandOption, IBuildResultData, IBuildStageOptions, IBuildTaskOption, IBundleBuildOptions, IPreviewSettingsResult, Platform } from './@types/private';
import { pluginManager } from './manager/plugin';
import { cloneConfigValue, formatMSTime } from './share/utils';
import { cloneConfigValue, defaultsDeep, formatMSTime } from './share/utils';
import { newConsole } from '../base/console';
import { basename, extname, isAbsolute, join } from 'path';
import assetManager from '../assets/manager/asset';
Expand All @@ -13,6 +13,8 @@ import utils from '../base/utils';
import { middlewareService } from '../../server/middleware/core';
import BuildMiddleware from './build.middleware';
import { BuildGlobalInfo } from './share/global';
import { Engine } from '../engine';
import { getDefaultScenes, getDefaultStartScene } from './share/common-options-validator';
export { clearCache } from './cache';
export type { BuildCacheScope, ClearCacheResult } from './cache';

Expand All @@ -29,6 +31,91 @@ export async function init(platform?: string[]) {
}
}

/**
* 使用平台注册的 verifyRules 对构建参数做严格校验。
* 仅供 api 层(CLI/MCP)在调用 build() 前显式调用;Pink 走自己的 UI 校验,不会经过这里。
* skipCheck 为 true 时跳过。
*
* 语义:先把平台 default 合进 options 再校验——用户漏传的字段会用平台默认值兜底通过;
* 只有用户明确传了非法值、或字段本身默认值就不合法(例如 iOS/Mac 的 packageName 默认空但 required)
* 时才会失败。任何 rule 失败(除 level='warn' 显式声明的)都硬阻塞,返回 { code: PARAM_ERROR, reason }。
*/
export async function verifyBuildOptions(
platform: string,
options?: IBuildCommandOption,
): Promise<{ code: Exclude<BuildExitCode, BuildExitCode.BUILD_SUCCESS>; reason: string } | null> {
if (options?.skipCheck) {
return null;
}
try {
// 与 createBuildTask 里 checkOptions 一致:先用平台 default 兜住漏传字段
const defaultOptions = await pluginManager.getOptionsByPlatform(platform);
const merged = defaultsDeep(JSON.parse(JSON.stringify(options || {})), defaultOptions);
merged.platform = platform;
// taskName 是 common option 里 default='' + verifyRules=['required'],
// 老流程靠 createBuildTask 里 `options.taskName = options.taskName || platform` 兜底,
// 而入口校验早于 build(),这里必须复刻同样的归一化,否则 required 规则永远拦。
merged.taskName = merged.taskName || platform;
// scenes / startScene 的合法默认值是从 asset-db 现算的(getDefaultScenes / getDefaultStartScene),
// 不是 commonOptionConfigs 里的静态 '' / []。老流程里 checkOptions 靠 fixedValue 自动回落到这两个函数,
// 新入口校验对 error 硬阻塞(不消费 fixedValue),因此必须在校验前先按同样逻辑把项目默认场景填进来。
try {
if (!merged.startScene) {
const defaultStartScene = getDefaultStartScene();
if (defaultStartScene) {
merged.startScene = defaultStartScene;
}
}
if (!Array.isArray(merged.scenes) || merged.scenes.length === 0) {
const defaultScenes = getDefaultScenes();
if (defaultScenes.length) {
merged.scenes = defaultScenes;
}
}
} catch {
// asset-db 未初始化时忽略(单测/引擎未加载),交给下游的 required-like 规则处理
}
// renderPipeline 是项目设置而非平台选项,构建阶段才由 checkProjectSetting 填进 options。
// 入口校验早于构建,这里按编辑器的做法直接读工程配置补上,否则依赖它的规则(apiLevelRenderPipeline)恒不触发。
if (!merged.renderPipeline) {
try {
const renderPipeline = Engine.getConfig().renderPipeline;
if (renderPipeline) {
merged.renderPipeline = renderPipeline;
}
} catch {
// Engine 未初始化(如单测/未加载引擎)时忽略,不影响其余规则
}
}
const results = await pluginManager.checkBuildOptions(platform, merged as any);
const errors: string[] = [];
const warnings: string[] = [];
for (const key of Object.keys(results)) {
const r = results[key];
if (r.valid) {
continue;
}
const line = ` - ${key}: ${r.message || 'invalid'}`;
if (r.level === 'warn') {
warnings.push(line);
} else {
errors.push(line);
}
}
if (warnings.length) {
console.warn(`Build option warnings:\n${warnings.join('\n')}`);
}
if (errors.length) {
const reason = `Build option errors:\n${errors.join('\n')}`;
console.error(reason);
return { code: BuildExitCode.PARAM_ERROR, reason };
}
} catch (e) {
console.warn('Failed to run build option checks:', e);
}
return null;
}

function getBuilderLogRoot() {
const projectTempDir = builderConfig.projectTempDir;
return basename(projectTempDir) === 'builder' ? projectTempDir : join(projectTempDir, 'builder');
Expand Down
146 changes: 145 additions & 1 deletion src/core/builder/platforms/android/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
import { IPlatformBuildPluginConfig } from '../../../@types/protected';
import { commonOptions, baseNativeCommonOptions } from '../../native-common';

// huawei-agc 直接 spread 本配置来复用整套规则,此时选项落在 packages['huawei-agc'] 而不是 packages.android,
// 所以联动条件必须按 options.platform 取包名(checkBuildOptions 会写入该字段),否则 huawei-agc 上所有 gate 都读到 undefined。
function pkgOptions(options: any): any {
return options?.packages?.[options?.platform] || options?.packages?.android || {};
}

const config: IPlatformBuildPluginConfig = {
...commonOptions,
displayName: 'Android',
Expand All @@ -28,6 +34,134 @@ const config: IPlatformBuildPluginConfig = {
},
message: 'Invalid package name specified',
},
// 当 useDebugKeystore=false 时,keystore 相关字段(keystorePath/Password/Alias/AliasPassword)
// 需校验:keystoreRequired 卡 undefined/null(真的没设),keystoreNotEmpty 卡 ''(设了但空)。
// 迁移自 editor 的 getVerifyMap,两条规则区分语义。
// gate 用 !== false:checkBuildOption 逐字段校验时不会合并平台默认值(createVerifyOptions 只 clone 调用方传入的 options),
// 字段缺失时必须按声明的默认值 true 处理,否则会误报"keystore 不能为空"。
keystoreRequired: {
func: (value: unknown, options: any) => {
if (pkgOptions(options).useDebugKeystore !== false) {
return true;
}
return value !== null && value !== undefined;
},
message: 'Required when useDebugKeystore is false (field must be set for the custom release keystore; set useDebugKeystore to true to use the built-in debug keystore)',
},
keystoreNotEmpty: {
func: (value: unknown, options: any) => {
if (pkgOptions(options).useDebugKeystore !== false) {
return true;
}
return value !== '';
},
message: 'Cannot be empty when useDebugKeystore is false (a value is needed for the custom release keystore; set useDebugKeystore to true to use the built-in debug keystore)',
},
// apiLevel 的分支校验(editor 侧 checkAndroidAPILevels 逻辑拆解,每个分支独立 rule 保留精确 message)
// checkAndroidAPILevels 现在只剩 huawei-agc/hooks.ts 在调,用作 silent auto-fix;android 自己已完全走这套 rule
apiLevelIsNumber: {
func: (value: unknown) => typeof value === 'number' ? !isNaN(value) : !isNaN(Number(value)),
message: 'API Level must be a number',
},
apiLevelInstant: {
func: (value: unknown, options: any) => {
if (!pkgOptions(options).androidInstant) {
return true;
}
return Number(value) >= 23;
},
message: 'When Android Instant App is enabled, the minimum API Level required is 23.',
},
apiLevelTbb: {
func: (value: unknown, options: any) => {
// CLI 的 JobSystem 由 baseNativeCommonOptions 声明在各平台自己的 options 里,不存在 packages.native(那是编辑器的形态)
if (pkgOptions(options).JobSystem !== 'tbb') {
return true;
}
return Number(value) >= 21;
},
message: 'When TBB is enabled, the minimum API Level required is 21.',
},
apiLevelRenderPipeline: {
func: (value: unknown, options: any) => {
// 与 editor 一致的延迟渲染管线 uuid
if (options?.renderPipeline !== '5d45ba66-829a-46d3-948e-2ed3fa7ee421') {
return true;
}
return Number(value) >= 21;
},
message: 'When Deferred Render Pipeline is enabled, the minimum API Level required is 21.',
},
apiLevelMin19: {
func: (value: unknown) => Number(value) >= 19,
message: 'The minimum API Level required is 19.',
},
// 迁移自 editor verificationFunc 剩余 case:appABIs / renderBackEnd / orientation
// 空数组 [] 和空对象 {} 都不会被 validator-manager 的空值 short-circuit 拦截,规则会正常 fire。
appABIs: {
func: (value: unknown) => Array.isArray(value) && value.length > 0,
message: 'appABIs must include at least one ABI',
},
renderBackEnd: {
func: (value: unknown) => {
if (!value || typeof value !== 'object') {
return false;
}
// 只认可 android/google-play 支持的 backend key;至少 1 个开启才算合法。
// 这一层比 editor 严格(editor 侧只查"任一 truthy",会漏掉 {metal:true} 这种平台不匹配的写法)。
const supported = ['vulkan', 'gles3', 'gles2'];
const v = value as Record<string, unknown>;
return supported.some((k) => !!v[k]);
},
message: 'renderBackEnd must have at least one supported backend enabled (vulkan / gles3 / gles2 for android)',
},
orientation: {
func: (value: unknown) => {
if (!value || typeof value !== 'object') {
return false;
}
return Object.values(value as Record<string, unknown>).some((v) => !!v);
},
message: 'orientation must have at least one direction enabled',
},
// maxAspectRatio 只在 resizeableActivity=false 时校验(editor 逻辑一致),Required 结尾使空值也能走进 func
maxAspectRatioRequired: {
func: (value: unknown, options: any) => {
if (pkgOptions(options).resizeableActivity !== false) {
return true;
}
if (typeof value !== 'string' || value.trim() === '') {
return false;
}
const trimmed = value.trim();
const LOWER_BOUND = 1.33; // https://developer.android.com/guide/practices/screens-distribution#MaxAspectRatio
const optMatch = trimmed.match(/^(\d+(?:\.\d+)?)(?:\s*\(\s*\d+\s*:\s*\d+\s*\))?$/);
if (optMatch) {
return Number.parseFloat(optMatch[1]) >= LOWER_BOUND;
}
const fracMatch = trimmed.match(/^(\d+)\s*:\s*(\d+)$/);
if (fracMatch) {
const w = Number.parseInt(fracMatch[1], 10);
const h = Number.parseInt(fracMatch[2], 10);
return w > 0 && h > 0 && w / h >= LOWER_BOUND;
}
return false;
},
message: 'maxAspectRatio must be a number, "w:h", or "n.n (w:h)" with value >= 1.33 (required when resizeableActivity is false)',
},
// remoteUrl 只在 androidInstant=true 且非空时要求 http 前缀(editor 里空值也是允许的)
remoteUrlHttp: {
func: (value: unknown, options: any) => {
if (!pkgOptions(options).androidInstant) {
return true;
}
if (value === '' || value === null || value === undefined) {
return true;
}
return typeof value === 'string' && value.startsWith('http');
},
message: 'remoteUrl should start with http when androidInstant is enabled',
},
},
options: {
...baseNativeCommonOptions,
Expand Down Expand Up @@ -62,6 +196,7 @@ const config: IPlatformBuildPluginConfig = {
gles3: true,
gles2: true,
},
verifyRules: ['renderBackEnd'],
},
packageName: {
label: 'i18n:android.options.package_name',
Expand All @@ -73,14 +208,16 @@ const config: IPlatformBuildPluginConfig = {
label: 'i18n:android.options.apiLevel',
type: 'number',
default: 35,
verifyRules: ['required'],
// 顺序敏感(validator 短路):required → 是数字 → 分支特定下限 → 通用下限 19
verifyRules: ['required', 'apiLevelIsNumber', 'apiLevelInstant', 'apiLevelTbb', 'apiLevelRenderPipeline', 'apiLevelMin19'],
},
appABIs: {
label: 'i18n:android.options.appABIs',
type: 'array',
items: { type: 'string' },
default: ['arm64-v8a'],
hidden: true,
verifyRules: ['appABIs'],
},
resizeableActivity: {
label: 'i18n:android.options.resizeable_activity',
Expand All @@ -91,6 +228,7 @@ const config: IPlatformBuildPluginConfig = {
label: 'i18n:android.options.max_aspect_ratio',
type: 'string',
default: '2.4',
verifyRules: ['maxAspectRatioRequired'],
},
orientation: {
label: 'i18n:android.options.screen_orientation',
Expand All @@ -117,6 +255,7 @@ const config: IPlatformBuildPluginConfig = {
landscapeRight: true,
landscapeLeft: true,
},
verifyRules: ['orientation'],
},
useDebugKeystore: {
label: 'i18n:android.KEYSTORE.use_debug_keystore',
Expand All @@ -127,21 +266,25 @@ const config: IPlatformBuildPluginConfig = {
label: 'i18n:android.KEYSTORE.keystore_path',
type: 'string',
default: '',
verifyRules: ['keystoreRequired', 'keystoreNotEmpty'],
},
keystorePassword: {
label: 'i18n:android.KEYSTORE.keystore_password',
type: 'string',
default: '',
verifyRules: ['keystoreRequired', 'keystoreNotEmpty'],
},
keystoreAlias: {
label: 'i18n:android.KEYSTORE.keystore_alias',
type: 'string',
default: '',
verifyRules: ['keystoreRequired', 'keystoreNotEmpty'],
},
keystoreAliasPassword: {
label: 'i18n:android.KEYSTORE.keystore_alias_password',
type: 'string',
default: '',
verifyRules: ['keystoreRequired', 'keystoreNotEmpty'],
},
appBundle: {
label: 'i18n:android.options.app_bundle',
Expand All @@ -165,6 +308,7 @@ const config: IPlatformBuildPluginConfig = {
type: 'string',
hidden: true,
default: '',
verifyRules: ['remoteUrlHttp'],
},
isSoFileCompressed: {
label: 'i18n:android.options.compress_so_files',
Expand Down
9 changes: 3 additions & 6 deletions src/core/builder/platforms/android/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { join } from 'path';
import { IBuildResult, IAndroidInternalBuildOptions } from './type';
import { BuilderCache, IBuilder } from '../../../@types/protected';
import { generateAndroidOptions, checkAndroidAPILevels } from './utils';
import { generateAndroidOptions } from './utils';
import * as nativeCommonHook from '../../native-common/hooks';
import { GlobalPaths } from '../../../../../global';

Expand All @@ -26,11 +26,8 @@ export async function onAfterInit(this: IBuilder, options: IAndroidInternalBuild

const renderBackEnd = android.renderBackEnd;

const res = await checkAndroidAPILevels(android.apiLevel, options);
if (!res.valid) {
console.error(res.message);
typeof res.fixedValue === 'number' && (android.apiLevel = res.fixedValue);
}
// apiLevel 校验已由 api 层的 verifyBuildOptions 走 verifyRuleMap 处理,非法直接阻断构建;
// 这里不再 silent auto-fix。

if (android.useDebugKeystore) {
android.keystorePath = join(GlobalPaths.staticDir, 'tools/keystore/debug.keystore');
Expand Down
Loading
Loading