Ver código fonte

本地打包配置更新

leo 1 semana atrás
pai
commit
8683a17efe
5 arquivos alterados com 600 adições e 2 exclusões
  1. 4 0
      .android-build-client.env
  2. 2 2
      .hbuilderx/launch.json
  3. 591 0
      android-build-client.mjs
  4. 3 0
      manifest.json
  5. BIN
      yfzhikong.keystore

+ 4 - 0
.android-build-client.env

@@ -0,0 +1,4 @@
+BUILD_DCLOUD_APPKEY=2b7d87cf456879044d754072c9c05d4e
+BUILD_APP_PACKAGE=uni.UNIDBA6730
+BUILD_KEYSTORE_PASSWORD=12345678
+BUILD_KEY_ALIAS=testalias

+ 2 - 2
.hbuilderx/launch.json

@@ -19,8 +19,8 @@
             "type" : "uniCloud"
         },
         {
-            "customPlaygroundType" : "local",
-            "playground" : "custom",
+            "customPlaygroundType" : "device",
+            "playground" : "standard",
             "type" : "uni-app:app-android"
         }
     ]

+ 591 - 0
android-build-client.mjs

@@ -0,0 +1,591 @@
+#!/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);
+}
+
+async function collectNativeResources(projectRoot) {
+  const manifestPath = join(projectRoot, "manifest.json");
+  if (!(await fileExists(manifestPath))) {
+    return [];
+  }
+  const manifest = await parseJsonFile(manifestPath);
+  const appPlus = manifest["app-plus"] || {};
+  const distribute = appPlus.distribute || {};
+  const androidIcons = (distribute.icons || {}).android || {};
+  const androidSplash = (distribute.splashscreen || {}).android || {};
+  const densityMap = {
+    hdpi: "drawable-hdpi",
+    xhdpi: "drawable-xhdpi",
+    xxhdpi: "drawable-xxhdpi",
+    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;
+    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;
+});

+ 3 - 0
manifest.json

@@ -124,6 +124,9 @@
                         "spotlight@3x" : "unpackage/res/icons/120x120.png"
                     }
                 }
+            },
+            "splashscreen" : {
+                "androidStyle" : "default"
             }
         },
         "uniStatistics" : {

BIN
yfzhikong.keystore