12 Commits

Author SHA1 Message Date
LukeMackin
fe3929b41c fix: pnpm install同步lockfile(expo-updates 0.27.5) 2026-07-20 13:52:28 +08:00
LukeMackin
de8126cd11 fix: expo-constants/expo-updates降级到SDK52配套版本(~17.0.0/~0.27.5) 2026-07-20 13:50:33 +08:00
LukeMackin
ffd5d4d8e5 fix: AndroidManifest添加launchMode=singleTask+MainApplication适配expo prebuild 2026-07-20 13:47:52 +08:00
LukeMackin
8bffc389c8 fix: 移除gradle.properties中的本地代理配置 2026-07-20 13:37:03 +08:00
LukeMackin
27633376c4 build: 代理构建成功 — Gradle代理+恢复file_paths+Expo SDK53 2026-07-20 13:35:00 +08:00
LukeMackin
b7f276f718 feat: Expo SDK升级到53, pnpm install成功 2026-07-20 13:17:06 +08:00
LukeMackin
3abb76f264 wip: expo prebuild 生成android工程(gradle插件兼容性问题待解决) 2026-07-20 13:07:20 +08:00
LukeMackin
bdde28cd7b fix: expo-updates版本改为镜像可用的~0.27.5 (sdk-52) 2026-07-20 13:04:03 +08:00
LukeMackin
73d7eff9d9 merge: 同步main的清理 2026-07-20 12:57:23 +08:00
LukeMackin
6cd2a22d09 feat(expo): app.json 添加 runtimeVersion 字段 2026-07-20 00:38:32 +08:00
LukeMackin
71f6b6369b fix: expo-manifest.json 改为 Expo Updates Modern Manifest 格式(id+createdAt+runtimeVersion) 2026-07-19 23:46:17 +08:00
LukeMackin
e5ce52ac40 feat(expo): JS热更新(expo-updates) + APK大更新双轨
- app.json: expo-updates配置(checkAutomatically ON_LOAD)
- ota/expo-manifest.json: JS bundle热更新manifest
- App.tsx: 启动时自动检查JS+APK双轨更新
- pnpm install后npx expo prebuild生成android/原生工程
2026-07-19 23:40:17 +08:00
94 changed files with 619 additions and 2643 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

View File

@@ -1,2 +0,0 @@
{"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"}

View File

@@ -1,9 +0,0 @@
[
{
"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
}
]

View File

@@ -1,9 +0,0 @@
{
"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"
}

View File

@@ -1,28 +0,0 @@
{"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"}

View File

@@ -1,100 +0,0 @@
[
{
"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
}
]

View File

@@ -1,9 +0,0 @@
{
"status": "complete",
"iteration": 14,
"current_direction": "**结论已定。** 13轮迭代证据充分。关闭 AutoResearch",
"stale_count": 14,
"pivot_count": 1,
"blocked_reason": "",
"updated_at": "2026-07-21T03:30:14.7277324Z"
}

View File

@@ -1,2 +0,0 @@
{"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"}

View File

@@ -1,9 +0,0 @@
[
{
"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
}
]

View File

@@ -1,9 +0,0 @@
{
"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"
}

View File

@@ -1,35 +0,0 @@
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);

View File

@@ -1,3 +0,0 @@
// Root entry point for Expo dev server
// Redirect to client-android
import './packages/client-android/index';

View File

@@ -1,44 +1,18 @@
# 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
```
## 格式
**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...
CI 构建 APK → ./ota/gen-manifest.sh → git push manifest-stable.json
手机 App → raw URL 拉取 manifest → 比较版本 → 下载 APK → SHA256 校验
```

12
ota/expo-manifest.json Normal file
View File

@@ -0,0 +1,12 @@
{
"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": {}
}

View File

@@ -1,54 +1,38 @@
#!/bin/bash
# gen-manifest.sh — 生成 APK 发行版 manifest
# 在 Android Studio 构建 Release APK 后运行
# 用法: bash gen-manifest.sh 6 stable
# bash gen-manifest.sh 6 all
# 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
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
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"
SHA256=$(sha256sum "$APK_PATH" | cut -d' ' -f1)
echo "APK : $APK_PATH"
echo "SHA256: $SHA256"
SIZE=$(stat -c%s "$APK_PATH")
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
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
echo "Generated: ota/manifest-stable.json"
echo " tag: ${RELEASE_TAG}"
echo " sha256: ${SHA256}"
echo " size: ${SIZE}"
echo ""
echo "Next: git tag $TAG && git push --tags"
echo " Gitea Release → upload APK"
echo " bash push-hotfix.sh $VER stable # H5同步"
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"

View File

@@ -1 +0,0 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=index-stable.html"></head><body></body></html>

View File

@@ -1 +0,0 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=index-stable.html"></head><body></body></html>

View File

@@ -1,80 +0,0 @@
<!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()">&#9881;</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
ota/manifest-beta.json Normal file
View File

@@ -0,0 +1 @@
{"version":"0.1.0","builds":{}}

View File

@@ -0,0 +1 @@
{"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
ota/manifest-stable.json Normal file
View File

@@ -0,0 +1 @@
{"version":"0.1.0","builds":{}}

View File

@@ -1 +0,0 @@
{"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"}}}

View File

@@ -1,79 +0,0 @@
#!/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

View File

@@ -1 +0,0 @@
https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/manifest.json

View File

@@ -1,286 +1,72 @@
import { StatusBar } from 'expo-status-bar';
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 };
import { useEffect, useState } from 'react';
import { StyleSheet, Text, View, ActivityIndicator } from 'react-native';
import { onAppStartCheckUpdate } from './src/updater/index';
export default function App() {
const [tab, setTab] = useState<'chat' | 'terminal' | 'settings' | 'device' | 'connection'>('chat');
const [status, setStatus] = useState<'checking' | 'up-to-date' | 'updating' | 'error'>('checking');
const [message, setMessage] = useState('正在检查更新...');
// 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`);
useEffect(() => {
(async () => {
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);
const result = await onAppStartCheckUpdate();
if (result.jsUpdate) {
setStatus('updating');
setMessage('发现 JS 更新,正在下载...(下次启动生效)');
} else if (result.apkUpdate) {
setStatus('updating');
setMessage('安装包已就绪,重启后生效');
} else {
setStatus('up-to-date');
setMessage('已是最新版本');
}
};
/** Connect/disconnect toggle */
const toggleConnection = () => {
setConnStatus(prev => {
if (prev === 'disconnected' || prev === 'error') return 'connecting';
return 'disconnected';
});
};
// ── Render ──────────────────────────────────────────────
} catch (e: any) {
setStatus('error');
setMessage(`更新检查失败: ${e.message}`);
}
})();
}, []);
return (
<KeyboardAvoidingView style={styles.root} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={styles.container}>
<StatusBar style="light" />
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Yuzu GCA</Text>
<View style={[styles.statusDot, styles[`dot_${connStatus}` as keyof typeof styles]]} />
</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>
<Text style={styles.subtitle}>Global Control Assistant</Text>
<View style={styles.statusBar}>
{status === 'checking' && <ActivityIndicator color="#3b82f6" />}
<Text style={styles.statusText}>{message}</Text>
</View>
</View>
)}
{/* ── 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({
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' },
container: {
flex: 1,
backgroundColor: '#0f172a',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
title: {
fontSize: 28,
fontWeight: '800',
color: '#e2e8f0',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#94a3b8',
marginBottom: 32,
},
statusBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
statusText: {
fontSize: 14,
color: '#94a3b8',
},
});

View File

@@ -1,2 +1,2 @@
#Wed Jul 22 19:01:51 CST 2026
gradle.version=8.13
#Mon Jul 20 13:05:50 CST 2026
gradle.version=8.10.2

View File

@@ -9,7 +9,7 @@ android {
applicationId "dev.yuzu.gca"
minSdk 24
targetSdk 35
versionCode 5
versionCode 8
versionName "0.1.0"
}

View File

@@ -1,29 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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"/>
<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">
<application
android:name=".MainApplication"
android:label="Yuzu GCA v0.1.0"
android:allowBackup="false"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.DayNight">
<activity
android:name=".MainActivity"
android:label="Yuzu GCA v0.1.0"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<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>

View File

@@ -1,84 +0,0 @@
<!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>

View File

@@ -1,58 +1,255 @@
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.util.Log
import android.webkit.WebView
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.BufferedReader
import java.io.InputStreamReader
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
class MainActivity : AppCompatActivity() {
private lateinit var logView: TextView
private var selectedChannel = "stable"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WebView(this).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.allowUniversalAccessFromFileURLs = true
addJavascriptInterface(LocalBridge(), "Android")
loadUrl("file:///android_asset/index.html")
}.also { setContentView(it) }
val scroll = ScrollView(this)
val layout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(24, 24, 24, 24)
}
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}" }
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)
}
@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}" }
fun makeChannelBtn(label: String, channel: String): Button {
return Button(this).apply {
text = label
textSize = 12f
setOnClickListener {
selectedChannel = channel
highlightChannel(channelRow, this)
}
}
}
@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()
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 | 点击[检查更新]开始")
}
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
}
// 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
}
// 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
}
// 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) }
}
}

View File

@@ -0,0 +1,5 @@
package dev.yuzu.gca
import android.app.Application
class MainApplication : Application()

View File

@@ -1,5 +0,0 @@
<?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>

View File

@@ -1,5 +0,0 @@
<?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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,6 +1,5 @@
<resources>
<color name="splashscreen_background">#0f172a</color>
<color name="iconBackground">#0f172a</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#0f172a</color>
</resources>

View File

@@ -2,4 +2,5 @@
<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>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="apk" path="/" />
</paths>

View File

@@ -12,7 +12,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle:8.13.2')
classpath("com.android.tools.build:gradle:8.7.3")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
}
}

View File

@@ -1,5 +1,56 @@
# 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
android.enableJetifier=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.
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

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -1,94 +1,26 @@
@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
@echo off
setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set 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 APP_BASE_NAME=%~n0
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% 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
if "%ERRORLEVEL%" == "0" goto execute
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
@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 %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% -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

View File

@@ -1,2 +1,3 @@
rootProject.name = 'YuzuGCA'
include ':app'

View File

@@ -3,33 +3,26 @@
"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,
"permissions": [
"REQUEST_INSTALL_PACKAGES"
]
"versionCode": 8,
"permissions": ["REQUEST_INSTALL_PACKAGES"]
},
"plugins": [
],
"extra": {
"eas": {
"projectId": "yuzu-gca-android"
}
}
"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" } }
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 B

View File

@@ -1,4 +0,0 @@
import { registerRootComponent } from 'expo';
import App from './App';
registerRootComponent(App);

View File

@@ -2,7 +2,7 @@
"name": "@yuzu-gca/client-android",
"version": "0.1.0",
"private": true,
"main": "index.js",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
@@ -17,6 +17,7 @@
"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"
},

View File

@@ -8,17 +8,9 @@
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"@yuzu-gca/shared": [
"../shared/src"
]
"@yuzu-gca/shared": ["../shared/src"]
}
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
],
"extends": "expo/tsconfig.base"
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@@ -1,20 +0,0 @@
{
"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"
}

View File

@@ -1,231 +0,0 @@
/**
* 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);
}
}

View File

@@ -1,34 +0,0 @@
/**
* 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;
}

View File

@@ -1,10 +0,0 @@
#!/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);
});

View File

@@ -1,99 +0,0 @@
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 };
}

View File

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

View File

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

View File

@@ -1,47 +0,0 @@
/**
* 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,
};

View File

@@ -1,2 +0,0 @@
export * from "./tools.js";
export * from "./config.js";

View File

@@ -1,375 +0,0 @@
/**
* 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);
}

861
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

BIN
s.png

Binary file not shown.

File diff suppressed because one or more lines are too long