feat(android): OTA双轨更新 — shared版本号+Android Expo/PackageInstaller

- packages/shared: 版本号解析 vX.Y.Z-dav-{client}-{build} + manifest远程检查
- packages/client-android: Expo52 + expo-updates JS bundle OTA
- PackageInstallerModule.kt: APK静默安装原生模块(~60行Kotlin)
- monorepo: pnpm workspace + 版本号体系
This commit is contained in:
LukeMackin
2026-07-19 01:41:36 +08:00
parent b12aef0375
commit 9f8659c293
15 changed files with 472 additions and 0 deletions

9
.gitignore vendored
View File

@@ -36,3 +36,12 @@ src-tauri/target/
.expo/
.expo-shared/
*.tsbuildinfo
# Android
android/app/build/
android/.gradle/
android/build/
*.hprof
*.jks
*.keystore
local.properties

10
package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "yuzu-gca",
"private": true,
"version": "0.1.0",
"description": "Global Control Assistant — 全局控制助手",
"scripts": {
"android": "pnpm --filter @yuzu-gca/client-android start",
"build:android": "pnpm --filter @yuzu-gca/client-android build"
}
}

View File

@@ -0,0 +1,72 @@
import { StatusBar } from 'expo-status-bar';
import { useEffect, useState } from 'react';
import { StyleSheet, Text, View, ActivityIndicator } from 'react-native';
import { onAppStartCheckUpdate } from './src/updater/index';
export default function App() {
const [status, setStatus] = useState<'checking' | 'up-to-date' | 'updating' | 'error'>('checking');
const [message, setMessage] = useState('正在检查更新...');
useEffect(() => {
(async () => {
try {
const result = await onAppStartCheckUpdate();
if (result.jsUpdate) {
setStatus('updating');
setMessage('发现 JS 更新,正在下载...(下次启动生效)');
} else if (result.apkUpdate) {
setStatus('updating');
setMessage('安装包已就绪,重启后生效');
} else {
setStatus('up-to-date');
setMessage('已是最新版本');
}
} catch (e: any) {
setStatus('error');
setMessage(`更新检查失败: ${e.message}`);
}
})();
}, []);
return (
<View style={styles.container}>
<StatusBar style="light" />
<Text style={styles.title}>Yuzu GCA</Text>
<Text style={styles.subtitle}>Global Control Assistant</Text>
<View style={styles.statusBar}>
{status === 'checking' && <ActivityIndicator color="#3b82f6" />}
<Text style={styles.statusText}>{message}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0f172a',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
title: {
fontSize: 28,
fontWeight: '800',
color: '#e2e8f0',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#94a3b8',
marginBottom: 32,
},
statusBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
statusText: {
fontSize: 14,
color: '#94a3b8',
},
});

View File

@@ -0,0 +1,42 @@
{
"expo": {
"name": "YuzuGCA",
"slug": "yuzu-gca",
"version": "0.1.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"backgroundColor": "#0f172a"
},
"ios": {
"supportsTablet": false,
"bundleIdentifier": "dev.yuzu.gca"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#0f172a"
},
"package": "dev.yuzu.gca",
"allowBackup": false,
"permissions": [
"REQUEST_INSTALL_PACKAGES"
]
},
"plugins": [
"expo-updates"
],
"updates": {
"enabled": true,
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0,
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json"
},
"extra": {
"eas": {
"projectId": "yuzu-gca-android"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"version": "0.1.0",
"builds": {
"android": {
"tag": "v0.1.0-dav-android-1",
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-1/gca-0.1.0.apk",
"sha256": "PLACEHOLDER",
"size": 0,
"minVersion": "0.1.0"
}
}
}

View File

@@ -0,0 +1,55 @@
package dev.yuzu.gca
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
import java.io.FileInputStream
class PackageInstallerModule : Module() {
override fun definition() = ModuleDefinition {
Name("PackageInstallerModule")
AsyncFunction("installApk") { filePath: String ->
installApk(filePath)
}
}
private fun installApk(filePath: String) {
val context: Context = appContext.reactContext ?: return
val apkFile = File(filePath)
if (!apkFile.exists()) throw Exception("APK file not found: $filePath")
val packageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
val sessionId = packageInstaller.createSession(sessionParams)
val session = packageInstaller.openSession(sessionId)
try {
FileInputStream(apkFile).use { input ->
session.openWrite("package", 0, apkFile.length()).use { output ->
input.copyTo(output)
}
session.fsync(output)
}
// 安装完成后发送通知,用户点击即重启
val intent = Intent(context, context.javaClass)
intent.action = Intent.ACTION_MAIN
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
session.commit(pendingIntent.intentSender)
} finally {
session.close()
}
}
}

View File

@@ -0,0 +1,4 @@
{
"platform": "android",
"modules": ["PackageInstallerModule"]
}

View File

@@ -0,0 +1,28 @@
{
"name": "@yuzu-gca/client-android",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"build": "eas build -p android --profile preview",
"export": "expo export --platform android",
"prebuild": "expo prebuild --platform android"
},
"dependencies": {
"@yuzu-gca/shared": "workspace:*",
"expo": "~52.0.0",
"expo-constants": "~17.0.0",
"expo-device": "~7.0.0",
"expo-file-system": "~18.0.0",
"expo-status-bar": "~2.0.0",
"expo-updates": "~26.0.0",
"react": "18.3.1",
"react-native": "0.76.5"
},
"devDependencies": {
"@types/react": "~18.3.0",
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,111 @@
import { parseVersion, checkUpdate, semverGt } from '@yuzu-gca/shared';
import Constants from 'expo-constants';
import * as Device from 'expo-device';
import * as FileSystem from 'expo-file-system';
import { NativeModules, Platform } from 'react-native';
// ============================================================
// 1. 版本号读取
// ============================================================
/** 从 app.json + expo-updates 获取本地版本 */
export function getLocalVersion(): string {
return Constants.expoConfig?.version ?? '0.1.0';
}
export function getLocalBuildTag(): string {
const v = getLocalVersion();
return `v${v}-dav-android-${Constants.expoConfig?.ios?.buildNumber ?? Constants.expoConfig?.android?.versionCode ?? 1}`;
}
// ============================================================
// 2. JS Bundle OTAexpo-updates 自动处理)
// ============================================================
/** 手动触发 OTA 检查expo-updates 已自动启动检查,此方法用于设置页手动刷新) */
export async function checkJsBundleUpdate(): Promise<{
hasUpdate: boolean;
remoteVersion: string;
}> {
const localVersion = getLocalVersion();
const localTag = getLocalBuildTag();
const result = await checkUpdate('android', parseVersion(localTag)!);
if (!result.shouldUpdate || !result.remoteVersion) {
return { hasUpdate: false, remoteVersion: localVersion };
}
// expo-updates 会在检测到新 manifest 时自动下载 bundle
// 这里只返回状态;实际下载由 expo-updates 后台完成
return {
hasUpdate: semverGt(result.remoteVersion.raw.replace(/^v/, '').split('-')[0], localVersion),
remoteVersion: result.remoteVersion.raw,
};
}
// ============================================================
// 3. APK 静默安装(大更新)
// ============================================================
const { PackageInstallerModule } = NativeModules;
/** 后台下载 APK 并通过 PackageInstaller 静默安装 */
export async function downloadAndInstallApk(apkUrl: string, sha256: string): Promise<void> {
const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`;
const download = FileSystem.createDownloadResumable(
apkUrl,
localPath,
{},
(progress) => {
const pct = (progress.totalBytesWritten / progress.totalBytesExpectedToWrite) * 100;
console.log(`[OTA] 下载进度: ${pct.toFixed(0)}%`);
},
);
const result = await download.downloadAsync();
if (!result?.uri) throw new Error('APK 下载失败');
console.log(`[OTA] 下载完成: ${result.uri}`);
// 调用原生 PackageInstaller 安装
if (Platform.OS === 'android' && PackageInstallerModule) {
await PackageInstallerModule.installApk(result.uri);
console.log('[OTA] APK 已提交安装,用户下次重启生效');
} else {
throw new Error('PackageInstaller 原生模块不可用');
}
}
// ============================================================
// 4. 启动时一键检查JS OTA + APK 更新)
// ============================================================
export async function onAppStartCheckUpdate(): Promise<{
jsUpdate: boolean;
apkUpdate: boolean;
}> {
const localTag = getLocalBuildTag();
const local = parseVersion(localTag);
if (!local) return { jsUpdate: false, apkUpdate: false };
const result = await checkUpdate('android', local);
if (!result.shouldUpdate || !result.build) {
return { jsUpdate: false, apkUpdate: false };
}
// JS OTA: expo-updates 自动处理,无需手动干预
const jsUpdate = semverGt(
result.build.tag.replace(/^v/, '').split('-')[0],
localTag.replace(/^v/, '').split('-')[0],
);
// APK 大更新:需要下载 + PackageInstaller
if (result.build.size > 5_000_000) {
// >5MB = APK 安装包
await downloadAndInstallApk(result.build.url, result.build.sha256);
return { jsUpdate: false, apkUpdate: true };
}
return { jsUpdate, apkUpdate: false };
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"@yuzu-gca/shared": ["../shared/src"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,13 @@
{
"name": "@yuzu-gca/shared",
"version": "0.1.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,2 @@
export { parseVersion, compareVersion, checkUpdate, semverGt } from './updater/index';
export type { GcaVersion, Manifest, ManifestBuild } from './updater/index';

View File

@@ -0,0 +1,82 @@
/** 版本号解析 vX.Y.Z-dav-{client}-{build} */
export interface GcaVersion {
major: number;
minor: number;
patch: number;
client: 'desktop' | 'android' | 'cli';
build: number;
raw: string;
}
export interface Manifest {
version: string;
builds: Record<string, ManifestBuild>;
}
export interface ManifestBuild {
tag: string;
url: string;
sha256: string;
size: number;
minVersion?: string;
}
const MANIFEST_URL = 'https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/manifest.json';
const VERSION_RE = /^v(\d+)\.(\d+)\.(\d+)-dav-(desktop|android|cli)-(\d+)$/;
export function parseVersion(raw: string): GcaVersion | null {
const m = raw.trim().match(VERSION_RE);
if (!m) return null;
return {
major: parseInt(m[1], 10),
minor: parseInt(m[2], 10),
patch: parseInt(m[3], 10),
client: m[4] as GcaVersion['client'],
build: parseInt(m[5], 10),
raw,
};
}
export function compareVersion(a: GcaVersion, b: GcaVersion): number {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
if (a.patch !== b.patch) return a.patch - b.patch;
return a.build - b.build;
}
/** 调用方传入本地版本号,返回是否需要更新以及目标 manifest entry */
export async function checkUpdate(
client: GcaVersion['client'],
localVersion: GcaVersion,
manifestUrl = MANIFEST_URL,
): Promise<{ shouldUpdate: boolean; build: ManifestBuild | null; remoteVersion: GcaVersion | null }> {
try {
const resp = await fetch(manifestUrl);
if (!resp.ok) return { shouldUpdate: false, build: null, remoteVersion: null };
const manifest: Manifest = await resp.json();
const build = manifest.builds[client];
if (!build) return { shouldUpdate: false, build: null, remoteVersion: null };
const remoteVersion = parseVersion(build.tag);
if (!remoteVersion) return { shouldUpdate: false, build: null, remoteVersion: null };
return {
shouldUpdate: compareVersion(remoteVersion, localVersion) > 0,
build,
remoteVersion,
};
} catch {
return { shouldUpdate: false, build: null, remoteVersion: null };
}
}
/** SemVer 比较(用于 expo-updates manifest */
export function semverGt(a: string, b: string): boolean {
const [aM, am, aP] = a.split('.').map(Number);
const [bM, bm, bP] = b.split('.').map(Number);
if (aM !== bM) return aM > bM;
if (am !== bm) return am > bm;
return aP > bP;
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- 'packages/*'