Selaa lähdekoodia

fix: 更新打包配置

leo 1 päivä sitten
vanhempi
commit
3c690caedd
2 muutettua tiedostoa jossa 57 lisäystä ja 630 poistoa
  1. 0 605
      android-build-client-6.mjs
  2. 57 25
      android-build-client.mjs

+ 0 - 605
android-build-client-6.mjs

@@ -1,605 +0,0 @@
-#!/usr/bin/env node
-
-import { cp, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
-import { basename, dirname, extname, join, resolve } from "node:path";
-import { tmpdir } from "node:os";
-import { spawn } from "node:child_process";
-
-const BUILD_SERVICE_BASE_URL = "https://hbuilder.a.yunfeiyun.com";
-const DEFAULT_UPLOAD_BASE_URL = "https://s3.hnyfwlw.com/test-ivan";
-const DEFAULT_UPLOAD_PREFIX = "hbuilder-cloud/android-build-input";
-const ARTIFACT_SCENARIO = "release";
-const DEFAULT_POLL_INTERVAL_SECONDS = 3;
-const CLIENT_ENV_FILENAME = ".android-build-client.env";
-const SUPPORTED_KEYSTORE_SUFFIXES = new Set([".keystore", ".jks"]);
-
-function printUsage() {
-  console.log(`用法:
-  1. 在项目根目录创建 ${CLIENT_ENV_FILENAME}
-  2. 执行 node android-build-client.mjs
-
-默认行为:
-  1. 自动检测 unpackage/resources 下最新导出的 App 资源
-  2. 自动读取项目根目录 manifest.json 中的版本号
-  3. 从 ${CLIENT_ENV_FILENAME} 读取 DCloud AppKey、包名和签名参数
-  4. 自动将项目根目录下唯一的 .keystore/.jks 签名文件一起打进标准包
-  5. 打出标准构建包并匿名上传到 ${DEFAULT_UPLOAD_BASE_URL}
-  6. 请求远程构建服务 ${BUILD_SERVICE_BASE_URL}
-  7. 默认持续轮询直到完成
-`);
-}
-
-async function loadClientEnv(projectRoot) {
-  const envPath = join(projectRoot, CLIENT_ENV_FILENAME);
-  if (!(await fileExists(envPath))) {
-    return {};
-  }
-  const content = await readFile(envPath, "utf8");
-  const env = {};
-  for (const rawLine of content.split(/\r?\n/)) {
-    const line = rawLine.trim();
-    if (!line || line.startsWith("#")) {
-      continue;
-    }
-    const index = line.indexOf("=");
-    if (index <= 0) {
-      continue;
-    }
-    const key = line.slice(0, index).trim();
-    let value = line.slice(index + 1).trim();
-    if (
-      (value.startsWith("\"") && value.endsWith("\"")) ||
-      (value.startsWith("'") && value.endsWith("'"))
-    ) {
-      value = value.slice(1, -1);
-    }
-    env[key] = value;
-  }
-  return env;
-}
-
-function stripJsonComments(text) {
-  let result = "";
-  let inString = false;
-  let stringQuote = "";
-  let escaped = false;
-  let inLineComment = false;
-  let inBlockComment = false;
-
-  for (let index = 0; index < text.length; index += 1) {
-    const char = text[index];
-    const next = text[index + 1];
-
-    if (inLineComment) {
-      if (char === "\n") {
-        inLineComment = false;
-        result += char;
-      }
-      continue;
-    }
-
-    if (inBlockComment) {
-      if (char === "*" && next === "/") {
-        inBlockComment = false;
-        index += 1;
-      }
-      continue;
-    }
-
-    if (inString) {
-      result += char;
-      if (escaped) {
-        escaped = false;
-      } else if (char === "\\") {
-        escaped = true;
-      } else if (char === stringQuote) {
-        inString = false;
-        stringQuote = "";
-      }
-      continue;
-    }
-
-    if ((char === "\"" || char === "'") && !inString) {
-      inString = true;
-      stringQuote = char;
-      result += char;
-      continue;
-    }
-
-    if (char === "/" && next === "/") {
-      inLineComment = true;
-      index += 1;
-      continue;
-    }
-
-    if (char === "/" && next === "*") {
-      inBlockComment = true;
-      index += 1;
-      continue;
-    }
-
-    result += char;
-  }
-
-  return result;
-}
-
-function normalizeJsonText(text) {
-  return stripJsonComments(text.replace(/^\uFEFF/, ""));
-}
-
-async function parseJsonFile(path) {
-  return JSON.parse(normalizeJsonText(await readFile(path, "utf8")));
-}
-
-async function fileExists(path) {
-  try {
-    await stat(path);
-    return true;
-  } catch {
-    return false;
-  }
-}
-
-async function findLatestResourceDir(projectRoot) {
-  const resourcesRoot = join(projectRoot, "unpackage", "resources");
-  const candidates = await readdir(resourcesRoot, { withFileTypes: true });
-  let latest = null;
-  for (const entry of candidates) {
-    if (!entry.isDirectory()) {
-      continue;
-    }
-    const dirPath = join(resourcesRoot, entry.name);
-    const manifestPath = join(dirPath, "www", "manifest.json");
-    if (!(await fileExists(manifestPath))) {
-      continue;
-    }
-    const manifestStat = await stat(manifestPath);
-    if (!latest || manifestStat.mtimeMs > latest.mtimeMs) {
-      latest = { path: dirPath, mtimeMs: manifestStat.mtimeMs };
-    }
-  }
-  if (!latest) {
-    throw new Error("未找到导出资源: unpackage/resources/*/www/manifest.json");
-  }
-  return latest.path;
-}
-
-async function loadProjectManifest(projectRoot) {
-  const manifestPath = join(projectRoot, "manifest.json");
-  if (!(await fileExists(manifestPath))) {
-    throw new Error("缺少项目根目录 manifest.json");
-  }
-  const data = await parseJsonFile(manifestPath);
-  return {
-    appName: data.name,
-    uniAppId: data.appid,
-    versionName: data.versionName,
-    versionCode: Number(data.versionCode),
-  };
-}
-
-async function loadResourceMetadata(resourceDir) {
-  const manifestPath = join(resourceDir, "www", "manifest.json");
-  const data = await parseJsonFile(manifestPath);
-  return {
-    resourceDir,
-    uniAppId: data.id,
-  };
-}
-
-async function deriveAppPackage(projectRoot) {
-  const environmentPath = join(projectRoot, "config", "environment.js");
-  if (await fileExists(environmentPath)) {
-    const content = await readFile(environmentPath, "utf8");
-    const match = content.match(/https:\/\/([a-z0-9-]+)\.agmp\.yunfeiyun\.com/i);
-    if (match?.[1]) {
-      return `com.agmp.${match[1].toLowerCase()}`;
-    }
-  }
-  throw new Error("无法自动推导 Android 包名,请提供环境变量 BUILD_APP_PACKAGE");
-}
-
-async function resolveAppPackage(projectRoot, clientEnv) {
-  const appPackage = (process.env.BUILD_APP_PACKAGE || clientEnv.BUILD_APP_PACKAGE || "").trim();
-  if (appPackage) {
-    return appPackage;
-  }
-  return deriveAppPackage(projectRoot);
-}
-
-// 变体图标约定:项目根目录 appIcon/zk.png(中性版)/ appIcon/yfzk.png(云飞版),
-// 1024 原图直接打包不 resize(缺档由 Android 自动缩放),服务端按 isneutral 选中覆盖
-const VARIANT_ICON_SOURCES = [
-  { source: join("appIcon", "zk.png"), target: join("neutral", "icon-src.png"), label: "中性版" },
-  { source: join("appIcon", "yfzk.png"), target: join("yunfei", "icon-src.png"), label: "云飞版" },
-];
-
-async function collectNativeResources(projectRoot) {
-  // 变体图标必须存在:找不到直接报错,提示放这两张 1024 原图
-  const mappings = [];
-  const missingVariants = [];
-  for (const item of VARIANT_ICON_SOURCES) {
-    const absPath = resolve(projectRoot, item.source);
-    if (await fileExists(absPath)) {
-      mappings.push({ source: absPath, target: item.target });
-    } else {
-      missingVariants.push(item.source);
-    }
-  }
-  if (missingVariants.length > 0) {
-    throw new Error(
-      `缺少变体图标: ${missingVariants.join(", ")}。请将 1024x1024 原图放到项目根目录 appIcon/zk.png(中性版)和 appIcon/yfzk.png(云飞版),无需手动切尺寸`,
-    );
-  }
-
-  const manifestPath = join(projectRoot, "manifest.json");
-  if (!(await fileExists(manifestPath))) {
-    return mappings;
-  }
-  const manifest = await parseJsonFile(manifestPath);
-  const appPlus = manifest["app-plus"] || {};
-  const distribute = appPlus.distribute || {};
-  // manifest 声明的 Android 图标不再作为 icon 来源(双变体图标由服务端按 isneutral
-  // 选中 appIcon 下的变体图覆盖四个 drawable),因此这里跳过 icons,只处理启动图 splash
-  const androidSplash = (distribute.splashscreen || {}).android || {};
-  const densityMap = {
-    hdpi: "drawable-hdpi",
-    xhdpi: "drawable-xhdpi",
-    xxhdpi: "drawable-xxhdpi",
-    xxxhdpi: "drawable-xxxhdpi",
-  };
-
-  const missing = [];
-  for (const [density, relPath] of Object.entries(androidSplash)) {
-    const targetDir = densityMap[density];
-    if (!targetDir) continue;
-    const absPath = resolve(projectRoot, relPath);
-    if (await fileExists(absPath)) {
-      mappings.push({ source: absPath, target: join(targetDir, "splash.png") });
-    } else {
-      missing.push(relPath);
-    }
-  }
-  if (missing.length > 0) {
-    throw new Error(`manifest.json 中声明的 Android 启动图不存在: ${missing.join(", ")}`);
-  }
-  return mappings;
-}
-
-async function findProjectKeystore(projectRoot) {
-  const entries = await readdir(projectRoot, { withFileTypes: true });
-  const keystoreFiles = entries
-    .filter((entry) => entry.isFile())
-    .map((entry) => join(projectRoot, entry.name))
-    .filter((path) => SUPPORTED_KEYSTORE_SUFFIXES.has(extname(path).toLowerCase()));
-  if (keystoreFiles.length === 0) {
-    throw new Error("项目根目录缺少签名文件,请放置一个 .keystore 或 .jks 文件");
-  }
-  if (keystoreFiles.length > 1) {
-    throw new Error("项目根目录存在多个签名文件,请只保留一个 .keystore 或 .jks 文件");
-  }
-  return keystoreFiles[0];
-}
-
-function runCommand(command, args) {
-  return new Promise((resolvePromise, rejectPromise) => {
-    const child = spawn(command, args, {
-      stdio: ["ignore", "pipe", "pipe"],
-    });
-    let stdout = "";
-    let stderr = "";
-    child.stdout.on("data", (chunk) => {
-      stdout += String(chunk);
-    });
-    child.stderr.on("data", (chunk) => {
-      stderr += String(chunk);
-    });
-    child.on("error", (error) => {
-      rejectPromise(error);
-    });
-    child.on("close", (code) => {
-      if (code === 0) {
-        resolvePromise({ stdout, stderr });
-        return;
-      }
-      rejectPromise(new Error(stderr.trim() || stdout.trim() || `${command} 退出码: ${code}`));
-    });
-  });
-}
-
-function parseAliasNames(text) {
-  const aliases = new Set();
-  for (const line of text.split(/\r?\n/)) {
-    const trimmed = line.trim();
-    const aliasMatch = trimmed.match(/^Alias name:\s*(.+)$/i);
-    if (aliasMatch?.[1]) {
-      aliases.add(aliasMatch[1].trim());
-      continue;
-    }
-    const chineseMatch = trimmed.match(/^别名(?:名称)?[::]\s*(.+)$/);
-    if (chineseMatch?.[1]) {
-      aliases.add(chineseMatch[1].trim());
-    }
-  }
-  return [...aliases];
-}
-
-async function detectKeyAlias(keystorePath, keystorePassword) {
-  let output;
-  try {
-    output = await runCommand("keytool", [
-      "-J-Duser.language=en",
-      "-J-Duser.country=US",
-      "-list",
-      "-v",
-      "-keystore",
-      keystorePath,
-      "-storepass",
-      keystorePassword,
-    ]);
-  } catch (error) {
-    throw new Error(`无法自动解析证书 alias,请安装 keytool 或在 ${CLIENT_ENV_FILENAME} 中填写 BUILD_KEY_ALIAS。原始错误: ${error.message}`);
-  }
-  const aliases = parseAliasNames(`${output.stdout}\n${output.stderr}`);
-  if (aliases.length === 0) {
-    throw new Error(`未能从证书中解析到 alias,请在 ${CLIENT_ENV_FILENAME} 中填写 BUILD_KEY_ALIAS`);
-  }
-  if (aliases.length > 1) {
-    throw new Error(`证书中存在多个 alias: ${aliases.join(", ")},请在 ${CLIENT_ENV_FILENAME} 中显式指定 BUILD_KEY_ALIAS`);
-  }
-  return aliases[0];
-}
-
-async function createBuildInputArchive(config, resourceMetadata) {
-  const stageRoot = await mkdtemp(join(tmpdir(), "android-build-client-"));
-  const distRoot = config.outputDir;
-  const safeVersion = String(resourceMetadata.versionName).replaceAll("/", "_");
-  const timestamp = new Date().toISOString().replaceAll(":", "").replaceAll("-", "").replace(/\..+/, "");
-  const archivePath = join(
-    distRoot,
-    `android-build-input_${resourceMetadata.uniAppId}_v${safeVersion}_${timestamp}.tar.gz`,
-  );
-
-  try {
-    await mkdir(join(stageRoot, "www"), { recursive: true });
-    await mkdir(join(stageRoot, "native-res"), { recursive: true });
-    await mkdir(join(stageRoot, "meta"), { recursive: true });
-    await mkdir(join(stageRoot, "signing"), { recursive: true });
-    await mkdir(distRoot, { recursive: true });
-
-    await cp(join(resourceMetadata.resourceDir, "www"), join(stageRoot, "www"), { recursive: true });
-
-    const nativeResources = await collectNativeResources(config.projectRoot);
-    for (const mapping of nativeResources) {
-      const destination = join(stageRoot, "native-res", mapping.target);
-      await mkdir(dirname(destination), { recursive: true });
-      await cp(mapping.source, destination);
-    }
-
-    await writeFile(
-      join(stageRoot, "meta", "build.json"),
-      JSON.stringify(
-        {
-          uni_appid: resourceMetadata.uniAppId,
-          app_name: resourceMetadata.appName,
-          version_name: resourceMetadata.versionName,
-          version_code: resourceMetadata.versionCode,
-        },
-        null,
-        2,
-      ) + "\n",
-      "utf8",
-    );
-
-    await cp(config.keystorePath, join(stageRoot, "signing", basename(config.keystorePath)));
-
-    await runTar(stageRoot, archivePath);
-    return { archivePath, cleanup: () => rm(stageRoot, { recursive: true, force: true }) };
-  } catch (error) {
-    await rm(stageRoot, { recursive: true, force: true });
-    throw error;
-  }
-}
-
-function runTar(stageRoot, archivePath) {
-  return new Promise((resolvePromise, rejectPromise) => {
-    const child = spawn("tar", ["-C", stageRoot, "-czf", archivePath, "."], {
-      env: {
-        ...process.env,
-        COPYFILE_DISABLE: "1",
-      },
-      stdio: "inherit",
-    });
-    child.on("error", (error) => {
-      rejectPromise(new Error(`执行 tar 失败: ${error.message}`));
-    });
-    child.on("close", (code) => {
-      if (code === 0) {
-        resolvePromise();
-        return;
-      }
-      rejectPromise(new Error(`tar 打包失败,退出码: ${code}`));
-    });
-  });
-}
-
-function encodeS3Path(key) {
-  return key
-    .split("/")
-    .map((segment) => encodeURIComponent(segment).replace(/[!*'()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
-    .join("/");
-}
-
-async function uploadArchive(config, archivePath, resourceMetadata) {
-  const body = await readFile(archivePath);
-  const version = resourceMetadata.versionName;
-  const datePath = new Date().toISOString().slice(0, 10).replaceAll("-", "");
-  const timestamp = new Date().toISOString().replaceAll(":", "").replaceAll("-", "").replace(/\..+/, "");
-  const objectKey = [
-    config.uploadPrefix,
-    ARTIFACT_SCENARIO,
-    config.appPackage,
-    version,
-    datePath,
-    `${basename(archivePath, ".tar.gz")}_${timestamp}.tar.gz`,
-  ].join("/");
-  const uploadUrl = `${config.uploadBaseUrl}/${encodeS3Path(objectKey)}`;
-
-  const response = await fetch(uploadUrl, {
-    method: "PUT",
-    headers: {
-      "Content-Type": "application/gzip",
-    },
-    body,
-  });
-
-  if (!response.ok) {
-    throw new Error(`上传构建包失败: ${response.status} ${await response.text()}`);
-  }
-
-  return {
-    resourceUrl: `${config.uploadBaseUrl}/${objectKey}`,
-    objectKey,
-  };
-}
-
-async function requestRemoteBuild(config, resourceMetadata, resourceUrl) {
-  const payload = {
-    resource_url: resourceUrl,
-    app_package: config.appPackage,
-    dcloud_appkey: config.dcloudAppKey,
-    keystore_password: config.keystorePassword,
-    key_alias: config.keyAlias,
-    key_password: config.keyPassword,
-  };
-  payload.version_name = resourceMetadata.versionName;
-  payload.version_code = resourceMetadata.versionCode;
-
-  let response;
-  try {
-    response = await fetch(`${config.serviceBaseUrl}/v1/android/builds`, {
-      method: "POST",
-      headers: { "Content-Type": "application/json" },
-      body: JSON.stringify(payload),
-      signal: AbortSignal.timeout(15000),
-    });
-  } catch (error) {
-    throw new Error(`请求构建服务失败: ${config.serviceBaseUrl} 不可达。原始错误: ${error.message}`);
-  }
-  if (!response.ok) {
-    throw new Error(`请求构建服务失败: ${response.status} ${await response.text()}`);
-  }
-  const data = await response.json();
-  return {
-    ...data,
-    requestPayload: payload,
-    metadata: resourceMetadata,
-  };
-}
-
-async function waitForTask(config, taskId) {
-  while (true) {
-    let response;
-    try {
-      response = await fetch(`${config.serviceBaseUrl}/v1/android/builds/${taskId}`, {
-        signal: AbortSignal.timeout(15000),
-      });
-    } catch (error) {
-      throw new Error(
-        `查询任务状态失败: ${config.serviceBaseUrl} 不可达。原始错误: ${error.message}`,
-      );
-    }
-    if (!response.ok) {
-      throw new Error(`查询任务状态失败: ${response.status} ${await response.text()}`);
-    }
-    const task = await response.json();
-    console.log(`[build] 任务状态: ${task.status}`);
-    if (task.status === "succeeded") {
-      return task;
-    }
-    if (task.status === "failed") {
-      throw new Error(task.error_message || "远程构建失败");
-    }
-    await new Promise((resolvePromise) =>
-      setTimeout(resolvePromise, config.pollIntervalSeconds * 1000),
-    );
-  }
-}
-
-async function main() {
-  if (process.argv.includes("-h") || process.argv.includes("--help")) {
-    printUsage();
-    return;
-  }
-  const projectRoot = process.cwd();
-  const clientEnv = await loadClientEnv(projectRoot);
-  const dcloudAppKey = (process.env.BUILD_DCLOUD_APPKEY || clientEnv.BUILD_DCLOUD_APPKEY || "").trim();
-  if (!dcloudAppKey) {
-    throw new Error(`缺少 BUILD_DCLOUD_APPKEY,请在 ${CLIENT_ENV_FILENAME} 或环境变量中提供`);
-  }
-  const keystorePassword = (
-    process.env.BUILD_KEYSTORE_PASSWORD || clientEnv.BUILD_KEYSTORE_PASSWORD || ""
-  ).trim();
-  if (!keystorePassword) {
-    throw new Error(`缺少 BUILD_KEYSTORE_PASSWORD,请在 ${CLIENT_ENV_FILENAME} 或环境变量中提供`);
-  }
-  const keyPassword = (
-    process.env.BUILD_KEY_PASSWORD || clientEnv.BUILD_KEY_PASSWORD || keystorePassword
-  ).trim();
-
-  const projectManifest = await loadProjectManifest(projectRoot);
-  const appPackage = await resolveAppPackage(projectRoot, clientEnv);
-  const keystorePath = await findProjectKeystore(projectRoot);
-  const configuredAlias = (process.env.BUILD_KEY_ALIAS || clientEnv.BUILD_KEY_ALIAS || "").trim();
-  const keyAlias = configuredAlias || (await detectKeyAlias(keystorePath, keystorePassword));
-  const config = {
-    projectRoot,
-    serviceBaseUrl: BUILD_SERVICE_BASE_URL,
-    uploadBaseUrl: DEFAULT_UPLOAD_BASE_URL,
-    uploadPrefix: DEFAULT_UPLOAD_PREFIX,
-    appPackage,
-    dcloudAppKey,
-    keystorePath,
-    keystorePassword,
-    keyAlias,
-    keyPassword,
-    outputDir: resolve(projectRoot, "dist"),
-    pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS,
-  };
-  const resourceDir = await findLatestResourceDir(projectRoot);
-  const exportMetadata = await loadResourceMetadata(resourceDir);
-  const resourceMetadata = {
-    resourceDir,
-    uniAppId: projectManifest.uniAppId || exportMetadata.uniAppId,
-    appName: projectManifest.appName,
-    versionName: projectManifest.versionName,
-    versionCode: projectManifest.versionCode,
-  };
-
-  console.log(`[build] 构建服务: ${config.serviceBaseUrl}`);
-  console.log(`[build] 资源目录: ${resourceDir}`);
-  console.log(`[build] 资源 AppID: ${resourceMetadata.uniAppId}`);
-  console.log(`[build] 包名: ${config.appPackage}`);
-  console.log(`[build] 版本: ${resourceMetadata.versionName} (${resourceMetadata.versionCode})`);
-  console.log(`[build] 签名文件: ${config.keystorePath}`);
-  console.log("[build] 抓包支持: 已固定允许用户证书与明文 HTTP");
-
-  const { archivePath, cleanup } = await createBuildInputArchive(config, resourceMetadata);
-  try {
-    console.log(`[build] 标准包已生成: ${archivePath}`);
-    const uploadResult = await uploadArchive(config, archivePath, resourceMetadata);
-    console.log(`[build] 标准包已上传: ${uploadResult.resourceUrl}`);
-    const buildRequest = await requestRemoteBuild(config, resourceMetadata, uploadResult.resourceUrl);
-    console.log(`[build] 已提交构建任务: ${buildRequest.task_id}`);
-    const finalTask = await waitForTask(config, buildRequest.task_id);
-    console.log(`[build] 构建完成: ${finalTask.artifact_url || "-"}`);
-  } finally {
-    await cleanup();
-  }
-}
-
-main().catch((error) => {
-  console.error(`[build] ${error.message}`);
-  process.exitCode = 1;
-});

+ 57 - 25
android-build-client.mjs

@@ -20,7 +20,7 @@ function printUsage() {
 
 默认行为:
   1. 自动检测 unpackage/resources 下最新导出的 App 资源
-  2. 自动读取项目根目录 manifest.json 中的版本号
+  2. 自动读取项目根目录 manifest.json 中的版本号与 Android 权限声明
   3. 从 ${CLIENT_ENV_FILENAME} 读取 DCloud AppKey、包名和签名参数
   4. 自动将项目根目录下唯一的 .keystore/.jks 签名文件一起打进标准包
   5. 打出标准构建包并匿名上传到 ${DEFAULT_UPLOAD_BASE_URL}
@@ -165,6 +165,17 @@ async function findLatestResourceDir(projectRoot) {
   return latest.path;
 }
 
+function extractAndroidPermissions(manifest) {
+  // Android 权限声明(XML 声明字符串数组),非空时随 meta/build.json 透传给构建服务,
+  // 由服务端合并进原生 AndroidManifest.xml;缺失或格式不符时返回空数组(旧包行为不变)
+  const android = ((((manifest || {})["app-plus"] || {}).distribute || {}).android || {});
+  const permissions = android.permissions;
+  if (!Array.isArray(permissions)) {
+    return [];
+  }
+  return permissions.filter((item) => typeof item === "string" && item.trim().length > 0);
+}
+
 async function loadProjectManifest(projectRoot) {
   const manifestPath = join(projectRoot, "manifest.json");
   if (!(await fileExists(manifestPath))) {
@@ -176,6 +187,7 @@ async function loadProjectManifest(projectRoot) {
     uniAppId: data.appid,
     versionName: data.versionName,
     versionCode: Number(data.versionCode),
+    androidPermissions: extractAndroidPermissions(data),
   };
 }
 
@@ -208,15 +220,40 @@ async function resolveAppPackage(projectRoot, clientEnv) {
   return deriveAppPackage(projectRoot);
 }
 
+// 变体图标约定:项目根目录 appIcon/zk.png(中性版)/ appIcon/yfzk.png(云飞版),
+// 1024 原图直接打包不 resize(缺档由 Android 自动缩放),服务端按 isneutral 选中覆盖
+const VARIANT_ICON_SOURCES = [
+  { source: join("appIcon", "zk.png"), target: join("neutral", "icon-src.png"), label: "中性版" },
+  { source: join("appIcon", "yfzk.png"), target: join("yunfei", "icon-src.png"), label: "云飞版" },
+];
+
 async function collectNativeResources(projectRoot) {
+  // 变体图标必须存在:找不到直接报错,提示放这两张 1024 原图
+  const mappings = [];
+  const missingVariants = [];
+  for (const item of VARIANT_ICON_SOURCES) {
+    const absPath = resolve(projectRoot, item.source);
+    if (await fileExists(absPath)) {
+      mappings.push({ source: absPath, target: item.target });
+    } else {
+      missingVariants.push(item.source);
+    }
+  }
+  if (missingVariants.length > 0) {
+    throw new Error(
+      `缺少变体图标: ${missingVariants.join(", ")}。请将 1024x1024 原图放到项目根目录 appIcon/zk.png(中性版)和 appIcon/yfzk.png(云飞版),无需手动切尺寸`,
+    );
+  }
+
   const manifestPath = join(projectRoot, "manifest.json");
   if (!(await fileExists(manifestPath))) {
-    return [];
+    return mappings;
   }
   const manifest = await parseJsonFile(manifestPath);
   const appPlus = manifest["app-plus"] || {};
   const distribute = appPlus.distribute || {};
-  const androidIcons = (distribute.icons || {}).android || {};
+  // manifest 声明的 Android 图标不再作为 icon 来源(双变体图标由服务端按 isneutral
+  // 选中 appIcon 下的变体图覆盖四个 drawable),因此这里跳过 icons,只处理启动图 splash
   const androidSplash = (distribute.splashscreen || {}).android || {};
   const densityMap = {
     hdpi: "drawable-hdpi",
@@ -225,18 +262,7 @@ async function collectNativeResources(projectRoot) {
     xxxhdpi: "drawable-xxxhdpi",
   };
 
-  const mappings = [];
   const missing = [];
-  for (const [density, relPath] of Object.entries(androidIcons)) {
-    const targetDir = densityMap[density];
-    if (!targetDir) continue;
-    const absPath = resolve(projectRoot, relPath);
-    if (await fileExists(absPath)) {
-      mappings.push({ source: absPath, target: join(targetDir, "icon.png") });
-    } else {
-      missing.push(relPath);
-    }
-  }
   for (const [density, relPath] of Object.entries(androidSplash)) {
     const targetDir = densityMap[density];
     if (!targetDir) continue;
@@ -248,7 +274,7 @@ async function collectNativeResources(projectRoot) {
     }
   }
   if (missing.length > 0) {
-    throw new Error(`manifest.json 中声明的 Android 图标或启动图不存在: ${missing.join(", ")}`);
+    throw new Error(`manifest.json 中声明的 Android 启动图不存在: ${missing.join(", ")}`);
   }
   return mappings;
 }
@@ -363,18 +389,22 @@ async function createBuildInputArchive(config, resourceMetadata) {
       await cp(mapping.source, destination);
     }
 
+    const buildMetadata = {
+      uni_appid: resourceMetadata.uniAppId,
+      app_name: resourceMetadata.appName,
+      version_name: resourceMetadata.versionName,
+      version_code: resourceMetadata.versionCode,
+    };
+    if (
+      Array.isArray(resourceMetadata.androidPermissions) &&
+      resourceMetadata.androidPermissions.length > 0
+    ) {
+      buildMetadata.android_permissions = resourceMetadata.androidPermissions;
+    }
+
     await writeFile(
       join(stageRoot, "meta", "build.json"),
-      JSON.stringify(
-        {
-          uni_appid: resourceMetadata.uniAppId,
-          app_name: resourceMetadata.appName,
-          version_name: resourceMetadata.versionName,
-          version_code: resourceMetadata.versionCode,
-        },
-        null,
-        2,
-      ) + "\n",
+      JSON.stringify(buildMetadata, null, 2) + "\n",
       "utf8",
     );
 
@@ -561,6 +591,7 @@ async function main() {
     appName: projectManifest.appName,
     versionName: projectManifest.versionName,
     versionCode: projectManifest.versionCode,
+    androidPermissions: projectManifest.androidPermissions,
   };
 
   console.log(`[build] 构建服务: ${config.serviceBaseUrl}`);
@@ -568,6 +599,7 @@ async function main() {
   console.log(`[build] 资源 AppID: ${resourceMetadata.uniAppId}`);
   console.log(`[build] 包名: ${config.appPackage}`);
   console.log(`[build] 版本: ${resourceMetadata.versionName} (${resourceMetadata.versionCode})`);
+  console.log(`[build] Android 权限声明: ${resourceMetadata.androidPermissions.length} 条`);
   console.log(`[build] 签名文件: ${config.keystorePath}`);
   console.log("[build] 抓包支持: 已固定允许用户证书与明文 HTTP");