diff --git a/.github/scripts/generate-app-icons.mjs b/.github/scripts/generate-app-icons.mjs new file mode 100644 index 0000000..9879cfb --- /dev/null +++ b/.github/scripts/generate-app-icons.mjs @@ -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", + }); +} diff --git a/.github/scripts/patch-android-gradle.mjs b/.github/scripts/patch-android-gradle.mjs new file mode 100644 index 0000000..f8aad8f --- /dev/null +++ b/.github/scripts/patch-android-gradle.mjs @@ -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"); diff --git a/.github/scripts/prepare-template.mjs b/.github/scripts/prepare-template.mjs index ed72183..0dbe976 100644 --- a/.github/scripts/prepare-template.mjs +++ b/.github/scripts/prepare-template.mjs @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import AdmZip from "adm-zip"; +import { generateAppIcons } from "./generate-app-icons.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, "../.."); @@ -68,13 +69,6 @@ if (fs.existsSync(androidConfPath)) { patchTauriConfig(androidConfPath); } -const iconCandidates = ["icon.png", "favicon.png", "logo.png"]; -for (const candidate of iconCandidates) { - const iconPath = path.join(distDir, candidate); - if (fs.existsSync(iconPath)) { - console.log(`Found icon candidate: ${candidate}`); - break; - } -} +generateAppIcons(); console.log("Template prepared successfully"); diff --git a/.github/scripts/setup-android-signing.sh b/.github/scripts/setup-android-signing.sh new file mode 100644 index 0000000..dfd3299 --- /dev/null +++ b/.github/scripts/setup-android-signing.sh @@ -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" </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 diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index 6c496cf..e9d184c 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -117,18 +117,32 @@ jobs: env: 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 working-directory: template run: npm run tauri android build -- --apk env: NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.NDK_VERSION }} - - name: Collect Android artifacts + - name: Sign Android APK run: | - mkdir -p artifacts/android - find template/src-tauri/gen/android -name "*.apk" -exec cp {} artifacts/android/ \; - find template/src-tauri -path "*/build/outputs/apk/*" -name "*.apk" -exec cp {} artifacts/android/ \; - ls -la artifacts/android + bash .github/scripts/setup-android-signing.sh + bash .github/scripts/sign-android-apk.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 }} - uses: actions/upload-artifact@v4 with: @@ -176,7 +190,7 @@ jobs: Automated build for **${{ inputs.app_name }}** (English: `${{ inputs.app_name_en }}`, ID: `${{ inputs.app_identifier }}`). - Windows desktop installer/portable binary - - Android APK (demo signing, sideload only) + - Android APK (signed for sideload install) draft: false prerelease: true files: release-assets/final/* diff --git a/README.md b/README.md index a20f761..daa41e9 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,35 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force 构建产物发布到 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 构建依赖 NDK / SDK,workflow 已预装常见组件 @@ -117,7 +143,7 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force - 无登录鉴权,仅适合本地/内网 demo - GitHub Actions 有排队时间与额度限制 - 大 zip 会短暂占用仓库空间(finalize 会尝试删除 `site.zip`) -- Android APK 非 Play Store 发布级别 +- 未配置自有证书时,各次 CI 构建可能使用不同签名,无法覆盖安装旧版 APK ## 目录结构 diff --git a/scripts/generate-android-keystore.sh b/scripts/generate-android-keystore.sh new file mode 100644 index 0000000..ac57d0e --- /dev/null +++ b/scripts/generate-android-keystore.sh @@ -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}" diff --git a/web/src/pages/Upload.tsx b/web/src/pages/Upload.tsx index ba9cd03..0656414 100644 --- a/web/src/pages/Upload.tsx +++ b/web/src/pages/Upload.tsx @@ -43,7 +43,8 @@ export default function Upload() {

上传静态站点

- 压缩包需包含 index.html,大小默认不超过 50MB。中文名用于界面展示,英文名用于安装包与 + 压缩包需包含 index.html,大小默认不超过 50MB。可选在 zip 根目录放置 logo.png 或 + favicon.ico 作为应用图标(优先 logo.png)。中文名用于界面展示,英文名用于安装包与 Bundle ID 生成。