4 Commits

Author SHA1 Message Date
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
105 changed files with 435 additions and 10500 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`);
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 ──────────────────────────────────────────────
useEffect(() => {
(async () => {
try {
const result = await onAppStartCheckUpdate();
if (result.jsUpdate) {
setStatus('updating');
setMessage('发现 JS 更新,正在下载...(下次启动生效)');
} else if (result.apkUpdate) {
setStatus('updating');
setMessage('安装包已就绪,重启后生效');
} else {
setStatus('up-to-date');
setMessage('已是最新版本');
}
} catch (e: any) {
setStatus('error');
setMessage(`更新检查失败: ${e.message}`);
}
})();
}, []);
return (
<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]]} />
<Text style={styles.title}>Yuzu GCA</Text>
<Text style={styles.subtitle}>Global Control Assistant</Text>
<View style={styles.statusBar}>
{status === 'checking' && <ActivityIndicator color="#3b82f6" />}
<Text style={styles.statusText}>{message}</Text>
</View>
{/* 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>
</View>
);
}
// ── 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,16 +0,0 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

View File

@@ -1,2 +1,2 @@
#Wed Jul 22 19:01:51 CST 2026
gradle.version=8.13
#Sun Jul 19 10:50:39 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,14 +0,0 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View File

@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -1,29 +1,33 @@
<?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">
<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>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
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:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</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)
}
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 | 点击[检查更新]开始")
}
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}" }
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
}
@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}" }
// 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 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()
// 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

@@ -1,3 +0,0 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
</layer-list>

View File

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splashscreen_background">#0f172a</color>
<color name="iconBackground">#0f172a</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#0f172a</color>
<color name="primary">#3b82f6</color>
<color name="background">#0f172a</color>
</resources>

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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="app_name">Yuzu GCA v0.1.0</string>
</resources>

View File

@@ -1,17 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:textColor">@android:color/black</item>
<item name="android:editTextStyle">@style/ResetEditText</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#0f172a</item>
</style>
<style name="ResetEditText" parent="@android:style/Widget.EditText">
<item name="android:padding">0dp</item>
<item name="android:textColorHint">#c8c8c8</item>
<item name="android:textColor">@android:color/black</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
</style>
</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,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,252 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

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,21 +2,21 @@
"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",
"android": "expo start --android",
"build": "eas build -p android --profile preview",
"export": "expo export --platform android",
"prebuild": "expo prebuild --platform android",
"ios": "expo run:ios"
"prebuild": "expo prebuild --platform android"
},
"dependencies": {
"@yuzu-gca/shared": "workspace:*",
"expo": "~52.0.49",
"expo": "~52.0.0",
"expo-constants": "~17.0.0",
"expo-file-system": "~18.0.0",
"expo-status-bar": "~2.0.0",
"expo-updates": "~26.0.0",
"react": "18.3.1",
"react-native": "0.76.5"
},

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";

Some files were not shown because too many files have changed in this diff Show More