feat: sign Android APK and generate icons from logo.png or favicon.ico
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
47
.github/scripts/generate-app-icons.mjs
vendored
Normal file
47
.github/scripts/generate-app-icons.mjs
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(__dirname, "../..");
|
||||||
|
const distDir = path.join(root, "template", "dist");
|
||||||
|
const templateDir = path.join(root, "template");
|
||||||
|
|
||||||
|
const ICON_PRIORITY = ["logo.png", "favicon.ico"];
|
||||||
|
|
||||||
|
function findIconSource(baseDir) {
|
||||||
|
for (const name of ICON_PRIORITY) {
|
||||||
|
const direct = path.join(baseDir, name);
|
||||||
|
if (fs.existsSync(direct)) return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
for (const name of ICON_PRIORITY) {
|
||||||
|
const nested = path.join(baseDir, entry.name, name);
|
||||||
|
if (fs.existsSync(nested)) return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateAppIcons() {
|
||||||
|
if (!fs.existsSync(distDir)) {
|
||||||
|
console.log("No dist directory, keeping default icons");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSource = findIconSource(distDir);
|
||||||
|
if (!iconSource) {
|
||||||
|
console.log("No logo.png or favicon.ico found, keeping default icons");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Generating app icons from: ${path.relative(root, iconSource)}`);
|
||||||
|
execSync(`npx tauri icon "${iconSource}"`, {
|
||||||
|
cwd: templateDir,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
}
|
||||||
61
.github/scripts/patch-android-gradle.mjs
vendored
Normal file
61
.github/scripts/patch-android-gradle.mjs
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(__dirname, "../..");
|
||||||
|
const gradlePath = path.join(
|
||||||
|
root,
|
||||||
|
"template/src-tauri/gen/android/app/build.gradle.kts",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(gradlePath)) {
|
||||||
|
console.log("Android gradle file not found, skipping patch");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs.readFileSync(gradlePath, "utf8");
|
||||||
|
|
||||||
|
if (content.includes("signingConfigs")) {
|
||||||
|
console.log("Android gradle already has signingConfigs");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.includes("import java.util.Properties")) {
|
||||||
|
content = content.replace(
|
||||||
|
/^(import .+\n)+/m,
|
||||||
|
(block) => `${block}import java.util.Properties\nimport java.io.FileInputStream\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingBlock = `
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["password"] as String
|
||||||
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["password"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (content.includes("buildTypes {")) {
|
||||||
|
content = content.replace("buildTypes {", `${signingBlock}\n buildTypes {`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.includes('signingConfig = signingConfigs.getByName("release")')) {
|
||||||
|
content = content.replace(
|
||||||
|
/getByName\("release"\)\s*\{/,
|
||||||
|
`getByName("release") {
|
||||||
|
signingConfig = signingConfigs.getByName("release")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(gradlePath, content);
|
||||||
|
console.log("Patched Android gradle signing configuration");
|
||||||
10
.github/scripts/prepare-template.mjs
vendored
10
.github/scripts/prepare-template.mjs
vendored
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
|
import { generateAppIcons } from "./generate-app-icons.mjs";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const root = path.resolve(__dirname, "../..");
|
const root = path.resolve(__dirname, "../..");
|
||||||
@@ -68,13 +69,6 @@ if (fs.existsSync(androidConfPath)) {
|
|||||||
patchTauriConfig(androidConfPath);
|
patchTauriConfig(androidConfPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconCandidates = ["icon.png", "favicon.png", "logo.png"];
|
generateAppIcons();
|
||||||
for (const candidate of iconCandidates) {
|
|
||||||
const iconPath = path.join(distDir, candidate);
|
|
||||||
if (fs.existsSync(iconPath)) {
|
|
||||||
console.log(`Found icon candidate: ${candidate}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Template prepared successfully");
|
console.log("Template prepared successfully");
|
||||||
|
|||||||
39
.github/scripts/setup-android-signing.sh
vendored
Normal file
39
.github/scripts/setup-android-signing.sh
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
KEYSTORE_PATH="${RUNNER_TEMP}/web2app-release.jks"
|
||||||
|
STORE_PASS="${ANDROID_KEYSTORE_PASSWORD:-web2app-ci}"
|
||||||
|
KEY_PASS="${ANDROID_KEY_PASSWORD:-web2app-ci}"
|
||||||
|
KEY_ALIAS="${ANDROID_KEY_ALIAS:-web2app}"
|
||||||
|
|
||||||
|
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||||
|
echo "Using Android keystore from repository secrets"
|
||||||
|
echo "${ANDROID_KEYSTORE_BASE64}" | base64 -d > "${KEYSTORE_PATH}"
|
||||||
|
else
|
||||||
|
echo "Generating CI Android keystore (sideload demo; set secrets for production)"
|
||||||
|
keytool -genkeypair -v \
|
||||||
|
-keystore "${KEYSTORE_PATH}" \
|
||||||
|
-alias "${KEY_ALIAS}" \
|
||||||
|
-keyalg RSA \
|
||||||
|
-keysize 2048 \
|
||||||
|
-validity 10000 \
|
||||||
|
-storepass "${STORE_PASS}" \
|
||||||
|
-keypass "${KEY_PASS}" \
|
||||||
|
-dname "CN=Web2App, OU=CI, O=Web2App, L=Local, ST=Local, C=CN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export WEB2APP_ANDROID_KEYSTORE="${KEYSTORE_PATH}"
|
||||||
|
export WEB2APP_ANDROID_STORE_PASS="${STORE_PASS}"
|
||||||
|
export WEB2APP_ANDROID_KEY_PASS="${KEY_PASS}"
|
||||||
|
export WEB2APP_ANDROID_KEY_ALIAS="${KEY_ALIAS}"
|
||||||
|
|
||||||
|
ANDROID_DIR="template/src-tauri/gen/android"
|
||||||
|
if [ -d "${ANDROID_DIR}" ]; then
|
||||||
|
cat > "${ANDROID_DIR}/keystore.properties" <<EOF
|
||||||
|
keyAlias=${KEY_ALIAS}
|
||||||
|
password=${KEY_PASS}
|
||||||
|
storeFile=${KEYSTORE_PATH}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Android signing material ready at ${KEYSTORE_PATH}"
|
||||||
46
.github/scripts/sign-android-apk.sh
vendored
Normal file
46
.github/scripts/sign-android-apk.sh
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUILD_TOOLS="${ANDROID_HOME}/build-tools/34.0.0"
|
||||||
|
KEYSTORE="${WEB2APP_ANDROID_KEYSTORE:?Missing keystore}"
|
||||||
|
STORE_PASS="${WEB2APP_ANDROID_STORE_PASS:?Missing store password}"
|
||||||
|
KEY_PASS="${WEB2APP_ANDROID_KEY_PASS:?Missing key password}"
|
||||||
|
KEY_ALIAS="${WEB2APP_ANDROID_KEY_ALIAS:-web2app}"
|
||||||
|
|
||||||
|
UNSIGNED_APK="$(find template/src-tauri -name '*unsigned*.apk' -type f | head -n 1 || true)"
|
||||||
|
APK="${UNSIGNED_APK}"
|
||||||
|
if [ -z "${APK}" ]; then
|
||||||
|
APK="$(find template/src-tauri -name '*.apk' -type f | head -n 1 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${APK}" ]; then
|
||||||
|
echo "No APK found to sign"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p artifacts/android
|
||||||
|
OUTPUT_NAME="$(basename "${APK%.apk}")-signed.apk"
|
||||||
|
|
||||||
|
if "${BUILD_TOOLS}/apksigner" verify "${APK}" >/dev/null 2>&1; then
|
||||||
|
echo "APK already signed: ${APK}"
|
||||||
|
cp "${APK}" "artifacts/android/${OUTPUT_NAME}"
|
||||||
|
ls -la artifacts/android
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Signing APK: ${APK}"
|
||||||
|
ALIGNED="${RUNNER_TEMP}/app-aligned.apk"
|
||||||
|
SIGNED="${RUNNER_TEMP}/app-signed.apk"
|
||||||
|
|
||||||
|
"${BUILD_TOOLS}/zipalign" -f -p 4 "${APK}" "${ALIGNED}"
|
||||||
|
"${BUILD_TOOLS}/apksigner" sign \
|
||||||
|
--ks "${KEYSTORE}" \
|
||||||
|
--ks-key-alias "${KEY_ALIAS}" \
|
||||||
|
--ks-pass "pass:${STORE_PASS}" \
|
||||||
|
--key-pass "pass:${KEY_PASS}" \
|
||||||
|
--out "${SIGNED}" \
|
||||||
|
"${ALIGNED}"
|
||||||
|
|
||||||
|
"${BUILD_TOOLS}/apksigner" verify --verbose "${SIGNED}"
|
||||||
|
cp "${SIGNED}" "artifacts/android/${OUTPUT_NAME}"
|
||||||
|
ls -la artifacts/android
|
||||||
26
.github/workflows/build-app.yml
vendored
26
.github/workflows/build-app.yml
vendored
@@ -117,18 +117,32 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.NDK_VERSION }}
|
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.NDK_VERSION }}
|
||||||
|
|
||||||
|
- name: Setup Android signing
|
||||||
|
run: bash .github/scripts/setup-android-signing.sh
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
|
||||||
|
- name: Patch Android Gradle signing
|
||||||
|
run: node .github/scripts/patch-android-gradle.mjs
|
||||||
|
|
||||||
- name: Build Android APK
|
- name: Build Android APK
|
||||||
working-directory: template
|
working-directory: template
|
||||||
run: npm run tauri android build -- --apk
|
run: npm run tauri android build -- --apk
|
||||||
env:
|
env:
|
||||||
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.NDK_VERSION }}
|
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.NDK_VERSION }}
|
||||||
|
|
||||||
- name: Collect Android artifacts
|
- name: Sign Android APK
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts/android
|
bash .github/scripts/setup-android-signing.sh
|
||||||
find template/src-tauri/gen/android -name "*.apk" -exec cp {} artifacts/android/ \;
|
bash .github/scripts/sign-android-apk.sh
|
||||||
find template/src-tauri -path "*/build/outputs/apk/*" -name "*.apk" -exec cp {} artifacts/android/ \;
|
env:
|
||||||
ls -la artifacts/android
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -176,7 +190,7 @@ jobs:
|
|||||||
Automated build for **${{ inputs.app_name }}** (English: `${{ inputs.app_name_en }}`, ID: `${{ inputs.app_identifier }}`).
|
Automated build for **${{ inputs.app_name }}** (English: `${{ inputs.app_name_en }}`, ID: `${{ inputs.app_identifier }}`).
|
||||||
|
|
||||||
- Windows desktop installer/portable binary
|
- Windows desktop installer/portable binary
|
||||||
- Android APK (demo signing, sideload only)
|
- Android APK (signed for sideload install)
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: true
|
prerelease: true
|
||||||
files: release-assets/final/*
|
files: release-assets/final/*
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -92,9 +92,35 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
|||||||
|
|
||||||
构建产物发布到 Release tag:`build-{job_id}`。
|
构建产物发布到 Release tag:`build-{job_id}`。
|
||||||
|
|
||||||
### Android 说明
|
### 应用图标
|
||||||
|
|
||||||
|
构建前会从上传 zip 的 `template/dist/` 中查找图标(根目录或单层子目录):
|
||||||
|
|
||||||
|
1. `logo.png`(优先)
|
||||||
|
2. `favicon.ico`
|
||||||
|
3. 若都未找到,使用模板内置默认图标
|
||||||
|
|
||||||
|
找到后 CI 会执行 `tauri icon` 生成 Windows / Android 所需的多尺寸图标。
|
||||||
|
|
||||||
|
### Android 签名与安装
|
||||||
|
|
||||||
|
- CI 构建完成后会用 `apksigner` 对 APK **签名**,可直接侧载安装
|
||||||
|
- 未配置 Secrets 时,workflow 会生成临时 CI 证书(密码 `web2app-ci`,仅适合 demo)
|
||||||
|
- 生产环境建议在仓库 Settings → Secrets 配置:
|
||||||
|
|
||||||
|
| Secret | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `ANDROID_KEYSTORE_BASE64` | `.jks` 文件 base64 |
|
||||||
|
| `ANDROID_KEYSTORE_PASSWORD` | keystore 密码 |
|
||||||
|
| `ANDROID_KEY_PASSWORD` | key 密码 |
|
||||||
|
| `ANDROID_KEY_ALIAS` | 别名,默认 `web2app` |
|
||||||
|
|
||||||
|
本地生成 keystore:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/generate-android-keystore.sh web2app-release.jks web2app
|
||||||
|
```
|
||||||
|
|
||||||
- CI 中使用 debug/默认签名,适合 sideload 测试
|
|
||||||
- 首次 Android 构建会在 CI 中执行 `tauri android init`
|
- 首次 Android 构建会在 CI 中执行 `tauri android init`
|
||||||
- Android 构建依赖 NDK / SDK,workflow 已预装常见组件
|
- Android 构建依赖 NDK / SDK,workflow 已预装常见组件
|
||||||
|
|
||||||
@@ -117,7 +143,7 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
|||||||
- 无登录鉴权,仅适合本地/内网 demo
|
- 无登录鉴权,仅适合本地/内网 demo
|
||||||
- GitHub Actions 有排队时间与额度限制
|
- GitHub Actions 有排队时间与额度限制
|
||||||
- 大 zip 会短暂占用仓库空间(finalize 会尝试删除 `site.zip`)
|
- 大 zip 会短暂占用仓库空间(finalize 会尝试删除 `site.zip`)
|
||||||
- Android APK 非 Play Store 发布级别
|
- 未配置自有证书时,各次 CI 构建可能使用不同签名,无法覆盖安装旧版 APK
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
|
|||||||
23
scripts/generate-android-keystore.sh
Normal file
23
scripts/generate-android-keystore.sh
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUT="${1:-web2app-release.jks}"
|
||||||
|
ALIAS="${2:-web2app}"
|
||||||
|
|
||||||
|
echo "Generating keystore: ${OUT}"
|
||||||
|
keytool -genkeypair -v \
|
||||||
|
-keystore "${OUT}" \
|
||||||
|
-alias "${ALIAS}" \
|
||||||
|
-keyalg RSA \
|
||||||
|
-keysize 2048 \
|
||||||
|
-validity 10000 \
|
||||||
|
-storepass "${ANDROID_KEYSTORE_PASSWORD:-changeit}" \
|
||||||
|
-keypass "${ANDROID_KEY_PASSWORD:-changeit}" \
|
||||||
|
-dname "CN=Web2App, OU=Mobile, O=Web2App, L=Local, ST=Local, C=CN"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Add these GitHub repository secrets:"
|
||||||
|
echo " ANDROID_KEYSTORE_BASE64=$(base64 -w 0 "${OUT}" 2>/dev/null || base64 -i "${OUT}")"
|
||||||
|
echo " ANDROID_KEYSTORE_PASSWORD=${ANDROID_KEYSTORE_PASSWORD:-changeit}"
|
||||||
|
echo " ANDROID_KEY_PASSWORD=${ANDROID_KEY_PASSWORD:-changeit}"
|
||||||
|
echo " ANDROID_KEY_ALIAS=${ALIAS}"
|
||||||
@@ -43,7 +43,8 @@ export default function Upload() {
|
|||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>上传静态站点</h2>
|
<h2>上传静态站点</h2>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
压缩包需包含 index.html,大小默认不超过 50MB。中文名用于界面展示,英文名用于安装包与
|
压缩包需包含 index.html,大小默认不超过 50MB。可选在 zip 根目录放置 logo.png 或
|
||||||
|
favicon.ico 作为应用图标(优先 logo.png)。中文名用于界面展示,英文名用于安装包与
|
||||||
Bundle ID 生成。
|
Bundle ID 生成。
|
||||||
</p>
|
</p>
|
||||||
<form className="form" onSubmit={onSubmit}>
|
<form className="form" onSubmit={onSubmit}>
|
||||||
|
|||||||
Reference in New Issue
Block a user