diff --git a/.github/workflows/app-internal.yml b/.github/workflows/app-internal.yml new file mode 100644 index 000000000..a83ac6e24 --- /dev/null +++ b/.github/workflows/app-internal.yml @@ -0,0 +1,300 @@ +name: Expo App Internal + +# 仅内测 artifact;与 eeui 正式发布并行,不创建 Release,也不上传 R2 / 商店。 +on: + workflow_dispatch: + inputs: + platform: + description: 构建平台 + type: choice + options: [all, android, ios] + default: all + ios_export_method: + description: iOS profiles 类型(必须包含测试设备 UDID) + type: choice + options: [ad-hoc, development] + default: ad-hoc + +permissions: + contents: read + +concurrency: + group: app-internal-${{ github.ref }} + cancel-in-progress: false + +env: + CI: '1' + NODE_ENV: production + # 内测 Release 必须使用包内前端,禁止开发机 .env 覆盖 WebView 地址。 + EXPO_NO_DOTENV: '1' + EXPO_PUBLIC_WEB_URL: '' + +jobs: + prepare: + name: Prepare bundled web assets + runs-on: ubuntu-latest + timeout-minutes: 30 + # 同名 iOS secrets 放在独立环境,避免覆盖 build 环境中的 App Store profiles。 + environment: app-internal + outputs: + version: ${{ steps.version.outputs.version }} + ios_ready: ${{ steps.signing.outputs.ios_ready }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Check Expo checkout + run: test -f resources/app/app.config.ts + + - uses: actions/setup-node@v4 + with: + # Expo 58 / RN 0.88 需要 Node ^22.13 / ^24.3 或更新版本。 + node-version: '24' + + - name: Read version + id: version + run: node -e 'require("fs").appendFileSync(process.env.GITHUB_OUTPUT, `version=${require("./package.json").version}\n`)' + + - name: Check iOS signing inputs + id: signing + env: + IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} + IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }} + IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }} + BUILD_PLATFORM: ${{ inputs.platform }} + run: | + if [ "$BUILD_PLATFORM" = android ]; then + echo 'ios_ready=false' >> "$GITHUB_OUTPUT" + elif [ -n "$IOS_CERTIFICATE_BASE64" ] && [ -n "$IOS_PROVISION_PROFILE_BASE64" ] && [ -n "$IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64" ]; then + echo 'ios_ready=true' >> "$GITHUB_OUTPUT" + else + echo 'ios_ready=false' >> "$GITHUB_OUTPUT" + echo '::notice::跳过 iOS:缺少签名证书或主 App / 分享扩展 profile。' + echo 'iOS 已跳过:需要 IOS_CERTIFICATE_BASE64、IOS_PROVISION_PROFILE_BASE64、IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64;密码通过 IOS_CERTIFICATE_PASSWORD 提供。profiles 必须为包含设备 UDID 的内测类型,不能使用 App Store profiles。' >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Install frontend dependencies + # 主仓库没有追踪 npm lockfile;须显式包含 Vite 等 devDependencies。 + run: npm install --include=dev --no-package-lock --no-audit --no-fund + + - name: Install build script dependencies + working-directory: electron + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + run: npm install --include=dev --no-package-lock --no-audit --no-fund + + - name: Build and copy web assets + run: ./cmd appexpo build + + - name: Upload bundled web assets + uses: actions/upload-artifact@v4 + with: + name: expo-web-assets + path: | + resources/app/assets/web/ + resources/app/version.json + if-no-files-found: error + retention-days: 7 + + android: + name: Android internal APK + needs: prepare + if: inputs.platform != 'ios' + runs-on: ubuntu-latest + timeout-minutes: 90 + environment: app-internal + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/download-artifact@v4 + with: + name: expo-web-assets + path: resources/app + + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: resources/app/package-lock.json + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: android-actions/setup-android@v3 + + - name: Install Expo dependencies + working-directory: resources/app + run: npm ci --include=dev --no-audit --no-fund + + - name: Prebuild Android + working-directory: resources/app + run: npx expo prebuild --platform android --clean --no-install + + - name: Prepare optional Android keystore + id: signing + env: + # 旧 publish.yml 未定义 Android 签名 secrets;这四项仅供新链路使用。 + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + if [ -n "$ANDROID_KEYSTORE_BASE64" ] && [ -n "$ANDROID_KEYSTORE_PASSWORD" ] && [ -n "$ANDROID_KEY_ALIAS" ] && [ -n "$ANDROID_KEY_PASSWORD" ]; then + umask 077 + echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/dootask-internal.jks" + echo "ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/dootask-internal.jks" >> "$GITHUB_ENV" + echo 'mode=keystore' >> "$GITHUB_OUTPUT" + echo 'Android Release 包使用提供的 keystore 签名。' >> "$GITHUB_STEP_SUMMARY" + else + echo 'mode=debug' >> "$GITHUB_OUTPUT" + echo '::notice::Android 签名 secrets 不完整,Release 包使用 debug 签名。' + echo 'Android Release 包使用 debug 签名(签名 secrets 缺失或不完整),用于全新安装内测;与正式包签名不同,不能直接覆盖正式安装。' >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Assemble Release + working-directory: resources/app/android + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: ./gradlew :app:assembleRelease --no-daemon --init-script ../scripts/android-signing.gradle + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: dootask-expo-android-${{ needs.prepare.outputs.version }}-${{ steps.signing.outputs.mode }} + path: resources/app/android/app/build/outputs/apk/release/*.apk + if-no-files-found: error + retention-days: 14 + + - name: Remove Android keystore + if: always() + run: rm -f "$RUNNER_TEMP/dootask-internal.jks" + + ios: + name: iOS internal IPA + needs: prepare + if: needs.prepare.outputs.ios_ready == 'true' + runs-on: macos-26 + timeout-minutes: 90 + environment: app-internal + env: + IOS_EXPORT_METHOD: ${{ inputs.ios_export_method }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/download-artifact@v4 + with: + name: expo-web-assets + path: resources/app + + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: resources/app/package-lock.json + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install Expo dependencies + working-directory: resources/app + run: npm ci --include=dev --no-audit --no-fund + + - name: Prebuild iOS + working-directory: resources/app + run: npx expo prebuild --platform ios --clean --no-install + + - name: Install CocoaPods + working-directory: resources/app/ios + run: pod install + + - name: Import signing certificate + env: + IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + umask 077 + SIGNING_DIR="$RUNNER_TEMP/dootask-ios-signing" + mkdir -p "$SIGNING_DIR" + KEYCHAIN_PATH="$SIGNING_DIR/signing.keychain-db" + KEYCHAIN_PASSWORD=$(openssl rand -hex 20) + security list-keychains -d user > "$SIGNING_DIR/original-keychains.txt" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + echo "$IOS_CERTIFICATE_BASE64" | base64 --decode > "$SIGNING_DIR/certificate.p12" + security import "$SIGNING_DIR/certificate.p12" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + + - name: Install and validate internal profiles + working-directory: resources/app + env: + IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }} + IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }} + run: python3 scripts/prepare-ios-signing.py "$RUNNER_TEMP/dootask-ios-signing" + + - name: Configure product target signing via Expo mod + working-directory: resources/app + run: node scripts/configure-ios-signing.js "$RUNNER_TEMP/dootask-ios-signing/signing.json" + + - name: Archive iOS Release + working-directory: resources/app + run: | + xcodebuild archive \ + -workspace ios/DooTask.xcworkspace \ + -scheme DooTask \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$RUNNER_TEMP/DooTask.xcarchive" \ + -derivedDataPath "$RUNNER_TEMP/dootask-derived-data" + + - name: Export internal IPA + run: | + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/DooTask.xcarchive" \ + -exportOptionsPlist "$RUNNER_TEMP/dootask-ios-signing/ExportOptions.plist" \ + -exportPath "$RUNNER_TEMP/dootask-ipa" + + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: dootask-expo-ios-${{ needs.prepare.outputs.version }}-${{ inputs.ios_export_method }} + path: ${{ runner.temp }}/dootask-ipa/*.ipa + if-no-files-found: error + retention-days: 14 + + - name: Remove signing material + if: always() + run: | + SIGNING_DIR="$RUNNER_TEMP/dootask-ios-signing" + if [ -f "$SIGNING_DIR/signing.json" ]; then + python3 - "$SIGNING_DIR/signing.json" <<'PY' + import json + from pathlib import Path + import sys + for filename in json.loads(Path(sys.argv[1]).read_text())["installed_profiles"]: + Path(filename).unlink(missing_ok=True) + PY + fi + if [ -f "$SIGNING_DIR/original-keychains.txt" ]; then + python3 - "$SIGNING_DIR/original-keychains.txt" <<'PY' + from pathlib import Path + import shlex + import subprocess + import sys + subprocess.run(["security", "list-keychains", "-d", "user", "-s", *shlex.split(Path(sys.argv[1]).read_text())], check=True) + PY + fi + security delete-keychain "$SIGNING_DIR/signing.keychain-db" 2>/dev/null || true + rm -rf "$SIGNING_DIR" diff --git a/cmd b/cmd index 3a5e1b617..c7142d012 100755 --- a/cmd +++ b/cmd @@ -429,6 +429,34 @@ electron_operate() { env BUILD_FRONTEND=$BUILD_FRONTEND node ./electron/build.js $argv } +# Expo 内测:同 electron 使用本机 Node,不依赖 Docker 或旧 eeui 容器。 +appexpo_operate() { + check_node + case "${1:-build}" in + "build") + if [[ $# -gt 1 ]]; then + error "用法: ./cmd appexpo [build] | ./cmd appexpo npm <参数...>" + return 1 + fi + if [ ! -d "./node_modules" ]; then + npm install --no-package-lock || return $? + fi + if [ ! -d "./electron/node_modules" ]; then + (cd electron && ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm install --no-package-lock) || return $? + fi + node ./electron/build.js app-expo build + ;; + "npm") + shift + (cd "${WORK_DIR}/resources/app" && npm "$@") + ;; + *) + error "用法: ./cmd appexpo [build] | ./cmd appexpo npm <参数...>" + return 1 + ;; + esac +} + # 执行容器命令 container_exec() { local container=$1 @@ -683,6 +711,8 @@ DooTask 管理脚本 serve, dev 启动开发模式 build, prod 生产环境构建 electron 构建桌面应用 + appexpo [build] 编译并同步 Expo 内测前端 + appexpo npm <参数...> 在 Expo 子模块执行本机 npm 🔧 服务管理: up [服务名] 启动容器 @@ -734,6 +764,8 @@ Usage: ./cmd [options] serve, dev Start dev mode build, prod Production build electron Build desktop app + appexpo [build] Build and copy Expo internal web assets + appexpo npm Run local npm in the Expo submodule 🔧 Services: up [service] Start containers @@ -1196,8 +1228,8 @@ if [[ "$1" == "help" ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]] || [[ $# exit 0 fi -# 非electron和permission命令需要检查Docker环境 -if [[ "$1" != "electron" ]] && [[ "$1" != "permission" ]]; then +# 非electron、appexpo和permission命令需要检查Docker环境 +if [[ "$1" != "electron" ]] && [[ "$1" != "appexpo" ]] && [[ "$1" != "permission" ]]; then check_docker env_init fi @@ -1260,6 +1292,11 @@ case "$1" in shift 1 electron_operate app "$@" ;; + "appexpo") + shift 1 + appexpo_operate "$@" + exit $? + ;; "electron") shift 1 electron_operate "$@" diff --git a/electron/build.js b/electron/build.js index 92fc3d2a0..de34a8556 100644 --- a/electron/build.js +++ b/electron/build.js @@ -336,8 +336,12 @@ async function uploadDraftFile(client, localFile, version) { * @param data */ async function startBuild(data) { - if (BUILD_FRONTEND === 'build' && !buildChecked) { + if ((BUILD_FRONTEND === 'build' || data.id === 'app-expo') && !buildChecked) { buildChecked = true + // Expo 每次重新编译,避免把上次桌面端或旧版本的文件带进内测包。 + if (data.id === 'app-expo') { + fse.emptyDirSync(electronDir); + } fs.mkdirSync(electronDir, { recursive: true }); fse.copySync(path.resolve(__dirname, "index.html"), path.resolve(electronDir, "index.html")) child_process.execSync("npx vite build -- fromcmd electronBuild", {stdio: "inherit"}); @@ -355,7 +359,7 @@ async function startBuild(data) { apiUrl: utils.formatUrl(data.url) + "api/", } // information - if (data.id === 'app') { + if (['app', 'app-expo'].includes(data.id)) { console.log("\n=== 编译信息 ==="); console.log("名称:", data.name); console.log("版本:", config.version + ` (${config.codeVerson})`); @@ -385,11 +389,16 @@ async function startBuild(data) { // config.js fs.writeFileSync(electronDir + "/config.js", "window.systemInfo = " + JSON.stringify(systemInfo), 'utf8'); fs.writeFileSync(electronDir + "/dark", '', 'utf8'); - fs.writeFileSync(nativeCachePath, utils.formatUrl(data.url)); - fs.writeFileSync(devloadCachePath, "", 'utf8'); + if (data.id !== 'app-expo') { + fs.writeFileSync(nativeCachePath, utils.formatUrl(data.url)); + fs.writeFileSync(devloadCachePath, "", 'utf8'); + } // index.html let manifestFile = path.resolve(electronDir, "manifest.json"); if (!fs.existsSync(manifestFile)) { + if (data.id === 'app-expo') { + throw new Error("manifest.json 未找到,Expo 前端构建失败"); + } console.error("manifest.json 未找到"); return; } @@ -401,6 +410,18 @@ async function startBuild(data) { indexString = indexString.replace("", ``); fs.writeFileSync(indexFile, indexString, 'utf8'); // + if (data.id === 'app-expo') { + const appDir = path.resolve(__dirname, "../resources/app"); + const webDir = path.join(appDir, "assets/web"); + fse.emptyDirSync(webDir); + fse.copySync(electronDir, webDir); + fs.writeFileSync(path.join(appDir, "version.json"), JSON.stringify({ + version: config.version, + codeVerson: config.codeVerson, + }, null, 2) + "\n", 'utf8'); + console.log("Expo 前端资源已同步至 resources/app/assets/web/"); + return; + } if (data.id === 'app') { const eeuiDir = path.resolve(__dirname, "../resources/mobile"); const publicDir = path.resolve(__dirname, "../resources/mobile/src/public"); @@ -553,6 +574,32 @@ if (["dev"].includes(argv[2])) { notarize: false, } }) +} else if (argv[2] === 'app-expo') { + if ((argv[3] && argv[3] !== 'build') || argv.length > 4) { + console.error("用法: node ./electron/build.js app-expo [build]"); + process.exit(1); + } + if (!fs.existsSync(path.resolve(__dirname, "../resources/app/app.config.ts"))) { + console.error("resources/app 未找到,请先初始化 Expo 子模块"); + process.exit(1); + } + startBuild({ + name: 'App', + id: 'app-expo', + // public 是前端“尚未设置服务器”的占位域名,不是静态资源的 origin。 + // electronBuild 的 ./ 资源路径在 localhost:22223/ 下加载,路由自动使用 hash。 + url: 'http://public/', + configure: { + platform: '', + archs: [], + publish: false, + release: true, + notarize: false, + } + }).catch(error => { + console.error(error.message || error); + process.exitCode = 1; + }); } else if (["android-upload"].includes(argv[2])) { // 上传安卓文件到 R2 draft(GitHub Actions) (async () => { diff --git a/resources/app b/resources/app index 1da72c70f..71e81bcfa 160000 --- a/resources/app +++ b/resources/app @@ -1 +1 @@ -Subproject commit 1da72c70f95a4489fd3b52d8ea367905d5ef2b5a +Subproject commit 71e81bcfae7ddeed49c819a2970bbdf298715b2a