init: Wardrobe 衣物管理系统 - 项目首页、OTA 升级、Android CI/CD
- 响应式项目首页 (Landing, 含 R2 APK 下载) + 路由 /home 调整 - OTA 升级: tauri-plugin-hotswap (lib.rs/main.rs 拆分, 客户端热替换) - OTA 服务端 (check/bundle/manifest-web) + build-ota/genkey/upload 脚本 - 密钥/凭据统一存配置中心 (signing + ota + cloudflare 分组) - Android CI (android-* 分支) + OTA CI (手动触发), 产物上传 R2 - 修复 tsc/构建阻塞问题
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wardrobe Android APK 本地打包脚本 (WSL 内运行)
|
||||
# 用法: bash scripts/build-apk.sh [debug|release]
|
||||
# 参考: D:\workbench\iboard\scripts\wsl\build-apk.sh
|
||||
# 尽量复用本机已装的 Android SDK / NDK, 不重复下载
|
||||
set -euo pipefail
|
||||
|
||||
WIN_PROJECT='/mnt/d/workbench/Wardrobe'
|
||||
cd "$WIN_PROJECT"
|
||||
|
||||
VARIANT="${1:-debug}"
|
||||
echo "构建变体: $VARIANT"
|
||||
|
||||
# ---- 复用已有 Android SDK / NDK (不重复安装) ----
|
||||
# 优先用 ANDROID_HOME 环境变量, 找不到再探测常见路径
|
||||
if [ -z "${ANDROID_HOME:-}" ] || [ ! -d "$ANDROID_HOME/platforms" ]; then
|
||||
for cand in "$HOME/android-sdk" "/opt/android-sdk" "/usr/lib/android-sdk"; do
|
||||
if [ -d "$cand/platforms" ]; then
|
||||
export ANDROID_HOME="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${ANDROID_HOME:-}" ] || [ ! -d "$ANDROID_HOME/platforms" ]; then
|
||||
echo "ERROR: 未找到可复用的 Android SDK (ANDROID_HOME)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "复用 SDK: $ANDROID_HOME"
|
||||
|
||||
# NDK: 复用已装的 27.2.x, 找不到报错
|
||||
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/27.2.12479018}"
|
||||
NDK_TOOLCHAIN_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
if [ ! -d "$NDK_TOOLCHAIN_BIN" ]; then
|
||||
echo "ERROR: NDK toolchain 不存在: $NDK_TOOLCHAIN_BIN, 请确认已安装 NDK 27.2.x" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "复用 NDK: $ANDROID_NDK_HOME"
|
||||
|
||||
export PATH="$NDK_TOOLCHAIN_BIN:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
|
||||
|
||||
# ---- 0. 首次需要 android 工程 ----
|
||||
cd src-tauri
|
||||
if [ ! -d "gen/android" ]; then
|
||||
echo "=== 0. 初始化 Android 工程 (一次性) ==="
|
||||
npx tauri android init --ci
|
||||
fi
|
||||
cd "$WIN_PROJECT"
|
||||
|
||||
# ---- 1. 构建前端 ----
|
||||
echo "=== 1. 构建前端 ==="
|
||||
npm run build
|
||||
|
||||
# ---- 1.5 拷贝 dist/ 到 Android 工程 assets/ ----
|
||||
ANDROID_ASSETS="$WIN_PROJECT/src-tauri/gen/android/app/src/main/assets"
|
||||
mkdir -p "$ANDROID_ASSETS"
|
||||
find "$ANDROID_ASSETS" -mindepth 1 -maxdepth 1 ! -name dexopt ! -name tauri.conf.json -exec rm -rf {} +
|
||||
cp -r dist/. "$ANDROID_ASSETS/"
|
||||
echo "前端 bundle 已拷贝到 $ANDROID_ASSETS"
|
||||
|
||||
# ---- 2. 构建 ----
|
||||
cd src-tauri
|
||||
|
||||
if [ "$VARIANT" = "release" ]; then
|
||||
# release: 手工 cargo 编 4 ABI, gradle 只跑打包
|
||||
RUST_BUILD_TASKS=(
|
||||
":app:rustBuildArm64Release"
|
||||
":app:rustBuildArmRelease"
|
||||
":app:rustBuildX86_64Release"
|
||||
":app:rustBuildX86Release"
|
||||
)
|
||||
SKIP_ARGS=()
|
||||
for t in "${RUST_BUILD_TASKS[@]}"; do SKIP_ARGS+=(-x "${t#:}"); done
|
||||
|
||||
ANDROID_TARGETS=(
|
||||
"aarch64-linux-android"
|
||||
"armv7-linux-androideabi"
|
||||
"x86_64-linux-android"
|
||||
"i686-linux-android"
|
||||
)
|
||||
|
||||
echo "[release] 手工 cargo 编 lib (4 个 ABI)..."
|
||||
for target in "${ANDROID_TARGETS[@]}"; do
|
||||
echo " [cargo] build lib release for $target ..."
|
||||
cargo build \
|
||||
--target "$target" \
|
||||
--features custom-protocol \
|
||||
--lib \
|
||||
--release
|
||||
done
|
||||
|
||||
echo " [gradle] assembleRelease -x rustBuild* ..."
|
||||
cd gen/android
|
||||
./gradlew --no-daemon assembleRelease "${SKIP_ARGS[@]}"
|
||||
|
||||
APK_DIR="$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/universal/release"
|
||||
SIGNED_APK="$APK_DIR/app-universal-release.apk"
|
||||
UNSIGNED_APK="$APK_DIR/app-universal-release-unsigned.apk"
|
||||
|
||||
if [ -f "$SIGNED_APK" ]; then
|
||||
APK_PATH="$SIGNED_APK"
|
||||
APK_STATUS="已签名"
|
||||
elif [ -f "$UNSIGNED_APK" ]; then
|
||||
APK_PATH="$UNSIGNED_APK"
|
||||
APK_STATUS="未签名"
|
||||
else
|
||||
APK_PATH=""
|
||||
APK_STATUS="未找到"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== 产物 ($APK_STATUS) ==="
|
||||
if [ -n "$APK_PATH" ]; then
|
||||
ls -lh "$APK_PATH"
|
||||
else
|
||||
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
|
||||
fi
|
||||
|
||||
if [ "$APK_STATUS" = "已签名" ]; then
|
||||
echo
|
||||
echo "安装: adb install -r $APK_PATH"
|
||||
fi
|
||||
else
|
||||
echo "[debug] 直接走 Tauri CLI"
|
||||
npx tauri android build --apk
|
||||
|
||||
APK_DIR="$WIN_PROJECT/src-tauri/target/debug/bundle/android/app/build/outputs/apk/debug"
|
||||
echo
|
||||
echo "=== 产物 ==="
|
||||
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
|
||||
fi
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
|
||||
// 用法:
|
||||
// node scripts/build-ota.mjs [--out <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign] [--key <path>]
|
||||
// 流程:
|
||||
// 1) 校验 dist/ 已构建
|
||||
// 2) 生成 bundle.tar.gz (dist/ 内容)
|
||||
// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量)
|
||||
// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...)
|
||||
// 5) 全部产物输出到 out 目录 (默认 ota-package/)
|
||||
//
|
||||
// minisign 安装: 见 README "OTA 升级" 章节
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
function getArg(name, def) {
|
||||
const i = args.indexOf(name)
|
||||
return i >= 0 && args[i + 1] ? args[i + 1] : def
|
||||
}
|
||||
const OUT_DIR = path.resolve(getArg('--out', 'ota-package'))
|
||||
const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数
|
||||
const MIN_BINARY = getArg('--min-binary', '')
|
||||
const NOTES = getArg('--notes', '')
|
||||
const NO_SIGN = args.includes('--no-sign')
|
||||
// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
|
||||
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
|
||||
const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY
|
||||
// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制)
|
||||
const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json')
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
|
||||
const APP_VERSION = pkg.version || '0.0.0'
|
||||
|
||||
const DIST = path.join(ROOT, 'dist')
|
||||
if (!fs.existsSync(path.join(DIST, 'index.html'))) {
|
||||
console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 准备输出目录
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
|
||||
// 1) 生成 bundle.tar.gz
|
||||
const BUNDLE_NAME = 'wardrobe-ota.tar.gz'
|
||||
const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME)
|
||||
const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' })
|
||||
if (r.status !== 0) {
|
||||
console.error('[build-ota] tar 打包失败')
|
||||
process.exit(1)
|
||||
}
|
||||
const bundleStat = fs.statSync(BUNDLE_PATH)
|
||||
const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex')
|
||||
|
||||
// 2) 签名 (minisign)
|
||||
let signature = ''
|
||||
let pubkey = ''
|
||||
if (!NO_SIGN) {
|
||||
// CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘)
|
||||
let KEY_PATH_TMP = null
|
||||
let KEY_PATH_USE = KEY_PATH
|
||||
if (KEY_FROM_ENV) {
|
||||
KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`)
|
||||
fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 })
|
||||
KEY_PATH_USE = KEY_PATH_TMP
|
||||
}
|
||||
if (!fs.existsSync(KEY_PATH_USE)) {
|
||||
console.error('[build-ota] 私钥不存在:', KEY_PATH_USE)
|
||||
console.error(' 注入方式二选一:')
|
||||
console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)')
|
||||
console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key <path>')
|
||||
console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
|
||||
process.exit(1)
|
||||
}
|
||||
// minisign -Sm <file> -s <key> 生成 detached signature
|
||||
const sigR = spawnSync('minisign', ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE], { stdio: 'inherit' })
|
||||
// 清理 CI 临时私钥 (无论成功失败都删)
|
||||
if (KEY_PATH_TMP) {
|
||||
try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ }
|
||||
KEY_PATH_TMP = null
|
||||
}
|
||||
if (sigR.status !== 0) {
|
||||
console.error('[build-ota] minisign 签名失败')
|
||||
process.exit(1)
|
||||
}
|
||||
const sigPath = BUNDLE_PATH + '.minisig'
|
||||
signature = fs.readFileSync(sigPath, 'utf8').trim()
|
||||
|
||||
// 尝试同步 pubkey 到 tauri.conf.json (如果存在)
|
||||
const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
|
||||
if (fs.existsSync(PUB_PATH)) {
|
||||
pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim()
|
||||
const lines = pubkey.split(/\r?\n/)
|
||||
pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 写 latest.json
|
||||
const minBinary = MIN_BINARY || (() => {
|
||||
// 默认从 src-tauri/tauri.conf.json 读 version
|
||||
try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION }
|
||||
catch { return APP_VERSION }
|
||||
})()
|
||||
const manifest = {
|
||||
version: APP_VERSION + '-ota',
|
||||
sequence: SEQUENCE,
|
||||
min_binary_version: minBinary,
|
||||
bundle: BUNDLE_NAME,
|
||||
bundle_size: bundleStat.size,
|
||||
bundle_sha256: bundleSha256,
|
||||
signature,
|
||||
notes: NOTES,
|
||||
pub_date: new Date().toISOString(),
|
||||
mandatory: false,
|
||||
// 内部用: 前端 web 模式比对 build_id
|
||||
build_id: 'seq-' + SEQUENCE,
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
|
||||
|
||||
// 4) 提示
|
||||
console.log('')
|
||||
console.log('=== OTA 包生成完成 ===')
|
||||
console.log('输出目录: ', OUT_DIR)
|
||||
console.log('版本: ', manifest.version)
|
||||
console.log('sequence: ', manifest.sequence)
|
||||
console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
|
||||
console.log('bundle sha256:', bundleSha256)
|
||||
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
|
||||
if (pubkey) {
|
||||
console.log('')
|
||||
console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
|
||||
console.log(' ' + pubkey)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log('下一步: 把这两个文件发布到 OTA 存储:')
|
||||
console.log(' ' + BUNDLE_NAME)
|
||||
console.log(' latest.json')
|
||||
console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI 专用: 为 Tauri Android release 构建注入 release 签名配置
|
||||
# 在 `tauri android init` 之后、`tauri android build` 之前运行。
|
||||
# 参考: D:\workbench\iboard\scripts\ci\android-signing.sh
|
||||
#
|
||||
# 依赖 (由调用方注入环境变量, 不要写死密钥到脚本/仓库):
|
||||
# ANDROID_KEYSTORE_B64 - base64 编码的 keystore 文件内容 (CI secret)
|
||||
# ANDROID_KEY_ALIAS - 密钥别名 (CI secret/variable)
|
||||
# ANDROID_KEY_PASSWORD - store & key 密码 (CI secret; 本脚本两者用同一个)
|
||||
#
|
||||
# 作用:
|
||||
# 1. 把 base64 密钥解码写到 $RUNNER_TEMP/wardrobe-release.keystore
|
||||
# 2. 生成 gen/android/keystore.properties (Tauri 签名约定读取此文件)
|
||||
# 3. 修改 gen/android/app/build.gradle.kts, 添加 signingConfigs.release
|
||||
# 并让 buildTypes.release 使用它, 产出已签名 APK
|
||||
set -euo pipefail
|
||||
|
||||
APP_GRADLE="src-tauri/gen/android/app/build.gradle.kts"
|
||||
|
||||
echo "[signing] 注入 Android release 签名配置..."
|
||||
|
||||
# --- 1. 解码 keystore ---
|
||||
if [ -z "${ANDROID_KEYSTORE_B64:-}" ]; then
|
||||
echo "[signing] ERROR: ANDROID_KEYSTORE_B64 未设置, 无法签名 release APK" >&2
|
||||
exit 1
|
||||
fi
|
||||
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS 未设置}"
|
||||
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD 未设置}"
|
||||
|
||||
KEYSTORE_FILE="${RUNNER_TEMP:-/tmp}/wardrobe-release.keystore"
|
||||
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "$KEYSTORE_FILE"
|
||||
|
||||
# --- 2. 写 keystore.properties (Tauri 官方约定的签名配置文件位置) ---
|
||||
KEYSTORE_PROPERTIES="src-tauri/gen/android/keystore.properties"
|
||||
cat > "$KEYSTORE_PROPERTIES" <<EOF
|
||||
keyAlias=${ANDROID_KEY_ALIAS}
|
||||
password=${ANDROID_KEY_PASSWORD}
|
||||
storeFile=${KEYSTORE_FILE}
|
||||
EOF
|
||||
|
||||
# --- 3. 修改 build.gradle.kts 注入 signingConfigs ---
|
||||
if [ ! -f "$APP_GRADLE" ]; then
|
||||
echo "[signing] ERROR: 找不到 $APP_GRADLE (请先运行 tauri android init)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q 'create("release")' "$APP_GRADLE" 2>/dev/null; then
|
||||
echo "[signing] build.gradle.kts 已有 release signingConfigs, 跳过注入"
|
||||
else
|
||||
python3 - "$APP_GRADLE" <<'PYEOF'
|
||||
import sys, re
|
||||
|
||||
path = sys.argv[1]
|
||||
src = open(path, encoding='utf-8').read()
|
||||
|
||||
if 'import java.io.FileInputStream' not in src:
|
||||
imp = re.search(r'^import .*$', src, flags=re.M)
|
||||
new_import = 'import java.io.FileInputStream\n'
|
||||
if imp:
|
||||
src = src[:imp.end()] + '\n' + new_import + src[imp.end():]
|
||||
else:
|
||||
src = new_import + src
|
||||
|
||||
m = re.search(r'(\n[ \t]*)(buildTypes\s*\{)', src)
|
||||
if not m:
|
||||
print('[signing] 未找到 buildTypes 块, 中止', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
indent = m.group(1)
|
||||
inner_indent = indent + ' '
|
||||
|
||||
signing_block = (
|
||||
f'{inner_indent}signingConfigs {{\n'
|
||||
f'{inner_indent} create("release") {{\n'
|
||||
f'{inner_indent} val keystorePropertiesFile = rootProject.file("keystore.properties")\n'
|
||||
f'{inner_indent} val keystoreProperties = Properties()\n'
|
||||
f'{inner_indent} if (keystorePropertiesFile.exists()) {{\n'
|
||||
f'{inner_indent} keystoreProperties.load(FileInputStream(keystorePropertiesFile))\n'
|
||||
f'{inner_indent} }}\n'
|
||||
f'{inner_indent} keyAlias = keystoreProperties["keyAlias"] as? String ?: ""\n'
|
||||
f'{inner_indent} keyPassword = keystoreProperties["password"] as? String ?: ""\n'
|
||||
f'{inner_indent} storeFile = file(keystoreProperties["storeFile"] as? String ?: "")\n'
|
||||
f'{inner_indent} storePassword = keystoreProperties["password"] as? String ?: ""\n'
|
||||
f'{inner_indent} }}\n'
|
||||
f'{inner_indent}}}\n'
|
||||
)
|
||||
|
||||
src = src.replace(m.group(0), signing_block + m.group(0), 1)
|
||||
|
||||
rel = re.search(r'(\n[ \t]*)(getByName\("release"\)\s*\{)', src)
|
||||
if not rel:
|
||||
rel = re.search(r'(\n[ \t]*)(release\s*\{)', src)
|
||||
if rel:
|
||||
rel_indent = rel.group(1) + ' '
|
||||
inject = rel_indent + 'signingConfig = signingConfigs.getByName("release")\n'
|
||||
src = src.replace(rel.group(0), rel.group(0) + inject, 1)
|
||||
|
||||
open(path, 'w', encoding='utf-8').write(src)
|
||||
print('[signing] build.gradle.kts 已注入 import + signingConfigs + release signingConfig')
|
||||
PYEOF
|
||||
fi
|
||||
|
||||
# --- 4. 放行明文 HTTP (release APK 默认拦截 http://) ---
|
||||
if [ ! -f "$APP_GRADLE" ]; then
|
||||
echo "[signing] ERROR: 找不到 $APP_GRADLE (明文放行跳过)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$APP_GRADLE" <<'PYEOF'
|
||||
import sys, re
|
||||
|
||||
path = sys.argv[1]
|
||||
src = open(path, encoding='utf-8').read()
|
||||
|
||||
new = re.sub(
|
||||
r'(manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*)"false"',
|
||||
r'\1"true"',
|
||||
src,
|
||||
)
|
||||
|
||||
if 'usesCleartextTraffic' not in src:
|
||||
m = re.search(r'^([ \t]*)getByName\("release"\)\s*\{', new, flags=re.M)
|
||||
if not m:
|
||||
m = re.search(r'^([ \t]*)release\s*\{', new, flags=re.M)
|
||||
if m:
|
||||
inner = m.group(1) + ' '
|
||||
inject = f'\n{inner}manifestPlaceholders["usesCleartextTraffic"] = "true"'
|
||||
new = new.replace(m.group(0), m.group(0) + inject, 1)
|
||||
else:
|
||||
print('[signing] 警告: 未找到 release 构建块, 明文放行注入跳过', file=sys.stderr)
|
||||
|
||||
open(path, 'w', encoding='utf-8').write(new)
|
||||
print('[signing] usesCleartextTraffic 已设为 true (release 放行明文 HTTP)')
|
||||
PYEOF
|
||||
|
||||
echo "[signing] 完成: keystore.properties 已写, build.gradle.kts 已注入"
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
// 生成 minisign 密钥对 (ota-package/hotswap.{key,pub})
|
||||
// 首次跑一遍: node scripts/ota-genkey.mjs
|
||||
// - hotswap.key: 私钥, 不要提交, 部署时由 CI 注入 (环境变量 / secret)
|
||||
// - hotswap.pub: 公钥, 写到 src-tauri/tauri.conf.json 的 plugins.hotswap.pubkey
|
||||
//
|
||||
// 依赖: minisign (https://jedisct1.github.io/minisign/)
|
||||
// - Windows: scoop install minisign 或 choco install minisign (或下载官方 win64 二进制)
|
||||
// - macOS: brew install minisign
|
||||
// - Linux: apt install minisign
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const OUT_DIR = path.join(ROOT, 'ota-package')
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
|
||||
const KEY = path.join(OUT_DIR, 'hotswap.key')
|
||||
const PUB = path.join(OUT_DIR, 'hotswap.pub')
|
||||
|
||||
if (fs.existsSync(KEY) || fs.existsSync(PUB)) {
|
||||
console.error('[ota-genkey] 已存在密钥, 如需重新生成请先删除 ota-package/hotswap.{key,pub}')
|
||||
console.error(' 注意: 重新生成密钥会导致旧 bundle 无法被客户端验证, 慎用!')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 检查 minisign 是否存在
|
||||
const which = spawnSync('minisign', ['-v'], { stdio: 'pipe' })
|
||||
if (which.status !== 0 && which.error) {
|
||||
console.error('[ota-genkey] 未找到 minisign 命令, 请先安装:')
|
||||
console.error(' Windows: scoop install minisign')
|
||||
console.error(' macOS: brew install minisign')
|
||||
console.error(' Linux: apt install minisign')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// -G: 生成 -W: 无密码 -p: 公钥输出 -s: 私钥输出
|
||||
const r = spawnSync('minisign', ['-G', '-W', '-p', PUB, '-s', KEY], { stdio: 'inherit' })
|
||||
if (r.status !== 0) {
|
||||
console.error('[ota-genkey] minisign 生成失败')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('\n=== 密钥生成完成 ===')
|
||||
console.log('私钥: ' + KEY + ' (不要提交到 git!)')
|
||||
console.log('公钥: ' + PUB)
|
||||
|
||||
const pubText = fs.readFileSync(PUB, 'utf8')
|
||||
const lines = pubText.split(/\r?\n/)
|
||||
const pubB64 = lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ'))
|
||||
console.log('\n请把下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
|
||||
console.log(' ' + (pubB64 || '').trim())
|
||||
|
||||
// 自动写 .gitignore 提醒
|
||||
const gitignore = path.join(ROOT, '.gitignore')
|
||||
if (fs.existsSync(gitignore)) {
|
||||
let txt = fs.readFileSync(gitignore, 'utf8')
|
||||
if (!txt.includes('ota-package/hotswap.key')) {
|
||||
if (!txt.endsWith('\n')) txt += '\n'
|
||||
txt += '\n# OTA 升级私钥, 不要提交\nota-package/hotswap.key\n'
|
||||
fs.writeFileSync(gitignore, txt)
|
||||
console.log('\n[ota-genkey] 已把 ota-package/hotswap.key 加入 .gitignore')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# 把 OTA 升级包 (bundle + latest.json) 上传到 Cloudflare R2 (公开桶, 前缀 store/ota/)
|
||||
# 用法: bash scripts/upload-ota-r2.sh <OTA_DIR>
|
||||
# 默认 <OTA_DIR> = ota-package
|
||||
# 说明:
|
||||
# - bundle 固定名 wardrobe-ota.tar.gz (与 build-ota.mjs 保持一致), 覆盖式上传
|
||||
# - latest.json 一并上传, 便于把 R2 作为 OTA 静态存储 (配合 OTA_PUBLIC_BASE 使用)
|
||||
#
|
||||
# 依赖环境变量 (通常来自配置注册中心 CommonExternalService / cloudflare):
|
||||
# CF_API_TOKEN Cloudflare API token (r2/CF_API_TOKEN)
|
||||
# CF_R2_BUCKET (可选, 默认 store)
|
||||
# CF_R2_PUBLIC_URL (可选; 若设置, 同时输出可直接给 OTA_PUBLIC_BASE 使用的下载地址)
|
||||
set -euo pipefail
|
||||
|
||||
OTA_DIR="${1:-ota-package}"
|
||||
BUNDLE="wardrobe-ota.tar.gz"
|
||||
HAS_BUNDLE=0
|
||||
[ -f "$OTA_DIR/$BUNDLE" ] && HAS_BUNDLE=1
|
||||
|
||||
: "${CF_API_TOKEN:?缺少 CF_API_TOKEN (注册中心 key: r2/CF_API_TOKEN)}"
|
||||
export CLOUDFLARE_API_TOKEN="$CF_API_TOKEN"
|
||||
|
||||
BUCKET="${CF_R2_BUCKET:-store}"
|
||||
CF_R2_PUBLIC_URL="${CF_R2_PUBLIC_URL:-https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev}"
|
||||
|
||||
if ! command -v wrangler >/dev/null 2>&1; then
|
||||
echo "安装 wrangler ..."
|
||||
npm i -g wrangler
|
||||
fi
|
||||
|
||||
if [ "$HAS_BUNDLE" = "1" ]; then
|
||||
echo "上传 ${BUCKET}/ota/${BUNDLE} ..."
|
||||
wrangler r2 object put "${BUCKET}/ota/${BUNDLE}" \
|
||||
--file "$OTA_DIR/$BUNDLE" \
|
||||
--content-type application/gzip \
|
||||
--remote
|
||||
fi
|
||||
|
||||
if [ -f "$OTA_DIR/latest.json" ]; then
|
||||
echo "上传 ${BUCKET}/ota/latest.json ..."
|
||||
wrangler r2 object put "${BUCKET}/ota/latest.json" \
|
||||
--file "$OTA_DIR/latest.json" \
|
||||
--content-type application/json \
|
||||
--remote
|
||||
fi
|
||||
|
||||
echo "已上传."
|
||||
echo "bundle 下载地址: ${CF_R2_PUBLIC_URL}/ota/${BUNDLE}"
|
||||
echo "可将 OTA_PUBLIC_BASE=${CF_R2_PUBLIC_URL} 传给赋予了 check 端点的 server, 让客户端直连 R2 下载 bundle."
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# 把 APK 上传到 Cloudflare R2 (公开桶) via wrangler
|
||||
# 用法: bash scripts/upload-r2.sh <APK_PATH> [OBJECT_KEY]
|
||||
#
|
||||
# 依赖环境变量 (通常来自配置注册中心 CommonExternalService / cloudflare):
|
||||
# CF_API_TOKEN Cloudflare API token (r2/CF_API_TOKEN), wrangler 会从 token 自动解析账号 ID
|
||||
# CF_R2_BUCKET (可选, 默认 store)
|
||||
# CF_R2_PUBLIC_URL (可选, 默认 pub-98ee77ce79984c2cad0dc1c300901158.r2.dev, 仅用于打印下载地址)
|
||||
#
|
||||
# 说明: 与 kit 项目一致, 固定路径覆盖式上传, 保证有一个稳定的"最新"下载地址。
|
||||
set -euo pipefail
|
||||
|
||||
APK_PATH="${1:?用法: bash scripts/upload-r2.sh <APK_PATH> [OBJECT_KEY]}"
|
||||
OBJECT_KEY="${2:-data/android/$(basename "$APK_PATH")}"
|
||||
|
||||
: "${CF_API_TOKEN:?缺少 CF_API_TOKEN (注册中心 key: r2/CF_API_TOKEN)}"
|
||||
export CLOUDFLARE_API_TOKEN="$CF_API_TOKEN"
|
||||
|
||||
BUCKET="${CF_R2_BUCKET:-store}"
|
||||
CF_R2_PUBLIC_URL="${CF_R2_PUBLIC_URL:-https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev}"
|
||||
|
||||
if ! command -v wrangler >/dev/null 2>&1; then
|
||||
echo "安装 wrangler ..."
|
||||
npm i -g wrangler
|
||||
fi
|
||||
|
||||
echo "上传到 R2: ${BUCKET}/${OBJECT_KEY}"
|
||||
wrangler r2 object put "${BUCKET}/${OBJECT_KEY}" \
|
||||
--file "$APK_PATH" \
|
||||
--content-type application/vnd.android.package-archive \
|
||||
--remote
|
||||
|
||||
echo "已上传."
|
||||
echo "公共下载地址: ${CF_R2_PUBLIC_URL}/${OBJECT_KEY}"
|
||||
Reference in New Issue
Block a user