From 9f8659c293b1f4240010c5730ab69538d6ff4728 Mon Sep 17 00:00:00 2001 From: LukeMackin Date: Sun, 19 Jul 2026 01:41:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20OTA=E5=8F=8C=E8=BD=A8=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20=E2=80=94=20shared=E7=89=88=E6=9C=AC=E5=8F=B7+Andro?= =?UTF-8?q?id=20Expo/PackageInstaller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 + 版本号体系 --- .gitignore | 9 ++ package.json | 10 ++ packages/client-android/App.tsx | 72 ++++++++++++ packages/client-android/app.json | 42 +++++++ packages/client-android/manifest.json | 12 ++ .../dev/yuzu/gca/PackageInstallerModule.kt | 55 +++++++++ .../package-installer/expo-module.config.json | 4 + packages/client-android/package.json | 28 +++++ packages/client-android/src/updater/index.ts | 111 ++++++++++++++++++ packages/client-android/tsconfig.json | 16 +++ packages/shared/package.json | 13 ++ packages/shared/src/index.ts | 2 + packages/shared/src/updater/index.ts | 82 +++++++++++++ packages/shared/tsconfig.json | 14 +++ pnpm-workspace.yaml | 2 + 15 files changed, 472 insertions(+) create mode 100644 package.json create mode 100644 packages/client-android/App.tsx create mode 100644 packages/client-android/app.json create mode 100644 packages/client-android/manifest.json create mode 100644 packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt create mode 100644 packages/client-android/modules/package-installer/expo-module.config.json create mode 100644 packages/client-android/package.json create mode 100644 packages/client-android/src/updater/index.ts create mode 100644 packages/client-android/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/updater/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore index 6c2d460..30ec341 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,12 @@ src-tauri/target/ .expo/ .expo-shared/ *.tsbuildinfo + +# Android +android/app/build/ +android/.gradle/ +android/build/ +*.hprof +*.jks +*.keystore +local.properties diff --git a/package.json b/package.json new file mode 100644 index 0000000..bdea7ec --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/client-android/App.tsx b/packages/client-android/App.tsx new file mode 100644 index 0000000..8c4629e --- /dev/null +++ b/packages/client-android/App.tsx @@ -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 ( + + + Yuzu GCA + Global Control Assistant + + {status === 'checking' && } + {message} + + + ); +} + +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', + }, +}); diff --git a/packages/client-android/app.json b/packages/client-android/app.json new file mode 100644 index 0000000..12291d4 --- /dev/null +++ b/packages/client-android/app.json @@ -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" + } + } + } +} diff --git a/packages/client-android/manifest.json b/packages/client-android/manifest.json new file mode 100644 index 0000000..3503790 --- /dev/null +++ b/packages/client-android/manifest.json @@ -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" + } + } +} diff --git a/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt b/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt new file mode 100644 index 0000000..b5ef34a --- /dev/null +++ b/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt @@ -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() + } + } +} diff --git a/packages/client-android/modules/package-installer/expo-module.config.json b/packages/client-android/modules/package-installer/expo-module.config.json new file mode 100644 index 0000000..93f3acd --- /dev/null +++ b/packages/client-android/modules/package-installer/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platform": "android", + "modules": ["PackageInstallerModule"] +} diff --git a/packages/client-android/package.json b/packages/client-android/package.json new file mode 100644 index 0000000..06be123 --- /dev/null +++ b/packages/client-android/package.json @@ -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" + } +} diff --git a/packages/client-android/src/updater/index.ts b/packages/client-android/src/updater/index.ts new file mode 100644 index 0000000..721ef5d --- /dev/null +++ b/packages/client-android/src/updater/index.ts @@ -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 { + 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 }; +} diff --git a/packages/client-android/tsconfig.json b/packages/client-android/tsconfig.json new file mode 100644 index 0000000..12f7715 --- /dev/null +++ b/packages/client-android/tsconfig.json @@ -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"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..341d49d --- /dev/null +++ b/packages/shared/package.json @@ -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" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..00434e8 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +export { parseVersion, compareVersion, checkUpdate, semverGt } from './updater/index'; +export type { GcaVersion, Manifest, ManifestBuild } from './updater/index'; diff --git a/packages/shared/src/updater/index.ts b/packages/shared/src/updater/index.ts new file mode 100644 index 0000000..4977116 --- /dev/null +++ b/packages/shared/src/updater/index.ts @@ -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; +} + +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; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..964fbee --- /dev/null +++ b/packages/shared/tsconfig.json @@ -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"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..18ec407 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*'