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/*'