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:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
10
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
72
packages/client-android/App.tsx
Normal file
72
packages/client-android/App.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
42
packages/client-android/app.json
Normal file
42
packages/client-android/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
packages/client-android/manifest.json
Normal file
12
packages/client-android/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"platform": "android",
|
||||
"modules": ["PackageInstallerModule"]
|
||||
}
|
||||
28
packages/client-android/package.json
Normal file
28
packages/client-android/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
111
packages/client-android/src/updater/index.ts
Normal file
111
packages/client-android/src/updater/index.ts
Normal 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 OTA(expo-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 };
|
||||
}
|
||||
16
packages/client-android/tsconfig.json
Normal file
16
packages/client-android/tsconfig.json
Normal 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"]
|
||||
}
|
||||
13
packages/shared/package.json
Normal file
13
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseVersion, compareVersion, checkUpdate, semverGt } from './updater/index';
|
||||
export type { GcaVersion, Manifest, ManifestBuild } from './updater/index';
|
||||
82
packages/shared/src/updater/index.ts
Normal file
82
packages/shared/src/updater/index.ts
Normal 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;
|
||||
}
|
||||
14
packages/shared/tsconfig.json
Normal file
14
packages/shared/tsconfig.json
Normal 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
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
Reference in New Issue
Block a user