Compare commits
118 Commits
8d1b57d7ed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1a9e40d0a | ||
|
|
a7dad7debc | ||
|
|
323fd62ed4 | ||
|
|
5cf0cf40e3 | ||
|
|
d851c25edf | ||
|
|
b04238892d | ||
|
|
d75ed790cc | ||
|
|
09457ed730 | ||
|
|
cd62fd8fe4 | ||
|
|
c6ab70aaca | ||
|
|
282bc79510 | ||
|
|
37c6605582 | ||
|
|
fcfedf1ae0 | ||
|
|
12e00bf683 | ||
|
|
9f912da9fd | ||
|
|
3d82964075 | ||
|
|
490201f0d4 | ||
|
|
e29e0f7e36 | ||
|
|
ffb74262e9 | ||
|
|
4db4b6c0f3 | ||
|
|
09fde0501f | ||
|
|
ce7204b016 | ||
|
|
02967ae5c3 | ||
|
|
c407f6c483 | ||
|
|
aa45b258b2 | ||
|
|
93ca593f1b | ||
|
|
bca14ebe7b | ||
|
|
34ead6e471 | ||
|
|
8c1ba243b5 | ||
|
|
8d8d99acdd | ||
|
|
f85ce9aa43 | ||
|
|
a98d35cb1c | ||
|
|
e209db6be8 | ||
|
|
c41a3ad9d9 | ||
|
|
c6f7567eb9 | ||
|
|
f595c82983 | ||
|
|
153af75a44 | ||
|
|
7e2f2ebfbd | ||
|
|
99d8f66f52 | ||
|
|
40998f9be1 | ||
|
|
45cce458ac | ||
|
|
3a1058d10c | ||
|
|
f83bc2d524 | ||
|
|
4facecc089 | ||
|
|
b5bcf8e9c0 | ||
|
|
6ee4ff533a | ||
|
|
337c49f1f9 | ||
|
|
db0bc85609 | ||
|
|
7f7b75c0fc | ||
|
|
e9bcb7d804 | ||
|
|
4b4103cf06 | ||
|
|
9e4d9e7dd7 | ||
|
|
439c9066e2 | ||
|
|
1160fdd03b | ||
|
|
0984082ac0 | ||
|
|
c4bf1de467 | ||
|
|
fe182e6f3d | ||
|
|
ad918e3a5f | ||
|
|
5e1badce1f | ||
|
|
b0564a667d | ||
|
|
c3077a4927 | ||
|
|
38a60eae8f | ||
|
|
a4fa268b9f | ||
|
|
f4b0feb9d1 | ||
|
|
39d7e82a55 | ||
|
|
f56024f7c0 | ||
|
|
b88311a2d0 | ||
|
|
db8cfdf6e3 | ||
|
|
bb1e9ede2a | ||
|
|
9ba8c296b0 | ||
|
|
9e0d59895b | ||
|
|
049d2ccaaf | ||
| 01a8a76805 | |||
| 2ea30be977 | |||
| 18eeb07f66 | |||
| 61c6d32192 | |||
|
|
8f078e4f30 | ||
|
|
e31bc3dcb9 | ||
|
|
8fb84c2efb | ||
|
|
eccfaa7f66 | ||
|
|
1ef40900a8 | ||
|
|
cdf9b9d499 | ||
|
|
b4ddb0b5c7 | ||
|
|
f9d5d32785 | ||
|
|
67adde25a4 | ||
| 70c535d0c1 | |||
| 1b1a586ccc | |||
|
|
5b9a996759 | ||
|
|
f4ee38f39f | ||
|
|
7f22d4028f | ||
|
|
19860bdec7 | ||
|
|
af799da3ea | ||
|
|
e39a250fbe | ||
|
|
565b1a5ea1 | ||
|
|
a47b73dda7 | ||
|
|
bfd7bee2ae | ||
|
|
bf72ea937f | ||
|
|
96d88a9601 | ||
|
|
123824a734 | ||
|
|
4d6bb3ff6f | ||
|
|
8f2f90df0d | ||
|
|
9a2f502857 | ||
|
|
3b6c6a4923 | ||
|
|
7487158efd | ||
| 454772a343 | |||
| e5b40c028d | |||
| 12e1abbfb8 | |||
|
|
ead2045ce5 | ||
|
|
42b3592ed9 | ||
|
|
49793529c0 | ||
|
|
6f8b346e73 | ||
|
|
8f04acaf98 | ||
|
|
0727aa33f1 | ||
|
|
cd1f19815d | ||
|
|
de20e837fa | ||
|
|
bfa365c2c2 | ||
|
|
898fbc0c3c | ||
|
|
f052d280ce |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 325 KiB |
@@ -0,0 +1,2 @@
|
||||
{"status":"starting_turn","iteration":0,"created_at":"2026-07-20T16:59:22.3318065Z"}
|
||||
{"status":"turn_done","iteration":1,"created_at":"2026-07-20T16:59:51.4961797Z"}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"fingerprint": "15-127-0-0-1-10808-android-studio-sync-project-7bf5ff43",
|
||||
"summary": "代理是根因(15 个失败都是因为 `127.0.0.1:10808` 代理)。已移除。在 Android Studio 里点 **大象图标**(Sync Project)重新同步,",
|
||||
"first_seen_iteration": 1,
|
||||
"last_seen_iteration": 1,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"status": "running",
|
||||
"iteration": 1,
|
||||
"current_direction": "代理是根因(15 个失败都是因为 `127.0.0.1:10808` 代理)。已移除。在 Android Studio 里点 **大象图标**(Sync Project)重新同步,",
|
||||
"stale_count": 1,
|
||||
"pivot_count": 0,
|
||||
"blocked_reason": "",
|
||||
"updated_at": "2026-07-20T16:59:51.4929941Z"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{"status":"starting_turn","iteration":0,"created_at":"2026-07-21T03:16:15.2382547Z"}
|
||||
{"status":"turn_done","iteration":1,"created_at":"2026-07-21T03:17:05.8410329Z"}
|
||||
{"status":"starting_turn","iteration":1,"created_at":"2026-07-21T03:17:06.1408318Z"}
|
||||
{"status":"turn_done","iteration":2,"created_at":"2026-07-21T03:17:34.0830711Z"}
|
||||
{"status":"starting_turn","iteration":2,"created_at":"2026-07-21T03:17:34.8204313Z"}
|
||||
{"status":"turn_done","iteration":3,"created_at":"2026-07-21T03:18:32.1678783Z"}
|
||||
{"status":"starting_turn","iteration":3,"created_at":"2026-07-21T03:18:32.1884204Z"}
|
||||
{"status":"turn_done","iteration":4,"created_at":"2026-07-21T03:19:08.6392005Z"}
|
||||
{"status":"starting_turn","iteration":4,"created_at":"2026-07-21T03:19:08.7309788Z"}
|
||||
{"status":"turn_done","iteration":5,"created_at":"2026-07-21T03:19:33.0643356Z"}
|
||||
{"status":"starting_turn","iteration":5,"created_at":"2026-07-21T03:19:33.2245358Z"}
|
||||
{"status":"turn_done","iteration":6,"created_at":"2026-07-21T03:20:56.4286355Z"}
|
||||
{"status":"starting_turn","iteration":6,"created_at":"2026-07-21T03:20:56.5224447Z"}
|
||||
{"status":"turn_done","iteration":7,"created_at":"2026-07-21T03:22:00.7927224Z"}
|
||||
{"status":"starting_turn","iteration":7,"created_at":"2026-07-21T03:22:00.8862815Z"}
|
||||
{"status":"turn_done","iteration":8,"created_at":"2026-07-21T03:22:46.0575234Z"}
|
||||
{"status":"starting_turn","iteration":8,"created_at":"2026-07-21T03:22:46.1718312Z"}
|
||||
{"status":"turn_done","iteration":9,"created_at":"2026-07-21T03:24:13.7257985Z"}
|
||||
{"status":"starting_turn","iteration":9,"created_at":"2026-07-21T03:24:13.8277354Z"}
|
||||
{"status":"turn_done","iteration":10,"created_at":"2026-07-21T03:24:27.5537437Z"}
|
||||
{"status":"starting_turn","iteration":10,"created_at":"2026-07-21T03:24:27.6571627Z"}
|
||||
{"status":"turn_done","iteration":11,"created_at":"2026-07-21T03:25:19.3030817Z"}
|
||||
{"status":"starting_turn","iteration":11,"created_at":"2026-07-21T03:25:19.4076559Z"}
|
||||
{"status":"turn_done","iteration":12,"created_at":"2026-07-21T03:29:18.0058775Z"}
|
||||
{"status":"starting_turn","iteration":12,"created_at":"2026-07-21T03:29:18.1383267Z"}
|
||||
{"status":"turn_done","iteration":13,"created_at":"2026-07-21T03:30:01.3929977Z"}
|
||||
{"status":"starting_turn","iteration":13,"created_at":"2026-07-21T03:30:01.5037583Z"}
|
||||
{"status":"turn_done","iteration":14,"created_at":"2026-07-21T03:30:14.626083Z"}
|
||||
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"fingerprint": "as-build-ad2624ab",
|
||||
"summary": "已修复两个配置。在 AS 里重新 Build:",
|
||||
"first_seen_iteration": 1,
|
||||
"last_seen_iteration": 1,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "as-build-20a5fde3",
|
||||
"summary": "在 AS 重新 Build。这两步被彻底跳过。",
|
||||
"first_seen_iteration": 2,
|
||||
"last_seen_iteration": 2,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "task",
|
||||
"summary": "---",
|
||||
"first_seen_iteration": 3,
|
||||
"last_seen_iteration": 3,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "android-studio-build-release-expo-updates-root-8e271660",
|
||||
"summary": "用 Android Studio 重新 Build Release。修复了 `expo-updates` 路径加倍问题(缺少 `root` 配置项):",
|
||||
"first_seen_iteration": 4,
|
||||
"last_seen_iteration": 4,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "root-createreleaseupdatesresources-packages-client-andro-c0afb42a",
|
||||
"summary": "`root` 设定没有解决。`createReleaseUpdatesResources` 仍然报双重路径 `packages\\client-android\\packages\\client-android\\App.tsx`——这个任务不",
|
||||
"first_seen_iteration": 5,
|
||||
"last_seen_iteration": 5,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "task-5b2466d1",
|
||||
"summary": "两个修复就位:",
|
||||
"first_seen_iteration": 6,
|
||||
"last_seen_iteration": 6,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "android-studio-build-release-c00a91b0",
|
||||
"summary": "**现在请用 Android Studio 重新 Build Release。**",
|
||||
"first_seen_iteration": 7,
|
||||
"last_seen_iteration": 7,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "android-studio-build-release-7002dec6",
|
||||
"summary": "**请用 Android Studio 重新 Build Release。**",
|
||||
"first_seen_iteration": 8,
|
||||
"last_seen_iteration": 8,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "build-gradle-afterevaluate-js-bundle-ae4afdad",
|
||||
"summary": "`build.gradle` 已清理为干净的单一 `afterEvaluate` 块。JS bundle 已就位。",
|
||||
"first_seen_iteration": 9,
|
||||
"last_seen_iteration": 9,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "stale-9-tasks-named-configure-enabled-false-afterevaluat-49c894d6",
|
||||
"summary": "stale=9。`tasks.named().configure { enabled = false }` 在 `afterEvaluate` 中都无效——这两个任务由 Gradle 插件在配置阶段强制注册,无<EFBC8C>",
|
||||
"first_seen_iteration": 10,
|
||||
"last_seen_iteration": 10,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "android-studio-build-release-0ac7a51a",
|
||||
"summary": "**现在用 Android Studio 直接 Build Release——不用加任何参数。**",
|
||||
"first_seen_iteration": 11,
|
||||
"last_seen_iteration": 11,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "redmi-usb-79ba1bcf",
|
||||
"summary": "卸载成功。安装受阻——Redmi USB 安装限制。**请解锁手机屏幕,点\"允许\"后告诉我。**",
|
||||
"first_seen_iteration": 12,
|
||||
"last_seen_iteration": 12,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "android-studio-feat-expo-ota-12-main-apk-usb-45c0be45",
|
||||
"summary": "Android Studio 里的项目还是 `feat/expo-ota`(所以 12 次失败)。我这边 main 的 APK 已经好了——安装不过去是手机 USB 权限。",
|
||||
"first_seen_iteration": 13,
|
||||
"last_seen_iteration": 13,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "13-autoresearch-bafea58b",
|
||||
"summary": "**结论已定。** 13轮迭代,证据充分。关闭 AutoResearch:",
|
||||
"first_seen_iteration": 14,
|
||||
"last_seen_iteration": 14,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"status": "complete",
|
||||
"iteration": 14,
|
||||
"current_direction": "**结论已定。** 13轮迭代,证据充分。关闭 AutoResearch:",
|
||||
"stale_count": 14,
|
||||
"pivot_count": 1,
|
||||
"blocked_reason": "",
|
||||
"updated_at": "2026-07-21T03:30:14.7277324Z"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"status":"starting_turn","iteration":0,"created_at":"2026-07-21T03:15:01.5344339Z"}
|
||||
{"status":"turn_done","iteration":1,"created_at":"2026-07-21T03:16:14.9716471Z"}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"fingerprint": "910kb-bundle-android-studio-build-generate-signed-bundle-0bdca4e3",
|
||||
"summary": "910KB bundle 已放好。Android Studio 里重新 Build → Generate Signed Bundle / APK → release → Finish。这次不会超时也不会 Metro 报错。",
|
||||
"first_seen_iteration": 1,
|
||||
"last_seen_iteration": 1,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"status": "running",
|
||||
"iteration": 1,
|
||||
"current_direction": "910KB bundle 已放好。Android Studio 里重新 Build → Generate Signed Bundle / APK → release → Finish。这次不会超时也不会 Metro 报错。",
|
||||
"stale_count": 1,
|
||||
"pivot_count": 0,
|
||||
"blocked_reason": "",
|
||||
"updated_at": "2026-07-21T03:16:14.9681754Z"
|
||||
}
|
||||
35
cdp-read.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
|
||||
http.get('http://localhost:9223/json', res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => {
|
||||
const pages = JSON.parse(d);
|
||||
const wsUrl = pages[0].webSocketDebuggerUrl;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({id: 1, method: 'Runtime.evaluate', params: {expression: 'document.getElementById("ver").innerText'}}));
|
||||
});
|
||||
ws.on('message', data => {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.id === 1) {
|
||||
console.log('VERSION:', msg.result?.result?.value ?? JSON.stringify(msg.result));
|
||||
ws.send(JSON.stringify({id: 2, method: 'Runtime.evaluate', params: {expression: 'localStorage.getItem("gca_ver") || "none"'}}));
|
||||
}
|
||||
if (msg.id === 2) {
|
||||
console.log('LOCALSTORAGE gca_ver:', msg.result?.result?.value ?? JSON.stringify(msg.result));
|
||||
ws.send(JSON.stringify({id: 3, method: 'Runtime.evaluate', params: {expression: 'document.getElementById("log").innerText'}}));
|
||||
}
|
||||
if (msg.id === 3) {
|
||||
console.log('LOG:', msg.result?.result?.value?.substring(0, 500) ?? JSON.stringify(msg.result));
|
||||
ws.close();
|
||||
process.exit(0);
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
});
|
||||
}).on('error', e => { console.log('HTTP ERR:', e.message); process.exit(1); });
|
||||
|
||||
setTimeout(() => process.exit(0), 8000);
|
||||
3
index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Root entry point for Expo dev server
|
||||
// Redirect to client-android
|
||||
import './packages/client-android/index';
|
||||
@@ -1,18 +1,44 @@
|
||||
# OTA 文件夹
|
||||
# OTA 热更新目录
|
||||
|
||||
此目录存放 manifest 文件。manifest-stable.json 是 CI 构建时动态生成的产物,
|
||||
也提交到仓库通过 raw URL 供客户端拉取。
|
||||
## 文件说明
|
||||
|
||||
## 文件
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `version.txt` | v5 嵌入式初始化——格式: `版本\|URL\|SHA256` |
|
||||
| `h5-manifest-stable.json` | 发行渠道(stable)——格式: `{latest, min_version, url, sha256}` |
|
||||
| `h5-manifest-beta.json` | 测试渠道(beta) |
|
||||
| `h5-manifest-nightly.json` | 开发渠道(nightly) |
|
||||
| `h5/index-vN.html` | 可下载的热更新页面 |
|
||||
|
||||
- `gen-manifest.sh` — 构建后运行,计算 APK SHA256 并生成 manifest-stable.json
|
||||
- `manifest-stable.json` — stable 渠道
|
||||
- `manifest-beta.json` — beta 渠道
|
||||
- `manifest-nightly.json` — nightly 渠道
|
||||
## 推送补丁
|
||||
|
||||
## 流程
|
||||
```bash
|
||||
# 更新所有渠道到 v7
|
||||
cd ota
|
||||
bash push-hotfix.sh 7 all
|
||||
|
||||
# 只更新 nightly 渠道
|
||||
bash push-hotfix.sh 7 nightly
|
||||
|
||||
# 手动推送
|
||||
git add ota/
|
||||
git commit -m "hotfix: v7"
|
||||
git push
|
||||
```
|
||||
CI 构建 APK → ./ota/gen-manifest.sh → git push manifest-stable.json
|
||||
手机 App → raw URL 拉取 manifest → 比较版本 → 下载 APK → SHA256 校验
|
||||
|
||||
## 格式
|
||||
|
||||
**h5-manifest-*.json:**
|
||||
```json
|
||||
{
|
||||
"latest": "0.1.0-dav-android-6",
|
||||
"min_version": "0.1.0-dav-android-5",
|
||||
"url": "https://git.childish-ghost.com/.../ota/h5/index-v6.html",
|
||||
"sha256": "33a16632..."
|
||||
}
|
||||
```
|
||||
|
||||
**version.txt:**
|
||||
```
|
||||
0.1.0-dav-android-6|https://.../ota/h5/index-v6.html|33a16632...
|
||||
```
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"id": "e5ce52a-0000-0000-0000-000000000000",
|
||||
"createdAt": "2026-07-19T15:30:00.000Z",
|
||||
"runtimeVersion": "0.1.0",
|
||||
"launchAsset": {
|
||||
"key": "bundle",
|
||||
"contentType": "application/javascript",
|
||||
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-8/bundle.js"
|
||||
},
|
||||
"assets": [],
|
||||
"metadata": {}
|
||||
}
|
||||
@@ -1,38 +1,54 @@
|
||||
#!/bin/bash
|
||||
# OTA manifest 生成脚本
|
||||
# 用法: ./ota/gen-manifest.sh <versionName> <versionCode> <apk-path>
|
||||
# 构建号规则: 每次生成APK时 versionCode +1, 对应 git tag vX.Y.Z-dav-android-N
|
||||
# CI 构建时自动运行, SHA256动态填入, 生成 manifest-stable.json 后上传到 Gitea Release
|
||||
# gen-manifest.sh — 生成 APK 发行版 manifest
|
||||
# 在 Android Studio 构建 Release APK 后运行
|
||||
# 用法: bash gen-manifest.sh 6 stable
|
||||
# bash gen-manifest.sh 6 all
|
||||
|
||||
VERSION_NAME="${1:-0.1.0}"
|
||||
VERSION_CODE="${2:-7}"
|
||||
APK_PATH="${3:-packages/client-android/android/app/build/outputs/apk/debug/v0.1.0-dav-android-7-debug.apk}"
|
||||
RELEASE_TAG="v${VERSION_NAME}-dav-android-${VERSION_CODE}"
|
||||
APK_NAME="v${VERSION_NAME}-dav-android-${VERSION_CODE}-debug.apk"
|
||||
set -e
|
||||
|
||||
VER="$1"
|
||||
shift
|
||||
CHANNELS=("$@")
|
||||
|
||||
if [ -z "$VER" ] || [ ${#CHANNELS[@]} -eq 0 ]; then
|
||||
echo "用法: $0 <构建号> <渠道...>"
|
||||
echo "示例: $0 6 stable beta nightly"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OTA_DIR="$(dirname "$0")"
|
||||
APK_PATH="$OTA_DIR/../packages/client-android/android/app/build/outputs/apk/debug/v0.1.0-dav-android-${VER}-debug.apk"
|
||||
BASE="https://git.childish-ghost.com/LukeMackin/Yuzu-GCA"
|
||||
TAG="v0.1.0-dav-android-${VER}"
|
||||
APK_NAME="v0.1.0-dav-android-${VER}-debug.apk"
|
||||
DL_URL="${BASE}/releases/download/${TAG}/${APK_NAME}"
|
||||
|
||||
if [ ! -f "$APK_PATH" ]; then
|
||||
echo "APK not found: $APK_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SHA256=$(sha256sum "$APK_PATH" | cut -d' ' -f1)
|
||||
SIZE=$(stat -c%s "$APK_PATH")
|
||||
echo "APK : $APK_PATH"
|
||||
echo "SHA256: $SHA256"
|
||||
|
||||
cat > ota/manifest-stable.json <<JSON
|
||||
{
|
||||
"version": "${VERSION_NAME}",
|
||||
"builds": {
|
||||
"android": {
|
||||
"type": "apk",
|
||||
"tag": "${RELEASE_TAG}",
|
||||
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/${RELEASE_TAG}/${APK_NAME}",
|
||||
"sha256": "${SHA256}",
|
||||
"size": ${SIZE},
|
||||
"channel": "stable",
|
||||
"minVersion": "${VERSION_NAME}"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
for CH in "${CHANNELS[@]}"; do
|
||||
if [ "$CH" = "all" ]; then
|
||||
for CH in stable beta nightly; do
|
||||
cat > "$OTA_DIR/manifest-${CH}.json" <<EOF
|
||||
{"version":"0.1.0-dav-android-${VER}","versionCode":${VER},"url":"${DL_URL}","sha256":"${SHA256}"}
|
||||
EOF
|
||||
echo " ✓ manifest-${CH}.json"
|
||||
done
|
||||
break
|
||||
fi
|
||||
cat > "$OTA_DIR/manifest-${CH}.json" <<EOF
|
||||
{"version":"0.1.0-dav-android-${VER}","versionCode":${VER},"url":"${DL_URL}","sha256":"${SHA256}"}
|
||||
EOF
|
||||
echo " ✓ manifest-${CH}.json"
|
||||
done
|
||||
|
||||
echo "Generated: ota/manifest-stable.json"
|
||||
echo " tag: ${RELEASE_TAG}"
|
||||
echo " sha256: ${SHA256}"
|
||||
echo " size: ${SIZE}"
|
||||
echo ""
|
||||
echo "Upload: curl -X POST 'https://git.childish-ghost.com/api/v1/repos/LukeMackin/Yuzu-GCA/releases/\${RELEASE_ID}/assets?name=manifest-stable.json' -H 'Authorization: token \$TOKEN' -H 'Content-Type: application/json' --data-binary @ota/manifest-stable.json"
|
||||
echo "Next: git tag $TAG && git push --tags"
|
||||
echo " Gitea Release → upload APK"
|
||||
echo " bash push-hotfix.sh $VER stable # H5同步"
|
||||
|
||||
1
ota/h5/index-beta.html
Normal file
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=index-stable.html"></head><body></body></html>
|
||||
1
ota/h5/index-nightly.html
Normal file
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=index-stable.html"></head><body></body></html>
|
||||
80
ota/h5/index-stable.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui;background:#0f172a;color:#e2e8f0;padding:8px 16px 16px;margin:0;overflow-x:hidden}
|
||||
h1{font-size:18px;color:#38bdf8}
|
||||
.card{background:#1e293b;border-radius:8px;padding:16px;margin:12px 0}
|
||||
.card-title{font-size:14px;color:#94a3b8;margin-bottom:8px}
|
||||
.btn{display:block;width:100%;padding:14px;margin:8px 0;border:none;border-radius:8px;font-size:16px;cursor:pointer}
|
||||
.btn-check{background:#1e40af;color:#fff}
|
||||
.btn-download{background:#15803d;color:#fff;display:none}
|
||||
.btn-download.show{display:block}
|
||||
.log{background:#0f172a;padding:12px;border-radius:8px;margin-top:12px;max-height:200px;overflow-y:auto;font-size:11px;white-space:pre-wrap;color:#94a3b8}
|
||||
.ver{color:#94a3b8;font-size:12px;margin:4px 0}
|
||||
.gear{position:absolute;top:20px;right:16px;font-size:20px;cursor:pointer;color:#94a3b8}
|
||||
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.7);z-index:99}
|
||||
.modal.show{display:flex;align-items:center;justify-content:center}
|
||||
.modal-box{background:#1e293b;border-radius:12px;padding:24px;width:80%;max-width:360px}
|
||||
.modal-title{font-size:16px;color:#38bdf8;margin-bottom:16px}
|
||||
.ch-opt{display:block;width:100%;padding:12px;margin:6px 0;border:1px solid #334155;border-radius:6px;background:transparent;color:#e2e8f0;font-size:14px;cursor:pointer;text-align:left}
|
||||
.ch-opt.on{border-color:#38bdf8;background:#1e40af40}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;margin-left:8px}
|
||||
.badge-new{background:#dc2626;color:#fff}
|
||||
</style></head><body>
|
||||
<div class="gear" onclick="openSettings()">⚙</div>
|
||||
<h1>Yuzu GCA</h1>
|
||||
<div class="ver"><span id="ver"></span><span id="badge" class="badge badge-new" style="display:none">NEW</span></div>
|
||||
<div class="card"><div class="card-title">Channel: <span id="chLabel">Stable</span></div></div>
|
||||
<button class="btn btn-check" onclick="checkUpdate()">Check Update</button>
|
||||
<button class="btn btn-download" id="btnApply" onclick="applyUpdate()">Download Update</button>
|
||||
<div class="log" id="log"></div>
|
||||
|
||||
<div class="modal" id="modal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">Select Channel</div>
|
||||
<button class="ch-opt on" onclick="setChannel('stable')">Stable</button>
|
||||
<button class="ch-opt" onclick="setChannel('beta')">Beta</button>
|
||||
<button class="ch-opt" onclick="setChannel('nightly')">Nightly</button>
|
||||
<button class="btn btn-check" style="margin-top:12px" onclick="closeSettings()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var channel=localStorage.getItem('gca_channel')||'stable';
|
||||
document.getElementById('chLabel').innerText=(localStorage.getItem('gca_channel')||'stable').charAt(0).toUpperCase()+(localStorage.getItem('gca_channel')||'stable').slice(1);
|
||||
var dlUrl='',dlVer='',dlSha='';
|
||||
function log(m){var l=document.getElementById('log');l.innerText+=m+'\n';l.scrollTop=l.scrollHeight}
|
||||
function openSettings(){
|
||||
var m=document.getElementById('modal');m.classList.add('show');
|
||||
document.querySelectorAll('.ch-opt').forEach(function(b){b.classList.toggle('on',b.textContent.toLowerCase()===channel)})
|
||||
}
|
||||
function closeSettings(){document.getElementById('modal').classList.remove('show')}
|
||||
function setChannel(ch){
|
||||
channel=ch;localStorage.setItem('gca_channel',ch);
|
||||
document.getElementById('chLabel').innerText=ch.charAt(0).toUpperCase()+ch.slice(1);
|
||||
document.querySelectorAll('.ch-opt').forEach(function(b){b.classList.toggle('on',b.textContent.toLowerCase()===ch)});
|
||||
closeSettings();checkUpdate()
|
||||
}
|
||||
function checkUpdate(){
|
||||
log('channel:'+channel);
|
||||
var j=JSON.parse(Android.fetchManifest(channel));
|
||||
if(j.error){log('ERR:'+j.error);return}
|
||||
var h5=j.h5;log('latest:'+h5.latest+' min:'+h5.min_version);
|
||||
var v=(h5.min_version||'').replace(/[^0-9]/g,''),my=(document.getElementById('ver').innerText||'').replace(/[^0-9]/g,'');
|
||||
if(parseInt(my||'0')<parseInt(v||'0')){log('ERR:APK too old (need v'+h5.min_version+')');return}
|
||||
dlUrl=h5.url;dlSha=h5.sha256||'';
|
||||
if(!dlSha||/^(0{64}|TBD)/.test(dlSha)){log('ERR:channel no build');return}
|
||||
if(h5.latest!==document.getElementById('ver').innerText&&h5.latest!=='v'+document.getElementById('ver').innerText){
|
||||
dlVer=h5.latest;document.getElementById('btnApply').classList.add('show');log('***NEW VERSION***')}
|
||||
else log('up to date')
|
||||
}
|
||||
function applyUpdate(){
|
||||
log('download...');
|
||||
var t=Android.fetchText('h5/index-stable.html');
|
||||
if(typeof t!=='string'||t.startsWith('ERROR:')){log('ERR:'+t);return}
|
||||
var h=Android.sha256(t);
|
||||
if(h!==dlSha){log('SHA256 MISMATCH!');return}
|
||||
Android.saveCache(t);log('OK! restart app')
|
||||
}
|
||||
document.getElementById('ver').innerText=Android.fetchManifest(channel).match(/"latest":"([^"]+)"/)?.[1]||channel;
|
||||
</script></body></html>
|
||||
@@ -1 +0,0 @@
|
||||
{"version":"0.1.0","builds":{}}
|
||||
@@ -1 +0,0 @@
|
||||
{"version":"0.1.0","builds":{"android":{"type":"apk","tag":"v0.1.0-dav-android-8","url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-8/v0.1.0-dav-android-8-debug.apk","sha256":"b2451a398ea2dd34e585c5ec4529d6654451c9b9ab4ddf4a25ec9b032f0ac2bf","size":3506569,"channel":"nightly","minVersion":"0.1.0"}}}
|
||||
@@ -1 +0,0 @@
|
||||
{"version":"0.1.0","builds":{}}
|
||||
1
ota/manifest.json
Normal file
@@ -0,0 +1 @@
|
||||
{"stable":{"h5":{"latest":"0.1.0-dav-android-6","min_version":"0.1.0-dav-android-5","url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/h5/index-stable.html","sha256":"ae7db3b84ad018fbc4cc938c30db482dd4c3cad47aab6db81c8a6716cfadc844"},"apk":{"version":"0.1.0-dav-android-5","versionCode":5,"url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-5/v0.1.0-dav-android-5-debug.apk","sha256":"56b0e384f53015b0a1a5eb2507fd792dfc31fe44aff40342cd8a8d3bd5c1e14a"}},"beta":{"h5":{"latest":"0.1.0-dav-android-6","min_version":"0.1.0-dav-android-5","url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/h5/index-stable.html","sha256":"ae7db3b84ad018fbc4cc938c30db482dd4c3cad47aab6db81c8a6716cfadc844"},"apk":{"version":"0.1.0-dav-android-5","versionCode":5,"url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-5/v0.1.0-dav-android-5-debug.apk","sha256":"56b0e384f53015b0a1a5eb2507fd792dfc31fe44aff40342cd8a8d3bd5c1e14a"}},"nightly":{"h5":{"latest":"0.1.0-dav-android-7","min_version":"0.1.0-dav-android-5","url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/h5/index-stable.html","sha256":"ae7db3b84ad018fbc4cc938c30db482dd4c3cad47aab6db81c8a6716cfadc844"},"apk":{"version":"0.1.0-dav-android-5","versionCode":5,"url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-5/v0.1.0-dav-android-5-debug.apk","sha256":"56b0e384f53015b0a1a5eb2507fd792dfc31fe44aff40342cd8a8d3bd5c1e14a"}}}
|
||||
79
ota/push-hotfix.sh
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# push-hotfix.sh — 推送 H5 热更新补丁到 Gitea
|
||||
#
|
||||
# 用法:
|
||||
# ./push-hotfix.sh 7 stable # 只更新 stable 渠道
|
||||
# ./push-hotfix.sh 7 all # 更新全部三个渠道
|
||||
# ./push-hotfix.sh 7 beta nightly # 更新指定渠道
|
||||
#
|
||||
# 流程:
|
||||
# 1. 创建 ota/h5/index-v{VER}.html(如果不存在则从模板生成)
|
||||
# 2. 计算 SHA256
|
||||
# 3. 更新 ota/h5-manifest-{CHANNEL}.json
|
||||
# 4. 如果是稳定版,更新 ota/version.txt
|
||||
# 5. git add + commit + push
|
||||
|
||||
set -e
|
||||
|
||||
VER="$1"
|
||||
shift
|
||||
CHANNELS=("$@")
|
||||
|
||||
if [ -z "$VER" ] || [ ${#CHANNELS[@]} -eq 0 ]; then
|
||||
echo "用法: $0 <版本号> <渠道...>"
|
||||
echo "示例: $0 7 stable beta nightly"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OTA_DIR="$(dirname "$0")"
|
||||
H5_DIR="$OTA_DIR/h5"
|
||||
BASE_URL="https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota"
|
||||
|
||||
# 1. 创建 HTML 文件(如果不存在)
|
||||
HTML_FILE="$H5_DIR/index-v${VER}.html"
|
||||
if [ ! -f "$HTML_FILE" ]; then
|
||||
echo "→ 请手动创建 $HTML_FILE 或从 index-v6.html 复制修改"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 计算 SHA256
|
||||
SHA256=$(sha256sum "$HTML_FILE" | cut -d' ' -f1)
|
||||
echo "SHA256: $SHA256"
|
||||
|
||||
# 3. 更新 manifest
|
||||
FULL_URL="${BASE_URL}/h5/index-v${VER}.html"
|
||||
|
||||
for CH in "${CHANNELS[@]}"; do
|
||||
if [ "$CH" = "all" ]; then
|
||||
for CH in stable beta nightly; do
|
||||
MANIFEST="$OTA_DIR/h5-manifest-${CH}.json"
|
||||
cat > "$MANIFEST" <<EOF
|
||||
{"latest":"0.1.0-dav-android-${VER}","min_version":"0.1.0-dav-android-5","url":"${FULL_URL}","sha256":"${SHA256}"}
|
||||
EOF
|
||||
echo " ✓ h5-manifest-${CH}.json"
|
||||
done
|
||||
break
|
||||
fi
|
||||
MANIFEST="$OTA_DIR/h5-manifest-${CH}.json"
|
||||
cat > "$MANIFEST" <<EOF
|
||||
{"latest":"0.1.0-dav-android-${VER}","min_version":"0.1.0-dav-android-5","url":"${FULL_URL}","sha256":"${SHA256}"}
|
||||
EOF
|
||||
echo " ✓ h5-manifest-${CH}.json"
|
||||
done
|
||||
|
||||
# 4. 稳定版更新 version.txt
|
||||
if [[ " ${CHANNELS[*]} " =~ " stable " ]] || [[ " ${CHANNELS[*]} " =~ " all " ]]; then
|
||||
echo "0.1.0-dav-android-${VER}|${FULL_URL}|${SHA256}" > "$OTA_DIR/version.txt"
|
||||
echo " ✓ version.txt"
|
||||
fi
|
||||
|
||||
# 5. git push
|
||||
echo ""
|
||||
echo "commit & push?"
|
||||
read -p "(y/N) " yn
|
||||
if [ "$yn" = "y" ] || [ "$yn" = "Y" ]; then
|
||||
git add "$OTA_DIR"
|
||||
git commit -m "hotfix: v${VER} → ${CHANNELS[*]}"
|
||||
git push
|
||||
echo "✓ pushed"
|
||||
fi
|
||||
1
ota/version.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/manifest.json
|
||||
@@ -1,72 +1,286 @@
|
||||
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';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
StyleSheet, Text, View, TextInput, TouchableOpacity,
|
||||
ScrollView, Switch, FlatList, KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import { DEFAULT_GATEWAY_CONFIG } from '@yuzu-gca/shared';
|
||||
|
||||
/** Android Client: Chat + Settings + Connection */
|
||||
|
||||
type Msg = { role: 'user' | 'assistant'; text: string; ts: number };
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState<'checking' | 'up-to-date' | 'updating' | 'error'>('checking');
|
||||
const [message, setMessage] = useState('正在检查更新...');
|
||||
const [tab, setTab] = useState<'chat' | 'terminal' | 'settings' | 'device' | 'connection'>('chat');
|
||||
|
||||
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}`);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
// Chat state
|
||||
const [messages, setMessages] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// Settings state
|
||||
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_GATEWAY_CONFIG.url);
|
||||
const [deviceName, setDeviceName] = useState('Android Device');
|
||||
const [autoConnect, setAutoConnect] = useState(true);
|
||||
const [heartbeatInterval, setHeartbeatInterval] = useState(String(DEFAULT_GATEWAY_CONFIG.heartbeatInterval / 1000));
|
||||
const [logLevel, setLogLevel] = useState('info');
|
||||
|
||||
// Connection state
|
||||
const [connStatus, setConnStatus] = useState<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected');
|
||||
|
||||
// Terminal state
|
||||
const [termCmd, setTermCmd] = useState('');
|
||||
const [termOut, setTermOut] = useState('Execute remote commands via Gateway MCP.\nType a shell command and press Execute.\n');
|
||||
const [termRunning, setTermRunning] = useState(false);
|
||||
const runTermCmd = async () => {
|
||||
if (!termCmd.trim() || termRunning) return;
|
||||
const c = termCmd.trim(); setTermCmd(''); setTermRunning(true);
|
||||
setTermOut(o => o + `$ ${c}\n`);
|
||||
try {
|
||||
const r = await fetch(`${gatewayUrl}/tools/exec`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({command:c}), signal:AbortSignal.timeout(30000) });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const d = await r.json() as any;
|
||||
setTermOut(o => o + (d.stdout||d.stderr||JSON.stringify(d)) + '\n');
|
||||
} catch(e:any) { setTermOut(o => o + `Error: ${e.message}\n`); }
|
||||
finally { setTermRunning(false); }
|
||||
};
|
||||
|
||||
/** Send chat message to Gateway */
|
||||
const sendMessage = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
const userMsg: Msg = { role: 'user', text, ts: Date.now() };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json() as { reply?: string; error?: string };
|
||||
const aiMsg: Msg = {
|
||||
role: 'assistant',
|
||||
text: data.reply ?? data.error ?? 'No response',
|
||||
ts: Date.now(),
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
} catch (e: any) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
text: `Error: ${e.message}`,
|
||||
ts: Date.now(),
|
||||
}]);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Connect/disconnect toggle */
|
||||
const toggleConnection = () => {
|
||||
setConnStatus(prev => {
|
||||
if (prev === 'disconnected' || prev === 'error') return 'connecting';
|
||||
return 'disconnected';
|
||||
});
|
||||
};
|
||||
|
||||
// ── Render ──────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<KeyboardAvoidingView style={styles.root} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||
<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>
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Yuzu GCA</Text>
|
||||
<View style={[styles.statusDot, styles[`dot_${connStatus}` as keyof typeof styles]]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={styles.tabBar}>
|
||||
{(['chat', 'terminal', 'settings', 'device', 'connection'] as const).map(t => (
|
||||
<TouchableOpacity key={t} onPress={() => setTab(t)} style={[styles.tab, tab === t && styles.tabActive]}>
|
||||
<Text style={[styles.tabText, tab === t && styles.tabTextActive]}>
|
||||
{t === 'chat' ? 'Chat' : t === 'terminal' ? 'Term' : t === 'settings' ? 'Gateway' : t === 'device' ? 'Device' : (connStatus === 'connected' ? '🟢' : '⚫')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* ── Chat Tab ── */}
|
||||
{tab === 'chat' && (
|
||||
<View style={styles.chatContainer}>
|
||||
<FlatList
|
||||
data={messages}
|
||||
keyExtractor={(_, i) => String(i)}
|
||||
style={styles.chatList}
|
||||
renderItem={({ item }) => (
|
||||
<View style={[styles.bubble, item.role === 'user' ? styles.bubbleUser : styles.bubbleAI]}>
|
||||
<Text style={styles.bubbleRole}>{item.role === 'user' ? 'You' : 'GCA'}</Text>
|
||||
<Text style={styles.bubbleText}>{item.text}</Text>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.emptyText}>Send a message to control your devices via AI</Text>
|
||||
}
|
||||
/>
|
||||
<View style={styles.inputBar}>
|
||||
<TextInput
|
||||
style={styles.chatInput}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder="Type a command..."
|
||||
placeholderTextColor="#475569"
|
||||
onSubmitEditing={sendMessage}
|
||||
returnKeyType="send"
|
||||
/>
|
||||
<TouchableOpacity style={[styles.sendBtn, sending && styles.sendBtnDisabled]} onPress={sendMessage} disabled={sending}>
|
||||
<Text style={styles.sendBtnText}>{sending ? '...' : 'Send'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Terminal Tab ── */}
|
||||
{tab === 'terminal' && (
|
||||
<View style={styles.chatContainer}>
|
||||
<ScrollView style={styles.termOutput}>
|
||||
<Text style={styles.termText} selectable>{termOut}</Text>
|
||||
</ScrollView>
|
||||
<View style={styles.inputBar}>
|
||||
<TextInput style={styles.chatInput} value={termCmd} onChangeText={setTermCmd} placeholder="ls -la /home" placeholderTextColor="#475569" autoCapitalize="none" autoCorrect={false} onSubmitEditing={runTermCmd} returnKeyType="send" />
|
||||
<TouchableOpacity style={[styles.sendBtn, termRunning && styles.sendBtnDisabled]} onPress={runTermCmd} disabled={termRunning}>
|
||||
<Text style={styles.sendBtnText}>{termRunning ? '...' : 'Run'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Settings Tab ── */}
|
||||
{tab === 'settings' && (
|
||||
<ScrollView style={styles.body}>
|
||||
<Text style={styles.label}>Gateway URL</Text>
|
||||
<TextInput style={styles.input} value={gatewayUrl} onChangeText={setGatewayUrl} placeholder="http://localhost:3000" placeholderTextColor="#475569" autoCapitalize="none" keyboardType="url" />
|
||||
|
||||
<Text style={styles.label}>Heartbeat (seconds)</Text>
|
||||
<TextInput style={styles.input} value={heartbeatInterval} onChangeText={setHeartbeatInterval} keyboardType="numeric" placeholder="15" placeholderTextColor="#475569" />
|
||||
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.label}>Auto-connect on start</Text>
|
||||
<Switch value={autoConnect} onValueChange={setAutoConnect} trackColor={{ true: '#3b82f6' }} />
|
||||
</View>
|
||||
|
||||
<Text style={styles.label}>Log Level</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{(['debug', 'info', 'warn', 'error'] as const).map(lv => (
|
||||
<TouchableOpacity key={lv} style={[styles.chip, logLevel === lv && styles.chipActive]} onPress={() => setLogLevel(lv)}>
|
||||
<Text style={[styles.chipText, logLevel === lv && styles.chipTextActive]}>{lv}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* ── Device Tab ── */}
|
||||
{tab === 'device' && (
|
||||
<ScrollView style={styles.body}>
|
||||
<Text style={styles.label}>Device Name</Text>
|
||||
<TextInput style={styles.input} value={deviceName} onChangeText={setDeviceName} placeholder="Android Device" placeholderTextColor="#475569" />
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoTitle}>Device Info</Text>
|
||||
<Text style={styles.infoText}>Platform: Android</Text>
|
||||
<Text style={styles.infoText}>Gateway: {gatewayUrl}</Text>
|
||||
<Text style={styles.infoText}>Capabilities: exec, file_list, sysinfo...</Text>
|
||||
<Text style={styles.infoText}>MCP Tools: 36 defined, 7 implemented</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* ── Connection Tab ── */}
|
||||
{tab === 'connection' && (
|
||||
<View style={styles.body}>
|
||||
<View style={[styles.connIndicator, styles[`conn_${connStatus}` as keyof typeof styles]]}>
|
||||
<Text style={styles.connText}>
|
||||
{connStatus === 'connected' ? '🟢 Connected' :
|
||||
connStatus === 'connecting' ? '🟡 Connecting...' :
|
||||
connStatus === 'error' ? '🔴 Error' : '⚫ Disconnected'}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, connStatus === 'connected' ? styles.btnDanger : styles.btnPrimary]}
|
||||
onPress={toggleConnection}
|
||||
>
|
||||
<Text style={styles.btnText}>{connStatus === 'disconnected' ? 'Connect' : 'Disconnect'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────
|
||||
|
||||
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',
|
||||
},
|
||||
root: { flex: 1, backgroundColor: '#0f172a' },
|
||||
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingTop: 48, paddingBottom: 8 },
|
||||
title: { fontSize: 22, fontWeight: '800', color: '#e2e8f0' },
|
||||
statusDot: { width: 10, height: 10, borderRadius: 5 },
|
||||
dot_connected: { backgroundColor: '#22c55e' },
|
||||
dot_connecting: { backgroundColor: '#eab308' },
|
||||
dot_error: { backgroundColor: '#ef4444' },
|
||||
dot_disconnected: { backgroundColor: '#475569' },
|
||||
|
||||
// Tabs
|
||||
tabBar: { flexDirection: 'row', marginHorizontal: 16, marginBottom: 8 },
|
||||
tab: { flex: 1, padding: 10, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: '#1e293b' },
|
||||
tabActive: { borderBottomColor: '#3b82f6' },
|
||||
tabText: { fontSize: 13, color: '#64748b' },
|
||||
tabTextActive: { color: '#3b82f6', fontWeight: '600' },
|
||||
|
||||
// Chat
|
||||
chatContainer: { flex: 1 },
|
||||
chatList: { flex: 1, paddingHorizontal: 12 },
|
||||
bubble: { marginVertical: 4, padding: 10, borderRadius: 10, maxWidth: '85%' },
|
||||
bubbleUser: { alignSelf: 'flex-end', backgroundColor: '#1e40af' },
|
||||
bubbleAI: { alignSelf: 'flex-start', backgroundColor: '#1e293b', borderWidth: 1, borderColor: '#334155' },
|
||||
bubbleRole: { fontSize: 10, color: '#94a3b8', marginBottom: 2 },
|
||||
bubbleText: { fontSize: 14, color: '#e2e8f0' },
|
||||
emptyText: { textAlign: 'center', color: '#475569', marginTop: 40, fontSize: 14 },
|
||||
inputBar: { flexDirection: 'row', padding: 8, paddingBottom: 16, gap: 8 },
|
||||
chatInput: { flex: 1, backgroundColor: '#1e293b', color: '#e2e8f0', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14 },
|
||||
sendBtn: { backgroundColor: '#1e40af', borderRadius: 8, paddingHorizontal: 16, justifyContent: 'center' },
|
||||
sendBtnDisabled: { opacity: 0.5 },
|
||||
sendBtnText: { color: '#fff', fontWeight: '600', fontSize: 14 },
|
||||
|
||||
// Settings
|
||||
body: { flex: 1, paddingHorizontal: 16 },
|
||||
label: { fontSize: 12, color: '#94a3b8', marginBottom: 4, marginTop: 12 },
|
||||
input: { backgroundColor: '#1e293b', color: '#e2e8f0', borderRadius: 8, padding: 12, fontSize: 14 },
|
||||
switchRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 },
|
||||
chipRow: { flexDirection: 'row', gap: 8, marginTop: 4 },
|
||||
chip: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, backgroundColor: '#1e293b' },
|
||||
chipActive: { backgroundColor: '#1e40af' },
|
||||
chipText: { fontSize: 12, color: '#94a3b8' },
|
||||
chipTextActive: { color: '#fff' },
|
||||
infoCard: { backgroundColor: '#1e293b', borderRadius: 8, padding: 16, marginTop: 16 },
|
||||
infoTitle: { fontSize: 14, color: '#38bdf8', marginBottom: 8 },
|
||||
infoText: { fontSize: 12, color: '#94a3b8', marginTop: 4 },
|
||||
|
||||
// Connection
|
||||
connIndicator: { borderRadius: 8, padding: 16, marginTop: 16, alignItems: 'center' },
|
||||
conn_connected: { backgroundColor: '#064e3b' },
|
||||
conn_connecting: { backgroundColor: '#78350f' },
|
||||
conn_error: { backgroundColor: '#7f1d1d' },
|
||||
conn_disconnected: { backgroundColor: '#1e293b' },
|
||||
connText: { fontSize: 16, color: '#e2e8f0' },
|
||||
termOutput: { flex: 1, backgroundColor: '#0a0f1a', margin: 8, borderRadius: 8, padding: 12 },
|
||||
termText: { fontFamily: 'monospace', fontSize: 12, color: '#22c55e', lineHeight: 18 },
|
||||
btn: { borderRadius: 8, padding: 14, marginTop: 16, alignItems: 'center' },
|
||||
btnPrimary: { backgroundColor: '#1e40af' },
|
||||
btnDanger: { backgroundColor: '#991b1b' },
|
||||
btnText: { fontSize: 16, color: '#fff', fontWeight: '600' },
|
||||
});
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#Mon Jul 20 13:05:50 CST 2026
|
||||
gradle.version=8.10.2
|
||||
#Wed Jul 22 19:01:51 CST 2026
|
||||
gradle.version=8.13
|
||||
|
||||
@@ -2,14 +2,14 @@ apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
|
||||
android {
|
||||
namespace 'dev.yuzu.gca'
|
||||
namespace "dev.yuzu.gca"
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'dev.yuzu.gca'
|
||||
applicationId "dev.yuzu.gca"
|
||||
minSdk 24
|
||||
targetSdk 35
|
||||
versionCode 8
|
||||
versionCode 5
|
||||
versionName "0.1.0"
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<application android:name=".MainApplication" android:label="Yuzu GCA v0.1.0" android:allowBackup="false" android:supportsRtl="true" android:theme="@style/Theme.AppCompat.DayNight">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="3000"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/expo-manifest.json"/>
|
||||
<activity android:name=".MainActivity" android:label="Yuzu GCA v0.1.0" android:launchMode="singleTask" android:exported="true" android:screenOrientation="portrait" android:windowSoftInputMode="adjustResize">
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:usesCleartextTraffic="true" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/><activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
@@ -21,8 +25,5 @@
|
||||
<data android:scheme="dev.yuzu.gca"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,user-scalable=no">
|
||||
<title>Yuzu GCA</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:system-ui;background:#0f172a;color:#e2e8f0;padding:16px}
|
||||
h1{font-size:20px;color:#38bdf8;margin-bottom:4px}
|
||||
.info{color:#94a3b8;font-size:12px;margin-bottom:16px}
|
||||
.card{background:#1e293b;border-radius:8px;padding:16px;margin:12px 0}
|
||||
.card-title{font-size:13px;color:#94a3b8;margin-bottom:8px}
|
||||
.btn{display:block;width:100%;padding:14px;margin:8px 0;border:none;border-radius:8px;font-size:15px;cursor:pointer;color:#fff}
|
||||
.btn-blue{background:#1e40af}
|
||||
.btn-green{background:#15803d}
|
||||
.input{display:block;width:100%;padding:12px;margin:8px 0;border:none;border-radius:8px;font-size:14px;background:#0f172a;color:#e2e8f0}
|
||||
.log{background:#0a0f1a;padding:12px;border-radius:8px;margin-top:8px;max-height:250px;overflow-y:auto;font-size:11px;white-space:pre-wrap;color:#22c55e;font-family:monospace}
|
||||
.tab{display:inline-block;padding:8px 16px;margin-right:4px;border-radius:6px 6px 0 0;background:#1e293b;color:#64748b;font-size:13px;cursor:pointer}
|
||||
.tab-on{background:#1e40af;color:#fff}
|
||||
</style></head><body>
|
||||
<h1>Yuzu GCA</h1>
|
||||
<div class="info">Self-contained — no Gateway needed</div>
|
||||
|
||||
<div>
|
||||
<span class="tab tab-on" onclick="switchTab('exec',event)">Exec</span>
|
||||
<span class="tab" onclick="switchTab('files',event)">Files</span>
|
||||
<span class="tab" onclick="switchTab('sysinfo',event)">SysInfo</span>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tab-exec">
|
||||
<div class="card-title">Shell Command (local)</div>
|
||||
<input class="input" id="cmd" placeholder="e.g. ls, whoami, df -h">
|
||||
<button class="btn btn-blue" onclick="runExec()">Execute</button>
|
||||
<div class="log" id="log-exec"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tab-files" style="display:none">
|
||||
<div class="card-title">List Files (local)</div>
|
||||
<input class="input" id="path" placeholder="/sdcard or ." value=".">
|
||||
<button class="btn btn-blue" onclick="runFiles()">List</button>
|
||||
<div class="log" id="log-files"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tab-sysinfo" style="display:none">
|
||||
<div class="card-title">Device Info (local)</div>
|
||||
<button class="btn btn-blue" onclick="runSysinfo()">Refresh</button>
|
||||
<div class="log" id="log-sysinfo"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(t,ev){
|
||||
ev.target.classList.add('tab-on');
|
||||
['exec','files','sysinfo'].filter(function(x){return x!==t}).forEach(function(x){
|
||||
document.getElementById('tab-'+x).style.display='none';
|
||||
document.querySelectorAll('.tab').forEach(function(b){if(b.textContent.toLowerCase().indexOf(x)>=0)b.classList.remove('tab-on')});
|
||||
});
|
||||
document.getElementById('tab-'+t).style.display='block';
|
||||
}
|
||||
|
||||
function log(id,msg){
|
||||
var l=document.getElementById(id);
|
||||
l.innerText+=msg+'\n';l.scrollTop=l.scrollHeight;
|
||||
}
|
||||
|
||||
function runExec(){
|
||||
var c=document.getElementById('cmd').value, id='log-exec';
|
||||
document.getElementById(id).innerText='';
|
||||
log(id,'$ '+c);
|
||||
var r=Android.exec(c);
|
||||
log(id,r||'(empty)');
|
||||
}
|
||||
|
||||
function runFiles(){
|
||||
var p=document.getElementById('path').value, id='log-files';
|
||||
document.getElementById(id).innerText='';
|
||||
log(id,'ls '+p);
|
||||
var r=Android.listFiles(p);
|
||||
log(id,r||'(empty)');
|
||||
}
|
||||
|
||||
function runSysinfo(){
|
||||
var id='log-sysinfo';
|
||||
document.getElementById(id).innerText='';
|
||||
log(id,Android.sysinfo());
|
||||
}
|
||||
</script></body></html>
|
||||
@@ -1,255 +1,58 @@
|
||||
package dev.yuzu.gca
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import org.json.JSONObject
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import java.io.File
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var logView: TextView
|
||||
private var selectedChannel = "stable"
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val scroll = ScrollView(this)
|
||||
val layout = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(24, 24, 24, 24)
|
||||
}
|
||||
|
||||
val title = TextView(this).apply {
|
||||
text = "Yuzu GCA"
|
||||
textSize = 20f
|
||||
setPadding(0, 0, 0, 4)
|
||||
}
|
||||
layout.addView(title)
|
||||
|
||||
val versionInfo = TextView(this).apply {
|
||||
text = "v0.1.0-dav-android-8-debug"
|
||||
textSize = 12f
|
||||
setTextColor(Color.GRAY)
|
||||
setPadding(0, 0, 0, 12)
|
||||
}
|
||||
layout.addView(versionInfo)
|
||||
|
||||
val channelLabel = TextView(this).apply {
|
||||
text = "选择更新渠道:"
|
||||
textSize = 13f
|
||||
setPadding(0, 8, 0, 4)
|
||||
}
|
||||
layout.addView(channelLabel)
|
||||
|
||||
// 渠道按钮行
|
||||
val channelRow = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(0, 0, 0, 12)
|
||||
}
|
||||
|
||||
fun makeChannelBtn(label: String, channel: String): Button {
|
||||
return Button(this).apply {
|
||||
text = label
|
||||
textSize = 12f
|
||||
setOnClickListener {
|
||||
selectedChannel = channel
|
||||
highlightChannel(channelRow, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val btnStable = makeChannelBtn("Stable", "stable")
|
||||
val btnBeta = makeChannelBtn("Beta", "beta")
|
||||
val btnNightly = makeChannelBtn("Nightly", "nightly")
|
||||
|
||||
channelRow.addView(btnStable)
|
||||
channelRow.addView(btnBeta)
|
||||
channelRow.addView(btnNightly)
|
||||
layout.addView(channelRow)
|
||||
highlightChannel(channelRow, btnStable)
|
||||
|
||||
logView = TextView(this).apply {
|
||||
textSize = 12f
|
||||
setTextColor(Color.LTGRAY)
|
||||
}
|
||||
layout.addView(logView)
|
||||
|
||||
val btn = Button(this).apply {
|
||||
text = "检查更新"
|
||||
setOnClickListener {
|
||||
val ch = selectedChannel
|
||||
CoroutineScope(Dispatchers.IO).launch { doOtaTest(ch) }
|
||||
}
|
||||
}
|
||||
layout.addView(btn)
|
||||
|
||||
scroll.addView(layout)
|
||||
setContentView(scroll)
|
||||
|
||||
appendLog("渠道: stable | 点击[检查更新]开始")
|
||||
WebView(this).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.allowUniversalAccessFromFileURLs = true
|
||||
addJavascriptInterface(LocalBridge(), "Android")
|
||||
loadUrl("file:///android_asset/index.html")
|
||||
}.also { setContentView(it) }
|
||||
}
|
||||
|
||||
private fun highlightChannel(row: LinearLayout, active: Button) {
|
||||
for (i in 0 until row.childCount) {
|
||||
val b = row.getChildAt(i) as Button
|
||||
b.isSelected = (b == active)
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendLog(msg: String) {
|
||||
Log.i(TAG, msg)
|
||||
runOnUiThread { logView.append("$msg\n") }
|
||||
}
|
||||
|
||||
companion object { private const val TAG = "GCA-OTA" }
|
||||
|
||||
private fun manifestUrl(channel: String): String {
|
||||
val base = "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota"
|
||||
return when (channel) {
|
||||
"beta" -> "$base/manifest-beta.json"
|
||||
"nightly" -> "$base/manifest-nightly.json"
|
||||
else -> "$base/manifest-stable.json"
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doOtaTest(channel: String) = withContext(Dispatchers.IO) {
|
||||
val url = manifestUrl(channel)
|
||||
appendLog("=== OTA 渠道: $channel ===")
|
||||
appendLog("manifest: $url")
|
||||
|
||||
// 1. 拉取 manifest
|
||||
appendLog("[1/4] 拉取 manifest...")
|
||||
val manifestJson: String
|
||||
try {
|
||||
manifestJson = httpGet(url)
|
||||
appendLog(" ✅ 获取成功 (${manifestJson.length}B)")
|
||||
} catch (e: Exception) {
|
||||
appendLog(" ❌ 获取失败: ${e.message}")
|
||||
appendLog("=== 测试中止 ===")
|
||||
return@withContext
|
||||
inner class LocalBridge {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun exec(cmd: String): String {
|
||||
return try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("sh", "-c", cmd))
|
||||
val out = BufferedReader(InputStreamReader(p.inputStream)).readText()
|
||||
val err = BufferedReader(InputStreamReader(p.errorStream)).readText()
|
||||
p.waitFor()
|
||||
if (out.isNotEmpty()) out else err
|
||||
} catch (e: Exception) { "ERR: ${e.message}" }
|
||||
}
|
||||
|
||||
// 2. 解析
|
||||
appendLog("[2/4] 解析 manifest...")
|
||||
var manifestSha256 = ""
|
||||
var manifestApkUrl = ""
|
||||
var manifestTag = ""
|
||||
try {
|
||||
val json = JSONObject(manifestJson)
|
||||
if (!json.getJSONObject("builds").has("android")) {
|
||||
appendLog(" ℹ️ 该渠道暂无可用更新")
|
||||
appendLog("=== $channel 渠道测试完成 ===")
|
||||
return@withContext
|
||||
}
|
||||
val android = json.getJSONObject("builds").getJSONObject("android")
|
||||
manifestSha256 = android.getString("sha256")
|
||||
manifestApkUrl = android.getString("url")
|
||||
manifestTag = android.getString("tag")
|
||||
appendLog(" 渠道: $channel 版本: $manifestTag")
|
||||
appendLog(" SHA256: ${manifestSha256.take(16)}...")
|
||||
|
||||
// 版本比较:一致则跳过
|
||||
val localTag = "v0.1.0-dav-android-${packageManager.getPackageInfo(packageName, 0).versionCode}"
|
||||
if (manifestTag == localTag) {
|
||||
appendLog(" ✅ 已是最新版本,无需更新")
|
||||
appendLog("=== $channel 渠道测试完成 ===")
|
||||
return@withContext
|
||||
}
|
||||
appendLog(" 发现新版本,开始更新...")
|
||||
} catch (e: Exception) {
|
||||
appendLog(" ❌ 解析失败: ${e.message}")
|
||||
return@withContext
|
||||
@android.webkit.JavascriptInterface
|
||||
fun listFiles(path: String): String {
|
||||
return try {
|
||||
val dir = File(path)
|
||||
if (!dir.exists()) return "ERR: path not found"
|
||||
if (!dir.isDirectory) return "ERR: not a directory"
|
||||
dir.listFiles()?.joinToString("\n") { f ->
|
||||
(if (f.isDirectory) "[DIR] " else "[FILE] ") + f.name +
|
||||
(if (f.isFile) " (${f.length()}B)" else "")
|
||||
} ?: "empty"
|
||||
} catch (e: Exception) { "ERR: ${e.message}" }
|
||||
}
|
||||
|
||||
// 3. 下载
|
||||
appendLog("[3/4] 下载 APK...")
|
||||
val apkFile = File(cacheDir, "update-$channel.apk")
|
||||
try {
|
||||
downloadFile(manifestApkUrl, apkFile)
|
||||
appendLog(" ✅ 下载完成: ${apkFile.length()} bytes")
|
||||
} catch (e: Exception) {
|
||||
appendLog(" ❌ 下载失败: ${e.message}")
|
||||
return@withContext
|
||||
@android.webkit.JavascriptInterface
|
||||
fun sysinfo(): String {
|
||||
return """
|
||||
Device: ${android.os.Build.MODEL}
|
||||
Android: ${android.os.Build.VERSION.RELEASE} (SDK ${android.os.Build.VERSION.SDK_INT})
|
||||
CPU: ${Runtime.getRuntime().availableProcessors()} cores
|
||||
Memory: ${Runtime.getRuntime().maxMemory() / 1024 / 1024}MB max
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
// 4. SHA256 校验
|
||||
appendLog("[4/4] SHA256 校验...")
|
||||
val actualSha256 = computeSha256(apkFile)
|
||||
if (actualSha256 == manifestSha256) {
|
||||
appendLog(" ✅ SHA256 校验通过!")
|
||||
// 5. 安装 APK
|
||||
appendLog("[5/5] 提交安装...")
|
||||
try {
|
||||
installApk(apkFile)
|
||||
appendLog(" ✅ 安装已提交,稍后通知栏点击重启")
|
||||
} catch (e: Exception) {
|
||||
appendLog(" ❌ 安装失败: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
appendLog(" ❌ SHA256 校验失败!")
|
||||
appendLog(" expected: ${manifestSha256.take(32)}")
|
||||
appendLog(" actual: ${actualSha256.take(32)}")
|
||||
}
|
||||
|
||||
appendLog("=== $channel 渠道测试完成 ===")
|
||||
}
|
||||
|
||||
private fun httpGet(url: String): String {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 10000
|
||||
conn.readTimeout = 10000
|
||||
return conn.inputStream.bufferedReader().readText()
|
||||
}
|
||||
|
||||
private val ALLOWED_HOSTS = setOf("git.childish-ghost.com", "github.com")
|
||||
|
||||
private fun downloadFile(url: String, dest: File) {
|
||||
val host = URL(url).host
|
||||
if (!ALLOWED_HOSTS.any { host == it || host.endsWith(".$it") }) {
|
||||
throw SecurityException("不允许的下载域名: $host")
|
||||
}
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30000
|
||||
conn.readTimeout = 120000
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(dest).use { output -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApk(apkFile: File) {
|
||||
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", apkFile)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun computeSha256(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buf = ByteArray(8192)
|
||||
var read: Int
|
||||
while (input.read(buf).also { read = it } != -1) {
|
||||
digest.update(buf, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package dev.yuzu.gca
|
||||
|
||||
import android.app.Application
|
||||
import com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactNativeHost
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||
import com.facebook.soloader.SoLoader
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
override val reactNativeHost: ReactNativeHost =
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply { add(PackageInstallerPackage()) }
|
||||
override fun getJSMainModuleName() = "index"
|
||||
override fun getUseDeveloperSupport() = false
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
SoLoader.init(this, false)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package dev.yuzu.gca
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import com.facebook.react.bridge.*
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
class PackageInstallerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "PackageInstallerModule"
|
||||
|
||||
@ReactMethod
|
||||
fun installApk(filePath: String, expectedSha256: String, promise: Promise) {
|
||||
try {
|
||||
val context = reactApplicationContext
|
||||
val apkFile = File(filePath)
|
||||
if (!apkFile.exists()) { promise.reject("NOT_FOUND", "APK not found"); return }
|
||||
|
||||
if (!context.packageManager.canRequestPackageInstalls()) {
|
||||
promise.reject("PERMISSION", "Install permission not granted"); return
|
||||
}
|
||||
|
||||
// SHA256 verify
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
apkFile.inputStream().use { input ->
|
||||
val buf = ByteArray(8192); var read: Int
|
||||
while (input.read(buf).also { read = it } != -1) sha256.update(buf, 0, read)
|
||||
}
|
||||
if (sha256.digest().joinToString("") { "%02x".format(it) } != expectedSha256) {
|
||||
promise.reject("SHA256", "SHA256 mismatch"); return
|
||||
}
|
||||
|
||||
val pi = context.packageManager.packageInstaller
|
||||
val sessionId = pi.createSession(PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL))
|
||||
val session = pi.openSession(sessionId)
|
||||
try {
|
||||
apkFile.inputStream().use { input ->
|
||||
session.openWrite("package", 0, apkFile.length()).use { output ->
|
||||
input.copyTo(output); session.fsync(output)
|
||||
}
|
||||
}
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
?: run { promise.reject("NO_INTENT", "No launch intent"); return }
|
||||
session.commit(PendingIntent.getActivity(context, 0, launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE).intentSender)
|
||||
promise.resolve("installed")
|
||||
} finally { session.close() }
|
||||
} catch (e: Exception) { promise.reject("ERROR", e.message) }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package dev.yuzu.gca
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class PackageInstallerPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(PackageInstallerModule(reactContext))
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBackground"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBackground"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1014 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 350 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 803 B |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 7.5 KiB |
@@ -1,5 +1,6 @@
|
||||
<resources>
|
||||
<color name="splashscreen_background">#0f172a</color>
|
||||
<color name="iconBackground">#0f172a</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#0f172a</color>
|
||||
</resources>
|
||||
@@ -2,5 +2,4 @@
|
||||
<string name="app_name">YuzuGCA</string>
|
||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<string name="expo_runtime_version">0.1.0</string>
|
||||
</resources>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="apk" path="/" />
|
||||
</paths>
|
||||
@@ -12,7 +12,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.7.3")
|
||||
classpath('com.android.tools.build:gradle:8.13.2')
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,5 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
|
||||
# Enable AAPT2 PNG crunching
|
||||
android.enablePngCrunchInReleaseBuilds=true
|
||||
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
# This will allow you to use TurboModules and the Fabric render in
|
||||
# your application. You should enable this flag either if you want
|
||||
# to write custom TurboModules/Fabric components OR use libraries that
|
||||
# are providing them.
|
||||
android.enableJetifier=true
|
||||
newArchEnabled=true
|
||||
|
||||
# Use this property to enable or disable the Hermes JS engine.
|
||||
# If set to false, you will be using JSC instead.
|
||||
hermesEnabled=true
|
||||
|
||||
# Enable GIF support in React Native images (~200 B increase)
|
||||
expo.gif.enabled=true
|
||||
# Enable webp support in React Native images (~85 KB increase)
|
||||
expo.webp.enabled=true
|
||||
# Enable animated webp support (~3.4 MB increase)
|
||||
# Disabled by default because iOS doesn't support animated webp
|
||||
expo.webp.animated=false
|
||||
|
||||
# Enable network inspector
|
||||
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
||||
|
||||
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||
expo.useLegacyPackaging=false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
88
packages/client-android/android/gradlew.bat
vendored
@@ -1,26 +1,94 @@
|
||||
@echo off
|
||||
setlocal
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
set APP_HOME=%DIRNAME%
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
set ANDROID_HOME=C:\Users\Middl\AppData\Local\Android\Sdk
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
:fail
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
exit /b 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
rootProject.name = 'YuzuGCA'
|
||||
|
||||
include ':app'
|
||||
|
||||
@@ -3,26 +3,33 @@
|
||||
"name": "YuzuGCA",
|
||||
"slug": "yuzu-gca",
|
||||
"version": "0.1.0",
|
||||
"runtimeVersion": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"splash": { "backgroundColor": "#0f172a" },
|
||||
"ios": { "supportsTablet": false, "bundleIdentifier": "dev.yuzu.gca" },
|
||||
"splash": {
|
||||
"backgroundColor": "#0f172a"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "dev.yuzu.gca"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": { "backgroundColor": "#0f172a" },
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#0f172a"
|
||||
},
|
||||
"package": "dev.yuzu.gca",
|
||||
"allowBackup": false,
|
||||
"versionCode": 8,
|
||||
"permissions": ["REQUEST_INSTALL_PACKAGES"]
|
||||
"permissions": [
|
||||
"REQUEST_INSTALL_PACKAGES"
|
||||
]
|
||||
},
|
||||
"plugins": ["expo-updates"],
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"checkAutomatically": "ON_LOAD",
|
||||
"fallbackToCacheTimeout": 3000,
|
||||
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/expo-manifest.json"
|
||||
},
|
||||
"extra": { "eas": { "projectId": "yuzu-gca-android" } }
|
||||
"plugins": [
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "yuzu-gca-android"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
packages/client-android/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 354 B |
BIN
packages/client-android/assets/icon.png
Normal file
|
After Width: | Height: | Size: 354 B |
BIN
packages/client-android/assets/splash.png
Normal file
|
After Width: | Height: | Size: 354 B |
4
packages/client-android/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
import App from './App';
|
||||
|
||||
registerRootComponent(App);
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@yuzu-gca/client-android",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
@@ -17,7 +17,6 @@
|
||||
"expo-constants": "~17.0.0",
|
||||
"expo-file-system": "~18.0.0",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"expo-updates": "~0.27.5",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.5"
|
||||
},
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@yuzu-gca/shared": ["../shared/src"]
|
||||
"@yuzu-gca/shared": [
|
||||
"../shared/src"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
|
||||
20
packages/server/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@yuzu-gca/shared": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"dev": "tsc --watch",
|
||||
"build": "tsc"
|
||||
},
|
||||
"name": "@yuzu-gca/server",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"main": "dist/index.js"
|
||||
}
|
||||
231
packages/server/src/connection.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* C-015 + P-006/008/009: Gateway Connection Lifecycle
|
||||
*
|
||||
* - Register device with Gateway on startup
|
||||
* - Heartbeat keepalive every N seconds
|
||||
* - Exponential backoff reconnection on disconnect
|
||||
* - Connectivity check before registration
|
||||
*/
|
||||
|
||||
import { type DeviceConfig } from "@yuzu-gca/shared";
|
||||
|
||||
export interface ConnectionState {
|
||||
status: "disconnected" | "connecting" | "connected" | "reconnecting";
|
||||
lastHeartbeat: number;
|
||||
reconnectAttempt: number;
|
||||
registeredAt: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Gateway is reachable.
|
||||
* Returns true if a HEAD request to gateway.url succeeds.
|
||||
*/
|
||||
export async function checkConnectivity(gatewayUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/health`, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this device with the Gateway.
|
||||
* POST /devices/register with device identity and capabilities.
|
||||
* Returns the assigned device token on success.
|
||||
*/
|
||||
export async function registerWithGateway(
|
||||
gatewayUrl: string,
|
||||
config: DeviceConfig,
|
||||
): Promise<{ token: string } | null> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/devices/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: config.identity.id,
|
||||
name: config.identity.name,
|
||||
platform: config.identity.platform,
|
||||
arch: config.identity.arch,
|
||||
hostname: config.identity.hostname,
|
||||
capabilities: config.capabilities,
|
||||
transport: config.gateway.transport,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.error(`[Gateway] Registration failed: HTTP ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const data = await resp.json() as { token: string };
|
||||
console.log(`[Gateway] Registered as ${config.identity.name} (${config.identity.id})`);
|
||||
return { token: data.token };
|
||||
} catch (err) {
|
||||
console.error(`[Gateway] Registration error:`, (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a heartbeat to the Gateway.
|
||||
* POST /devices/:id/heartbeat
|
||||
* Returns true if Gateway acknowledged.
|
||||
*/
|
||||
export async function sendHeartbeat(
|
||||
gatewayUrl: string,
|
||||
deviceId: string,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/devices/${deviceId}/heartbeat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ timestamp: Date.now() }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister from Gateway.
|
||||
* DELETE /devices/:id
|
||||
*/
|
||||
export async function unregisterFromGateway(
|
||||
gatewayUrl: string,
|
||||
deviceId: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(`${gatewayUrl}/devices/${deviceId}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute exponential backoff delay with jitter.
|
||||
*/
|
||||
export function backoffDelay(attempt: number, maxMs: number = 30000): number {
|
||||
const base = Math.min(1000 * Math.pow(2, attempt), maxMs);
|
||||
const jitter = Math.random() * base * 0.3; // 0-30% jitter
|
||||
return base + jitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection manager — handles the full lifecycle:
|
||||
* check → register → heartbeat loop → disconnect → reconnect with backoff
|
||||
*/
|
||||
export class ConnectionManager {
|
||||
private state: ConnectionState = {
|
||||
status: "disconnected",
|
||||
lastHeartbeat: 0,
|
||||
reconnectAttempt: 0,
|
||||
registeredAt: null,
|
||||
};
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private token: string = "";
|
||||
private stopped: boolean = false;
|
||||
|
||||
constructor(
|
||||
private config: DeviceConfig,
|
||||
private onStateChange?: (state: ConnectionState) => void,
|
||||
) {}
|
||||
|
||||
get currentState(): ConnectionState { return { ...this.state }; }
|
||||
|
||||
/** Start the connection lifecycle (check → register → heartbeat) */
|
||||
async start(): Promise<void> {
|
||||
this.stopped = false;
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
/** Stop everything */
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.clearHeartbeat();
|
||||
if (this.token) {
|
||||
unregisterFromGateway(this.config.gateway.url, this.config.identity.id, this.token)
|
||||
.catch(() => {});
|
||||
}
|
||||
this.setState({ status: "disconnected", registeredAt: null });
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
this.setState({ status: "connecting" });
|
||||
|
||||
const reachable = await checkConnectivity(this.config.gateway.url);
|
||||
if (!reachable) {
|
||||
console.warn("[Gateway] Unreachable, retrying...");
|
||||
this.setState({ status: "reconnecting" });
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await registerWithGateway(this.config.gateway.url, this.config);
|
||||
if (!result) {
|
||||
this.setState({ status: "reconnecting" });
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.token = result.token;
|
||||
this.state.reconnectAttempt = 0;
|
||||
this.setState({ status: "connected", registeredAt: Date.now() });
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.clearHeartbeat();
|
||||
this.heartbeatTimer = setInterval(async () => {
|
||||
if (this.stopped) return;
|
||||
const ok = await sendHeartbeat(
|
||||
this.config.gateway.url,
|
||||
this.config.identity.id,
|
||||
this.token,
|
||||
);
|
||||
this.state.lastHeartbeat = Date.now();
|
||||
if (!ok && !this.stopped) {
|
||||
console.warn("[Gateway] Heartbeat failed, reconnecting...");
|
||||
this.clearHeartbeat();
|
||||
this.setState({ status: "reconnecting" });
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}, this.config.gateway.heartbeatInterval);
|
||||
}
|
||||
|
||||
private clearHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopped) return;
|
||||
const delay = backoffDelay(
|
||||
this.state.reconnectAttempt++,
|
||||
this.config.gateway.maxReconnectDelay,
|
||||
);
|
||||
console.log(`[Gateway] Reconnecting in ${Math.round(delay)}ms (attempt ${this.state.reconnectAttempt})`);
|
||||
setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
private setState(partial: Partial<ConnectionState>): void {
|
||||
this.state = { ...this.state, ...partial };
|
||||
this.onStateChange?.(this.currentState);
|
||||
}
|
||||
}
|
||||
34
packages/server/src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* C-001: MCP Server Framework — stdio transport for Gateway compatibility.
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import * as tools from "./tools.js";
|
||||
|
||||
export function createServer(): McpServer {
|
||||
const server = new McpServer({ name: "yuzu-gca-device", version: "0.1.0" });
|
||||
|
||||
server.tool("file_list", "List directory contents", { path: z.string(), recursive: z.boolean().optional() }, async a => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.file_list(a)) }] }));
|
||||
server.tool("file_read", "Read file content", { path: z.string(), encoding: z.string().optional(), offset: z.number().optional(), limit: z.number().optional() }, async a => ({ content: [{ type: "text" as const, text: (await tools.file_read(a)).content }] }));
|
||||
server.tool("file_write", "Write file content", { path: z.string(), content: z.string(), encoding: z.string().optional() }, async a => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.file_write(a)) }] }));
|
||||
server.tool("file_move", "Move/rename file", { from: z.string(), to: z.string() }, async a => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.file_move(a)) }] }));
|
||||
server.tool("exec", "Execute shell command", { command: z.string(), cwd: z.string().optional(), timeout: z.number().optional() }, async a => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.exec(a)) }] }));
|
||||
server.tool("process_list","List running processes", { filter: z.string().optional() }, async a => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.process_list(a)) }] }));
|
||||
server.tool("sysinfo", "System resource usage", {}, async () => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.sysinfo({})) }] }));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function startServer(server: McpServer): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("[Yuzu-GCA] MCP Server ready (stdio)");
|
||||
}
|
||||
|
||||
export async function bootstrap(): Promise<McpServer> {
|
||||
const server = createServer();
|
||||
await startServer(server);
|
||||
return server;
|
||||
}
|
||||
10
packages/server/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { bootstrap } from "./index.js";
|
||||
|
||||
console.error("[Yuzu-GCA] MCP Server starting (stdio)...");
|
||||
console.error(" Tools: file_list, file_read, file_write, file_move, exec, process_list, sysinfo");
|
||||
|
||||
bootstrap().catch(err => {
|
||||
console.error("[Yuzu-GCA] Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
99
packages/server/src/tools.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as os from "node:os";
|
||||
|
||||
const BASE_DIR = path.resolve(process.env.GCA_WORKSPACE ?? process.cwd());
|
||||
|
||||
function safePath(p: string): string {
|
||||
const r = path.resolve(BASE_DIR, p);
|
||||
if (!r.startsWith(BASE_DIR)) throw new Error(`Path traversal blocked: ${p}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── C-002: file_list ────────────────────────────────────────
|
||||
export async function file_list(args: { path: string; recursive?: boolean }): Promise<any> {
|
||||
const dir = safePath(args.path || ".");
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const result = entries.map(e => ({
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other",
|
||||
size: e.isFile() ? fs.statSync(path.join(dir, e.name)).size : undefined,
|
||||
}));
|
||||
return { path: dir, entries: result, count: result.length };
|
||||
}
|
||||
|
||||
// ── C-003: file_read ────────────────────────────────────────
|
||||
export async function file_read(args: { path: string; encoding?: string; offset?: number; limit?: number }): Promise<any> {
|
||||
const filePath = safePath(args.path);
|
||||
const enc = (args.encoding === "base64" ? "base64" : "utf8") as BufferEncoding;
|
||||
let content = fs.readFileSync(filePath, enc);
|
||||
if (enc === "utf8") {
|
||||
const lines = (content as string).split("\n");
|
||||
content = lines.slice(args.offset ?? 0, args.limit ? (args.offset ?? 0) + args.limit : undefined).join("\n");
|
||||
}
|
||||
return { path: filePath, content, size: fs.statSync(filePath).size };
|
||||
}
|
||||
|
||||
// ── C-004: file_write ───────────────────────────────────────
|
||||
export async function file_write(args: { path: string; content: string; encoding?: string }): Promise<any> {
|
||||
const filePath = safePath(args.path);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, args.content, (args.encoding === "base64" ? "base64" : "utf8") as BufferEncoding);
|
||||
return { ok: true, path: filePath, size: fs.statSync(filePath).size };
|
||||
}
|
||||
|
||||
// ── C-005: file_move ────────────────────────────────────────
|
||||
export async function file_move(args: { from: string; to: string }): Promise<any> {
|
||||
const fromPath = safePath(args.from);
|
||||
const toPath = safePath(args.to);
|
||||
fs.mkdirSync(path.dirname(toPath), { recursive: true });
|
||||
fs.renameSync(fromPath, toPath);
|
||||
return { ok: true, from: fromPath, to: toPath };
|
||||
}
|
||||
|
||||
// ── C-008: exec ─────────────────────────────────────────────
|
||||
function sanitizeCommand(cmd: string): string {
|
||||
// Allow: alphanumeric, dash, slash, dot, colon, underscore, space, equals, comma
|
||||
if (/[^\x20-\x7E]/.test(cmd)) throw new Error(`Unsafe chars: ${cmd.slice(0,50)}`);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export async function exec(args: { command: string; cwd?: string; timeout?: number }): Promise<any> {
|
||||
const command = sanitizeCommand(args.command);
|
||||
const cwd = safePath(args.cwd ?? ".");
|
||||
try {
|
||||
const stdout = execSync(command, { cwd, timeout: args.timeout ?? 30000, encoding: "utf8", maxBuffer: 10*1024*1024, stdio: ["pipe","pipe","pipe"] });
|
||||
return { ok: true, stdout: stdout.toString(), stderr: "" };
|
||||
} catch (err: any) {
|
||||
return { ok: false, stdout: err.stdout?.toString() ?? "", stderr: err.stderr?.toString() ?? "", exitCode: err.status ?? -1, signal: err.signal };
|
||||
}
|
||||
}
|
||||
|
||||
// ── C-010: process_list ─────────────────────────────────────
|
||||
export async function process_list(args: { filter?: string }): Promise<any> {
|
||||
let stdout: string;
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
stdout = execSync("tasklist /FO CSV /NH", { encoding: "utf8" }).toString();
|
||||
} else {
|
||||
stdout = execSync("ps aux --no-headers", { encoding: "utf8" }).toString();
|
||||
}
|
||||
} catch { return { error: "Failed to list processes" }; }
|
||||
const processes = stdout.trim().split("\n").map(line => {
|
||||
if (process.platform === "win32") {
|
||||
const [name, pid, , mem] = line.replace(/"/g, "").split(",");
|
||||
return { name: name?.trim(), pid: Number(pid), mem: mem?.trim() };
|
||||
}
|
||||
const parts = line.trim().split(/\s+/);
|
||||
return { user: parts[0], pid: Number(parts[1]), cpu: parts[2], mem: parts[3], name: parts[10] };
|
||||
});
|
||||
const filtered = args.filter ? processes.filter(p => p.name?.toLowerCase().includes(args.filter!.toLowerCase())) : processes.slice(0, 50);
|
||||
return { count: filtered.length, processes: filtered };
|
||||
}
|
||||
|
||||
// ── C-011: sysinfo ──────────────────────────────────────────
|
||||
export async function sysinfo(_args: {}): Promise<any> {
|
||||
const tm = os.totalmem(); const fm = os.freemem();
|
||||
return { hostname: os.hostname(), platform: os.platform(), arch: os.arch(), cpus: os.cpus().length, loadavg: os.loadavg(), memory: { total: tm, free: fm, used: tm - fm, percent: Math.round(((tm-fm)/tm)*100) }, uptime: os.uptime(), node: process.version };
|
||||
}
|
||||
1
packages/server/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2022","module":"Node16","outDir":"dist","rootDir":"src","declaration":true,"strict":true,"esModuleInterop":true,"skipLibCheck":true},"include":["src"]}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { parseVersion, compareVersion, checkUpdate, semverGt } from './updater/index';
|
||||
export type { GcaVersion, Manifest, ManifestBuild } from './updater/index';
|
||||
export { parseVersion, compareVersion, checkUpdate, semverGt } from './updater/index.js';
|
||||
export type { GcaVersion, Manifest, ManifestBuild } from './updater/index.js';
|
||||
export * from './mcp/index.js';
|
||||
|
||||
47
packages/shared/src/mcp/config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* S-002: Device Configuration Types
|
||||
*/
|
||||
|
||||
/** Connection mode for the Gateway */
|
||||
export type TransportMode = "sse" | "websocket" | "stdio";
|
||||
|
||||
export interface GatewayConfig {
|
||||
url: string;
|
||||
token?: string;
|
||||
transport: TransportMode;
|
||||
reconnectDelay: number; // ms, default 1000
|
||||
maxReconnectDelay: number; // ms, default 30000
|
||||
heartbeatInterval: number; // ms, default 15000
|
||||
}
|
||||
|
||||
export interface DeviceIdentity {
|
||||
id: string; // unique device ID (UUID)
|
||||
name: string; // human-readable name
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
hostname: string;
|
||||
osVersion: string;
|
||||
}
|
||||
|
||||
export interface ProxyConfig {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
auth?: { username: string; password: string };
|
||||
}
|
||||
|
||||
export interface DeviceConfig {
|
||||
identity: DeviceIdentity;
|
||||
gateway: GatewayConfig;
|
||||
proxy?: ProxyConfig;
|
||||
capabilities: string[]; // enabled MCP tool names
|
||||
logLevel: "debug" | "info" | "warn" | "error";
|
||||
}
|
||||
|
||||
export const DEFAULT_GATEWAY_CONFIG: GatewayConfig = {
|
||||
url: "http://localhost:3000",
|
||||
transport: "sse",
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectDelay: 30000,
|
||||
heartbeatInterval: 15000,
|
||||
};
|
||||
2
packages/shared/src/mcp/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./tools.js";
|
||||
export * from "./config.js";
|
||||
375
packages/shared/src/mcp/tools.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* S-001: MCP Tool Types 鈥?36 Tool definitions for Yuzu-GCA
|
||||
*
|
||||
* Each Tool follows the @modelcontextprotocol/sdk shape:
|
||||
* { name, description, inputSchema }
|
||||
*
|
||||
* Organized by module per docs/backlog:
|
||||
* Module 1: Device Management (5 tools)
|
||||
* Module 2: Command Execution (4 tools)
|
||||
* Module 3: File Operations (6 tools)
|
||||
* Module 4: System Control (4 tools)
|
||||
* Module 5: Remote Desktop (4 tools)
|
||||
* Module 6: AI Manipulation (4 tools)
|
||||
* Module 7: Network (3 tools)
|
||||
* Module 8: Hardware (3 tools)
|
||||
* Module 9: Developer Tools (2 tools)
|
||||
* Module 10: Media (1 tool)
|
||||
*/
|
||||
|
||||
import type { z } from "zod";
|
||||
|
||||
// 鈹€鈹€ JSON Schema primitive aliases 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
const str = (desc: string) => ({ type: "string" as const, description: desc });
|
||||
const num = (desc: string) => ({ type: "number" as const, description: desc });
|
||||
const bool = (desc: string) => ({ type: "boolean" as const, description: desc });
|
||||
const arr = (items: any, desc: string) => ({ type: "array" as const, items, description: desc });
|
||||
const obj = (props: Record<string,any>, required: string[], desc: string) => ({
|
||||
type: "object" as const, properties: props, required, description: desc,
|
||||
});
|
||||
|
||||
// 鈹€鈹€ Tool definition type 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: "object";
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCategory {
|
||||
module: number;
|
||||
name: string;
|
||||
tools: ToolDef[];
|
||||
}
|
||||
|
||||
// 鈹€鈹€ Module 1: Device Management (5 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const DEVICE_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "device_info",
|
||||
description: "Get device hardware/OS information (CPU, RAM, hostname, platform, arch)",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "device_list",
|
||||
description: "List all connected devices known to the Gateway",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "device_status",
|
||||
description: "Get current connection status and health of a specific device",
|
||||
inputSchema: obj({ deviceId: str("Target device ID (default: self)") }, [], ""),
|
||||
},
|
||||
{
|
||||
name: "device_register",
|
||||
description: "Register this device with the Gateway with capabilities and metadata",
|
||||
inputSchema: obj({
|
||||
name: str("Human-readable device name"),
|
||||
capabilities: arr(str("capability"), "List of capability strings"),
|
||||
}, ["name"], ""),
|
||||
},
|
||||
{
|
||||
name: "device_unregister",
|
||||
description: "Unregister from Gateway (graceful disconnect)",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 2: Command Execution (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const EXEC_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "exec",
|
||||
description: "Execute a shell command and return stdout/stderr. 30s timeout.",
|
||||
inputSchema: obj({
|
||||
command: str("Shell command to execute"),
|
||||
cwd: str("Working directory (optional)"),
|
||||
timeout: num("Timeout in ms (default 30000)"),
|
||||
}, ["command"], ""),
|
||||
},
|
||||
{
|
||||
name: "exec_background",
|
||||
description: "Start a long-running command and return a process ID for later control",
|
||||
inputSchema: obj({
|
||||
command: str("Shell command to execute"),
|
||||
cwd: str("Working directory (optional)"),
|
||||
}, ["command"], ""),
|
||||
},
|
||||
{
|
||||
name: "process_list",
|
||||
description: "List running processes with PID, name, CPU%, memory",
|
||||
inputSchema: obj({
|
||||
filter: str("Optional name filter (substring match)"),
|
||||
}, [], ""),
|
||||
},
|
||||
{
|
||||
name: "process_kill",
|
||||
description: "Kill a process by PID",
|
||||
inputSchema: obj({
|
||||
pid: num("Process ID to kill"),
|
||||
signal: str("Signal (default: SIGTERM)"),
|
||||
}, ["pid"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 3: File Operations (6 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const FILE_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "file_list",
|
||||
description: "List files and directories at a given path",
|
||||
inputSchema: obj({
|
||||
path: str("Directory path to list"),
|
||||
recursive: bool("Recursive listing (default false)"),
|
||||
}, ["path"], ""),
|
||||
},
|
||||
{
|
||||
name: "file_read",
|
||||
description: "Read contents of a file (text or binary as base64)",
|
||||
inputSchema: obj({
|
||||
path: str("File path to read"),
|
||||
encoding: str("utf8 | base64 (default utf8)"),
|
||||
offset: num("Line offset (0-based, optional)"),
|
||||
limit: num("Max lines to return (optional)"),
|
||||
}, ["path"], ""),
|
||||
},
|
||||
{
|
||||
name: "file_write",
|
||||
description: "Write content to a file (create or overwrite)",
|
||||
inputSchema: obj({
|
||||
path: str("File path to write"),
|
||||
content: str("Content to write (text or base64)"),
|
||||
encoding: str("utf8 | base64 (default utf8)"),
|
||||
}, ["path", "content"], ""),
|
||||
},
|
||||
{
|
||||
name: "file_move",
|
||||
description: "Move or rename a file/directory",
|
||||
inputSchema: obj({
|
||||
from: str("Source path"),
|
||||
to: str("Destination path"),
|
||||
}, ["from", "to"], ""),
|
||||
},
|
||||
{
|
||||
name: "file_delete",
|
||||
description: "Delete a file or directory (recursive for dirs)",
|
||||
inputSchema: obj({
|
||||
path: str("Path to delete"),
|
||||
recursive: bool("Recursive delete for directories (default false)"),
|
||||
}, ["path"], ""),
|
||||
},
|
||||
{
|
||||
name: "file_stat",
|
||||
description: "Get file metadata (size, permissions, mtime)",
|
||||
inputSchema: obj({
|
||||
path: str("Path to stat"),
|
||||
}, ["path"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 4: System Control (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const SYSTEM_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "sysinfo",
|
||||
description: "Get system resource usage (CPU%, RAM, disk, uptime, network IO)",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "power",
|
||||
description: "Power management: shutdown, reboot, sleep, lock",
|
||||
inputSchema: obj({
|
||||
action: str("shutdown | reboot | sleep | lock"),
|
||||
}, ["action"], ""),
|
||||
},
|
||||
{
|
||||
name: "service",
|
||||
description: "Manage system services/daemons (start/stop/restart/status)",
|
||||
inputSchema: obj({
|
||||
name: str("Service name"),
|
||||
action: str("start | stop | restart | status | enable | disable"),
|
||||
}, ["name", "action"], ""),
|
||||
},
|
||||
{
|
||||
name: "notify_send",
|
||||
description: "Send a desktop notification to the device",
|
||||
inputSchema: obj({
|
||||
title: str("Notification title"),
|
||||
message: str("Notification body"),
|
||||
urgency: str("low | normal | critical (default normal)"),
|
||||
}, ["title", "message"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 5: Remote Desktop (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const REMOTE_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "screenshot",
|
||||
description: "Capture a screenshot of the device display",
|
||||
inputSchema: obj({
|
||||
display: num("Display index (default 0)"),
|
||||
format: str("png | jpg (default png)"),
|
||||
quality: num("JPEG quality 1-100 (default 80)"),
|
||||
}, [], ""),
|
||||
},
|
||||
{
|
||||
name: "remote_input",
|
||||
description: "Inject keyboard/mouse input events on the remote device",
|
||||
inputSchema: obj({
|
||||
type: str("key | mouse | text"),
|
||||
action: str("key: press/release; mouse: move/click/scroll; text: input string"),
|
||||
value: str("Key name, coordinates, or text content"),
|
||||
}, ["type", "action", "value"], ""),
|
||||
},
|
||||
{
|
||||
name: "clipboard_sync",
|
||||
description: "Read or write the device clipboard",
|
||||
inputSchema: obj({
|
||||
action: str("read | write"),
|
||||
content: str("Content to write (required for write action)"),
|
||||
}, ["action"], ""),
|
||||
},
|
||||
{
|
||||
name: "remote_stream",
|
||||
description: "Start/stop a low-latency screen streaming session",
|
||||
inputSchema: obj({
|
||||
action: str("start | stop"),
|
||||
fps: num("Frames per second (default 15)"),
|
||||
quality: num("Quality 1-100 (default 60)"),
|
||||
}, ["action"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 6: AI Manipulation (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const AI_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "ui_find",
|
||||
description: "Find UI elements by description (uses OCR/accessibility)",
|
||||
inputSchema: obj({
|
||||
query: str("Text or description to search for on screen"),
|
||||
confidence: num("Minimum confidence 0-1 (default 0.7)"),
|
||||
}, ["query"], ""),
|
||||
},
|
||||
{
|
||||
name: "ui_act",
|
||||
description: "Click, type, or scroll at a UI element found by ui_find",
|
||||
inputSchema: obj({
|
||||
element: str("Element descriptor from ui_find result"),
|
||||
action: str("click | dblclick | type | scroll | hover"),
|
||||
value: str("Text to type or scroll direction"),
|
||||
}, ["element", "action"], ""),
|
||||
},
|
||||
{
|
||||
name: "browser_open",
|
||||
description: "Open a URL in the default browser and optionally extract content",
|
||||
inputSchema: obj({
|
||||
url: str("URL to open"),
|
||||
extract: bool("Extract page text content after load (default false)"),
|
||||
}, ["url"], ""),
|
||||
},
|
||||
{
|
||||
name: "ocr_screen",
|
||||
description: "Run OCR on a screenshot region and return text results",
|
||||
inputSchema: obj({
|
||||
x: num("Region left (0-1 fraction of width, default 0)"),
|
||||
y: num("Region top (default 0)"),
|
||||
w: num("Region width (default 1)"),
|
||||
h: num("Region height (default 1)"),
|
||||
lang: str("Language code (default: eng)"),
|
||||
}, [], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 7: Network (3 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const NETWORK_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "net_info",
|
||||
description: "Get network interfaces, IPs, MAC addresses",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "net_ping",
|
||||
description: "Ping a host and return latency/packet loss",
|
||||
inputSchema: obj({
|
||||
host: str("Hostname or IP to ping"),
|
||||
count: num("Number of pings (default 4)"),
|
||||
}, ["host"], ""),
|
||||
},
|
||||
{
|
||||
name: "net_speedtest",
|
||||
description: "Run a basic network speed test (download/upload)",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 8: Hardware (3 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const HARDWARE_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "hw_temperature",
|
||||
description: "Read CPU/GPU temperature sensors",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "hw_battery",
|
||||
description: "Get battery status (level, charging, health)",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "hw_sensors",
|
||||
description: "Read hardware sensors (fan speed, voltage, etc.)",
|
||||
inputSchema: obj({
|
||||
type: str("Filter by sensor type (optional)"),
|
||||
}, [], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 9: Developer Tools (2 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const DEV_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "dev_shell",
|
||||
description: "Open an interactive shell session (persistent, stateful)",
|
||||
inputSchema: obj({
|
||||
command: str("Shell command (empty to start session)"),
|
||||
}, ["command"], ""),
|
||||
},
|
||||
{
|
||||
name: "dev_eval",
|
||||
description: "Execute JavaScript/Python code snippet on the device and return result",
|
||||
inputSchema: obj({
|
||||
code: str("Code to execute"),
|
||||
lang: str("js | python (default js)"),
|
||||
}, ["code"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 10: Media (1 tool) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const MEDIA_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "media_control",
|
||||
description: "Control media playback (play/pause/next/prev/volume)",
|
||||
inputSchema: obj({
|
||||
action: str("play | pause | next | prev | volume_up | volume_down | mute"),
|
||||
}, ["action"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Aggregate 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const ALL_TOOLS: ToolCategory[] = [
|
||||
{ module: 1, name: "Device Management", tools: DEVICE_TOOLS },
|
||||
{ module: 2, name: "Command Execution", tools: EXEC_TOOLS },
|
||||
{ module: 3, name: "File Operations", tools: FILE_TOOLS },
|
||||
{ module: 4, name: "System Control", tools: SYSTEM_TOOLS },
|
||||
{ module: 5, name: "Remote Desktop", tools: REMOTE_TOOLS },
|
||||
{ module: 6, name: "AI Manipulation", tools: AI_TOOLS },
|
||||
{ module: 7, name: "Network", tools: NETWORK_TOOLS },
|
||||
{ module: 8, name: "Hardware", tools: HARDWARE_TOOLS },
|
||||
{ module: 9, name: "Developer Tools", tools: DEV_TOOLS },
|
||||
{ module: 10, name: "Media", tools: MEDIA_TOOLS },
|
||||
];
|
||||
|
||||
export const ALL_TOOL_DEFS: ToolDef[] = ALL_TOOLS.flatMap(c => c.tools);
|
||||
export const TOOL_COUNT = ALL_TOOL_DEFS.length; // 36
|
||||
|
||||
/** Look up a tool by name */
|
||||
export function getTool(name: string): ToolDef | undefined {
|
||||
return ALL_TOOL_DEFS.find(t => t.name === name);
|
||||
}
|
||||