Compare commits
153 Commits
v0.1.0-dav
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1a9e40d0a | ||
|
|
a7dad7debc | ||
|
|
323fd62ed4 | ||
|
|
5cf0cf40e3 | ||
|
|
d851c25edf | ||
|
|
b04238892d | ||
|
|
d75ed790cc | ||
|
|
09457ed730 | ||
|
|
cd62fd8fe4 | ||
|
|
c6ab70aaca | ||
|
|
282bc79510 | ||
|
|
37c6605582 | ||
|
|
fcfedf1ae0 | ||
|
|
12e00bf683 | ||
|
|
9f912da9fd | ||
|
|
3d82964075 | ||
|
|
490201f0d4 | ||
|
|
e29e0f7e36 | ||
|
|
ffb74262e9 | ||
|
|
4db4b6c0f3 | ||
|
|
09fde0501f | ||
|
|
ce7204b016 | ||
|
|
02967ae5c3 | ||
|
|
c407f6c483 | ||
|
|
aa45b258b2 | ||
|
|
93ca593f1b | ||
|
|
bca14ebe7b | ||
|
|
34ead6e471 | ||
|
|
8c1ba243b5 | ||
|
|
8d8d99acdd | ||
|
|
f85ce9aa43 | ||
|
|
a98d35cb1c | ||
|
|
e209db6be8 | ||
|
|
c41a3ad9d9 | ||
|
|
c6f7567eb9 | ||
|
|
f595c82983 | ||
|
|
153af75a44 | ||
|
|
7e2f2ebfbd | ||
|
|
99d8f66f52 | ||
|
|
40998f9be1 | ||
|
|
45cce458ac | ||
|
|
3a1058d10c | ||
|
|
f83bc2d524 | ||
|
|
4facecc089 | ||
|
|
b5bcf8e9c0 | ||
|
|
6ee4ff533a | ||
|
|
337c49f1f9 | ||
|
|
db0bc85609 | ||
|
|
7f7b75c0fc | ||
|
|
e9bcb7d804 | ||
|
|
4b4103cf06 | ||
|
|
9e4d9e7dd7 | ||
|
|
439c9066e2 | ||
|
|
1160fdd03b | ||
|
|
0984082ac0 | ||
|
|
c4bf1de467 | ||
|
|
fe182e6f3d | ||
|
|
ad918e3a5f | ||
|
|
5e1badce1f | ||
|
|
b0564a667d | ||
|
|
c3077a4927 | ||
|
|
38a60eae8f | ||
|
|
a4fa268b9f | ||
|
|
f4b0feb9d1 | ||
|
|
39d7e82a55 | ||
|
|
f56024f7c0 | ||
|
|
b88311a2d0 | ||
|
|
db8cfdf6e3 | ||
|
|
bb1e9ede2a | ||
|
|
9ba8c296b0 | ||
|
|
9e0d59895b | ||
|
|
049d2ccaaf | ||
| 01a8a76805 | |||
| 2ea30be977 | |||
| 18eeb07f66 | |||
| 61c6d32192 | |||
|
|
8f078e4f30 | ||
|
|
e31bc3dcb9 | ||
|
|
8fb84c2efb | ||
|
|
eccfaa7f66 | ||
|
|
1ef40900a8 | ||
|
|
cdf9b9d499 | ||
|
|
b4ddb0b5c7 | ||
|
|
f9d5d32785 | ||
|
|
67adde25a4 | ||
| 70c535d0c1 | |||
| 1b1a586ccc | |||
|
|
5b9a996759 | ||
|
|
f4ee38f39f | ||
|
|
7f22d4028f | ||
|
|
19860bdec7 | ||
|
|
af799da3ea | ||
|
|
e39a250fbe | ||
|
|
565b1a5ea1 | ||
|
|
a47b73dda7 | ||
|
|
bfd7bee2ae | ||
|
|
bf72ea937f | ||
|
|
96d88a9601 | ||
|
|
123824a734 | ||
|
|
4d6bb3ff6f | ||
|
|
8f2f90df0d | ||
|
|
9a2f502857 | ||
|
|
3b6c6a4923 | ||
|
|
7487158efd | ||
| 454772a343 | |||
| e5b40c028d | |||
| 12e1abbfb8 | |||
|
|
ead2045ce5 | ||
|
|
42b3592ed9 | ||
|
|
49793529c0 | ||
|
|
6f8b346e73 | ||
|
|
8f04acaf98 | ||
|
|
0727aa33f1 | ||
|
|
cd1f19815d | ||
|
|
de20e837fa | ||
|
|
bfa365c2c2 | ||
|
|
898fbc0c3c | ||
|
|
f052d280ce | ||
|
|
1684fac260 | ||
|
|
a2f204f5ee | ||
|
|
eeea966400 | ||
|
|
d68e6ca6df | ||
|
|
a96560966c | ||
|
|
ffd05e3e17 | ||
|
|
40d0c86292 | ||
|
|
e8f00faf41 | ||
|
|
b70c5015a0 | ||
|
|
14367a229b | ||
|
|
e02997db52 | ||
|
|
82a0650b59 | ||
|
|
0d3fa632e6 | ||
|
|
ad1b3e3aca | ||
|
|
0f37573faf | ||
|
|
49d3c187d0 | ||
|
|
3da606671a | ||
|
|
1461acd292 | ||
|
|
a7b6b455d1 | ||
|
|
c62e2a4430 | ||
|
|
20d81d5ab8 | ||
|
|
c1849802b6 | ||
|
|
31a56708d6 | ||
|
|
1b3d2fc06f | ||
|
|
db5b7de50f | ||
|
|
3244876c8c | ||
|
|
fcc51f4356 | ||
|
|
70e39454df | ||
|
|
d4169538c9 | ||
|
|
a3f0f975a6 | ||
|
|
109ad813e6 | ||
|
|
34cdec130f | ||
|
|
eab872ff09 | ||
|
|
3f8be6587e | ||
|
|
74619a2713 |
11
.gitignore
vendored
@@ -39,9 +39,18 @@ src-tauri/target/
|
||||
|
||||
# Android
|
||||
android/app/build/
|
||||
android/.gradle/
|
||||
**/android/.gradle/
|
||||
android/build/
|
||||
*.hprof
|
||||
*.jks
|
||||
*.keystore
|
||||
local.properties
|
||||
screen.png
|
||||
|
||||
# Temp/debug files
|
||||
*.traineddata
|
||||
tessdata_temp/
|
||||
*.ps1
|
||||
*_temp/
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 325 KiB |
@@ -0,0 +1,2 @@
|
||||
{"status":"starting_turn","iteration":0,"created_at":"2026-07-20T16:59:22.3318065Z"}
|
||||
{"status":"turn_done","iteration":1,"created_at":"2026-07-20T16:59:51.4961797Z"}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"fingerprint": "15-127-0-0-1-10808-android-studio-sync-project-7bf5ff43",
|
||||
"summary": "代理是根因(15 个失败都是因为 `127.0.0.1:10808` 代理)。已移除。在 Android Studio 里点 **大象图标**(Sync Project)重新同步,",
|
||||
"first_seen_iteration": 1,
|
||||
"last_seen_iteration": 1,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"status": "running",
|
||||
"iteration": 1,
|
||||
"current_direction": "代理是根因(15 个失败都是因为 `127.0.0.1:10808` 代理)。已移除。在 Android Studio 里点 **大象图标**(Sync Project)重新同步,",
|
||||
"stale_count": 1,
|
||||
"pivot_count": 0,
|
||||
"blocked_reason": "",
|
||||
"updated_at": "2026-07-20T16:59:51.4929941Z"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{"status":"starting_turn","iteration":0,"created_at":"2026-07-21T03:16:15.2382547Z"}
|
||||
{"status":"turn_done","iteration":1,"created_at":"2026-07-21T03:17:05.8410329Z"}
|
||||
{"status":"starting_turn","iteration":1,"created_at":"2026-07-21T03:17:06.1408318Z"}
|
||||
{"status":"turn_done","iteration":2,"created_at":"2026-07-21T03:17:34.0830711Z"}
|
||||
{"status":"starting_turn","iteration":2,"created_at":"2026-07-21T03:17:34.8204313Z"}
|
||||
{"status":"turn_done","iteration":3,"created_at":"2026-07-21T03:18:32.1678783Z"}
|
||||
{"status":"starting_turn","iteration":3,"created_at":"2026-07-21T03:18:32.1884204Z"}
|
||||
{"status":"turn_done","iteration":4,"created_at":"2026-07-21T03:19:08.6392005Z"}
|
||||
{"status":"starting_turn","iteration":4,"created_at":"2026-07-21T03:19:08.7309788Z"}
|
||||
{"status":"turn_done","iteration":5,"created_at":"2026-07-21T03:19:33.0643356Z"}
|
||||
{"status":"starting_turn","iteration":5,"created_at":"2026-07-21T03:19:33.2245358Z"}
|
||||
{"status":"turn_done","iteration":6,"created_at":"2026-07-21T03:20:56.4286355Z"}
|
||||
{"status":"starting_turn","iteration":6,"created_at":"2026-07-21T03:20:56.5224447Z"}
|
||||
{"status":"turn_done","iteration":7,"created_at":"2026-07-21T03:22:00.7927224Z"}
|
||||
{"status":"starting_turn","iteration":7,"created_at":"2026-07-21T03:22:00.8862815Z"}
|
||||
{"status":"turn_done","iteration":8,"created_at":"2026-07-21T03:22:46.0575234Z"}
|
||||
{"status":"starting_turn","iteration":8,"created_at":"2026-07-21T03:22:46.1718312Z"}
|
||||
{"status":"turn_done","iteration":9,"created_at":"2026-07-21T03:24:13.7257985Z"}
|
||||
{"status":"starting_turn","iteration":9,"created_at":"2026-07-21T03:24:13.8277354Z"}
|
||||
{"status":"turn_done","iteration":10,"created_at":"2026-07-21T03:24:27.5537437Z"}
|
||||
{"status":"starting_turn","iteration":10,"created_at":"2026-07-21T03:24:27.6571627Z"}
|
||||
{"status":"turn_done","iteration":11,"created_at":"2026-07-21T03:25:19.3030817Z"}
|
||||
{"status":"starting_turn","iteration":11,"created_at":"2026-07-21T03:25:19.4076559Z"}
|
||||
{"status":"turn_done","iteration":12,"created_at":"2026-07-21T03:29:18.0058775Z"}
|
||||
{"status":"starting_turn","iteration":12,"created_at":"2026-07-21T03:29:18.1383267Z"}
|
||||
{"status":"turn_done","iteration":13,"created_at":"2026-07-21T03:30:01.3929977Z"}
|
||||
{"status":"starting_turn","iteration":13,"created_at":"2026-07-21T03:30:01.5037583Z"}
|
||||
{"status":"turn_done","iteration":14,"created_at":"2026-07-21T03:30:14.626083Z"}
|
||||
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"fingerprint": "as-build-ad2624ab",
|
||||
"summary": "已修复两个配置。在 AS 里重新 Build:",
|
||||
"first_seen_iteration": 1,
|
||||
"last_seen_iteration": 1,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "as-build-20a5fde3",
|
||||
"summary": "在 AS 重新 Build。这两步被彻底跳过。",
|
||||
"first_seen_iteration": 2,
|
||||
"last_seen_iteration": 2,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "task",
|
||||
"summary": "---",
|
||||
"first_seen_iteration": 3,
|
||||
"last_seen_iteration": 3,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "android-studio-build-release-expo-updates-root-8e271660",
|
||||
"summary": "用 Android Studio 重新 Build Release。修复了 `expo-updates` 路径加倍问题(缺少 `root` 配置项):",
|
||||
"first_seen_iteration": 4,
|
||||
"last_seen_iteration": 4,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "root-createreleaseupdatesresources-packages-client-andro-c0afb42a",
|
||||
"summary": "`root` 设定没有解决。`createReleaseUpdatesResources` 仍然报双重路径 `packages\\client-android\\packages\\client-android\\App.tsx`——这个任务不",
|
||||
"first_seen_iteration": 5,
|
||||
"last_seen_iteration": 5,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "task-5b2466d1",
|
||||
"summary": "两个修复就位:",
|
||||
"first_seen_iteration": 6,
|
||||
"last_seen_iteration": 6,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "android-studio-build-release-c00a91b0",
|
||||
"summary": "**现在请用 Android Studio 重新 Build Release。**",
|
||||
"first_seen_iteration": 7,
|
||||
"last_seen_iteration": 7,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "android-studio-build-release-7002dec6",
|
||||
"summary": "**请用 Android Studio 重新 Build Release。**",
|
||||
"first_seen_iteration": 8,
|
||||
"last_seen_iteration": 8,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "build-gradle-afterevaluate-js-bundle-ae4afdad",
|
||||
"summary": "`build.gradle` 已清理为干净的单一 `afterEvaluate` 块。JS bundle 已就位。",
|
||||
"first_seen_iteration": 9,
|
||||
"last_seen_iteration": 9,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "stale-9-tasks-named-configure-enabled-false-afterevaluat-49c894d6",
|
||||
"summary": "stale=9。`tasks.named().configure { enabled = false }` 在 `afterEvaluate` 中都无效——这两个任务由 Gradle 插件在配置阶段强制注册,无<EFBC8C>",
|
||||
"first_seen_iteration": 10,
|
||||
"last_seen_iteration": 10,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "android-studio-build-release-0ac7a51a",
|
||||
"summary": "**现在用 Android Studio 直接 Build Release——不用加任何参数。**",
|
||||
"first_seen_iteration": 11,
|
||||
"last_seen_iteration": 11,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "redmi-usb-79ba1bcf",
|
||||
"summary": "卸载成功。安装受阻——Redmi USB 安装限制。**请解锁手机屏幕,点\"允许\"后告诉我。**",
|
||||
"first_seen_iteration": 12,
|
||||
"last_seen_iteration": 12,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "android-studio-feat-expo-ota-12-main-apk-usb-45c0be45",
|
||||
"summary": "Android Studio 里的项目还是 `feat/expo-ota`(所以 12 次失败)。我这边 main 的 APK 已经好了——安装不过去是手机 USB 权限。",
|
||||
"first_seen_iteration": 13,
|
||||
"last_seen_iteration": 13,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"fingerprint": "13-autoresearch-bafea58b",
|
||||
"summary": "**结论已定。** 13轮迭代,证据充分。关闭 AutoResearch:",
|
||||
"first_seen_iteration": 14,
|
||||
"last_seen_iteration": 14,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"status": "complete",
|
||||
"iteration": 14,
|
||||
"current_direction": "**结论已定。** 13轮迭代,证据充分。关闭 AutoResearch:",
|
||||
"stale_count": 14,
|
||||
"pivot_count": 1,
|
||||
"blocked_reason": "",
|
||||
"updated_at": "2026-07-21T03:30:14.7277324Z"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"status":"starting_turn","iteration":0,"created_at":"2026-07-21T03:15:01.5344339Z"}
|
||||
{"status":"turn_done","iteration":1,"created_at":"2026-07-21T03:16:14.9716471Z"}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"fingerprint": "910kb-bundle-android-studio-build-generate-signed-bundle-0bdca4e3",
|
||||
"summary": "910KB bundle 已放好。Android Studio 里重新 Build → Generate Signed Bundle / APK → release → Finish。这次不会超时也不会 Metro 报错。",
|
||||
"first_seen_iteration": 1,
|
||||
"last_seen_iteration": 1,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"status": "running",
|
||||
"iteration": 1,
|
||||
"current_direction": "910KB bundle 已放好。Android Studio 里重新 Build → Generate Signed Bundle / APK → release → Finish。这次不会超时也不会 Metro 报错。",
|
||||
"stale_count": 1,
|
||||
"pivot_count": 0,
|
||||
"blocked_reason": "",
|
||||
"updated_at": "2026-07-21T03:16:14.9681754Z"
|
||||
}
|
||||
8
.reasonix/desktop-topic-auto-title-meta.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"topic_20260718-152740_2cab7b15ea20700d": {
|
||||
"stage": 3,
|
||||
"userTurns": 3,
|
||||
"basisHash": "942c58e1a7d2413a",
|
||||
"updatedAt": 1784389797592
|
||||
}
|
||||
}
|
||||
3
.reasonix/desktop-topic-created-at.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"topic_20260718-152740_2cab7b15ea20700d": 1784388460704
|
||||
}
|
||||
3
.reasonix/desktop-topic-title-sources.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"topic_20260718-152740_2cab7b15ea20700d": "auto"
|
||||
}
|
||||
3
.reasonix/desktop-topic-titles.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"topic_20260718-152740_2cab7b15ea20700d": "Start pursuing the…"
|
||||
}
|
||||
35
cdp-read.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
|
||||
http.get('http://localhost:9223/json', res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => {
|
||||
const pages = JSON.parse(d);
|
||||
const wsUrl = pages[0].webSocketDebuggerUrl;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({id: 1, method: 'Runtime.evaluate', params: {expression: 'document.getElementById("ver").innerText'}}));
|
||||
});
|
||||
ws.on('message', data => {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.id === 1) {
|
||||
console.log('VERSION:', msg.result?.result?.value ?? JSON.stringify(msg.result));
|
||||
ws.send(JSON.stringify({id: 2, method: 'Runtime.evaluate', params: {expression: 'localStorage.getItem("gca_ver") || "none"'}}));
|
||||
}
|
||||
if (msg.id === 2) {
|
||||
console.log('LOCALSTORAGE gca_ver:', msg.result?.result?.value ?? JSON.stringify(msg.result));
|
||||
ws.send(JSON.stringify({id: 3, method: 'Runtime.evaluate', params: {expression: 'document.getElementById("log").innerText'}}));
|
||||
}
|
||||
if (msg.id === 3) {
|
||||
console.log('LOG:', msg.result?.result?.value?.substring(0, 500) ?? JSON.stringify(msg.result));
|
||||
ws.close();
|
||||
process.exit(0);
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
});
|
||||
}).on('error', e => { console.log('HTTP ERR:', e.message); process.exit(1); });
|
||||
|
||||
setTimeout(() => process.exit(0), 8000);
|
||||
3
index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Root entry point for Expo dev server
|
||||
// Redirect to client-android
|
||||
import './packages/client-android/index';
|
||||
44
ota/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# OTA 热更新目录
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `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` | 可下载的热更新页面 |
|
||||
|
||||
## 推送补丁
|
||||
|
||||
```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...
|
||||
```
|
||||
54
ota/gen-manifest.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# gen-manifest.sh — 生成 APK 发行版 manifest
|
||||
# 在 Android Studio 构建 Release APK 后运行
|
||||
# 用法: bash gen-manifest.sh 6 stable
|
||||
# bash gen-manifest.sh 6 all
|
||||
|
||||
set -e
|
||||
|
||||
VER="$1"
|
||||
shift
|
||||
CHANNELS=("$@")
|
||||
|
||||
if [ -z "$VER" ] || [ ${#CHANNELS[@]} -eq 0 ]; then
|
||||
echo "用法: $0 <构建号> <渠道...>"
|
||||
echo "示例: $0 6 stable beta nightly"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OTA_DIR="$(dirname "$0")"
|
||||
APK_PATH="$OTA_DIR/../packages/client-android/android/app/build/outputs/apk/debug/v0.1.0-dav-android-${VER}-debug.apk"
|
||||
BASE="https://git.childish-ghost.com/LukeMackin/Yuzu-GCA"
|
||||
TAG="v0.1.0-dav-android-${VER}"
|
||||
APK_NAME="v0.1.0-dav-android-${VER}-debug.apk"
|
||||
DL_URL="${BASE}/releases/download/${TAG}/${APK_NAME}"
|
||||
|
||||
if [ ! -f "$APK_PATH" ]; then
|
||||
echo "APK not found: $APK_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SHA256=$(sha256sum "$APK_PATH" | cut -d' ' -f1)
|
||||
echo "APK : $APK_PATH"
|
||||
echo "SHA256: $SHA256"
|
||||
|
||||
for CH in "${CHANNELS[@]}"; do
|
||||
if [ "$CH" = "all" ]; then
|
||||
for CH in stable beta nightly; do
|
||||
cat > "$OTA_DIR/manifest-${CH}.json" <<EOF
|
||||
{"version":"0.1.0-dav-android-${VER}","versionCode":${VER},"url":"${DL_URL}","sha256":"${SHA256}"}
|
||||
EOF
|
||||
echo " ✓ manifest-${CH}.json"
|
||||
done
|
||||
break
|
||||
fi
|
||||
cat > "$OTA_DIR/manifest-${CH}.json" <<EOF
|
||||
{"version":"0.1.0-dav-android-${VER}","versionCode":${VER},"url":"${DL_URL}","sha256":"${SHA256}"}
|
||||
EOF
|
||||
echo " ✓ manifest-${CH}.json"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Next: git tag $TAG && git push --tags"
|
||||
echo " Gitea Release → upload APK"
|
||||
echo " bash push-hotfix.sh $VER stable # H5同步"
|
||||
1
ota/h5/index-beta.html
Normal file
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=index-stable.html"></head><body></body></html>
|
||||
1
ota/h5/index-nightly.html
Normal file
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=index-stable.html"></head><body></body></html>
|
||||
80
ota/h5/index-stable.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui;background:#0f172a;color:#e2e8f0;padding:8px 16px 16px;margin:0;overflow-x:hidden}
|
||||
h1{font-size:18px;color:#38bdf8}
|
||||
.card{background:#1e293b;border-radius:8px;padding:16px;margin:12px 0}
|
||||
.card-title{font-size:14px;color:#94a3b8;margin-bottom:8px}
|
||||
.btn{display:block;width:100%;padding:14px;margin:8px 0;border:none;border-radius:8px;font-size:16px;cursor:pointer}
|
||||
.btn-check{background:#1e40af;color:#fff}
|
||||
.btn-download{background:#15803d;color:#fff;display:none}
|
||||
.btn-download.show{display:block}
|
||||
.log{background:#0f172a;padding:12px;border-radius:8px;margin-top:12px;max-height:200px;overflow-y:auto;font-size:11px;white-space:pre-wrap;color:#94a3b8}
|
||||
.ver{color:#94a3b8;font-size:12px;margin:4px 0}
|
||||
.gear{position:absolute;top:20px;right:16px;font-size:20px;cursor:pointer;color:#94a3b8}
|
||||
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.7);z-index:99}
|
||||
.modal.show{display:flex;align-items:center;justify-content:center}
|
||||
.modal-box{background:#1e293b;border-radius:12px;padding:24px;width:80%;max-width:360px}
|
||||
.modal-title{font-size:16px;color:#38bdf8;margin-bottom:16px}
|
||||
.ch-opt{display:block;width:100%;padding:12px;margin:6px 0;border:1px solid #334155;border-radius:6px;background:transparent;color:#e2e8f0;font-size:14px;cursor:pointer;text-align:left}
|
||||
.ch-opt.on{border-color:#38bdf8;background:#1e40af40}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;margin-left:8px}
|
||||
.badge-new{background:#dc2626;color:#fff}
|
||||
</style></head><body>
|
||||
<div class="gear" onclick="openSettings()">⚙</div>
|
||||
<h1>Yuzu GCA</h1>
|
||||
<div class="ver"><span id="ver"></span><span id="badge" class="badge badge-new" style="display:none">NEW</span></div>
|
||||
<div class="card"><div class="card-title">Channel: <span id="chLabel">Stable</span></div></div>
|
||||
<button class="btn btn-check" onclick="checkUpdate()">Check Update</button>
|
||||
<button class="btn btn-download" id="btnApply" onclick="applyUpdate()">Download Update</button>
|
||||
<div class="log" id="log"></div>
|
||||
|
||||
<div class="modal" id="modal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">Select Channel</div>
|
||||
<button class="ch-opt on" onclick="setChannel('stable')">Stable</button>
|
||||
<button class="ch-opt" onclick="setChannel('beta')">Beta</button>
|
||||
<button class="ch-opt" onclick="setChannel('nightly')">Nightly</button>
|
||||
<button class="btn btn-check" style="margin-top:12px" onclick="closeSettings()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var channel=localStorage.getItem('gca_channel')||'stable';
|
||||
document.getElementById('chLabel').innerText=(localStorage.getItem('gca_channel')||'stable').charAt(0).toUpperCase()+(localStorage.getItem('gca_channel')||'stable').slice(1);
|
||||
var dlUrl='',dlVer='',dlSha='';
|
||||
function log(m){var l=document.getElementById('log');l.innerText+=m+'\n';l.scrollTop=l.scrollHeight}
|
||||
function openSettings(){
|
||||
var m=document.getElementById('modal');m.classList.add('show');
|
||||
document.querySelectorAll('.ch-opt').forEach(function(b){b.classList.toggle('on',b.textContent.toLowerCase()===channel)})
|
||||
}
|
||||
function closeSettings(){document.getElementById('modal').classList.remove('show')}
|
||||
function setChannel(ch){
|
||||
channel=ch;localStorage.setItem('gca_channel',ch);
|
||||
document.getElementById('chLabel').innerText=ch.charAt(0).toUpperCase()+ch.slice(1);
|
||||
document.querySelectorAll('.ch-opt').forEach(function(b){b.classList.toggle('on',b.textContent.toLowerCase()===ch)});
|
||||
closeSettings();checkUpdate()
|
||||
}
|
||||
function checkUpdate(){
|
||||
log('channel:'+channel);
|
||||
var j=JSON.parse(Android.fetchManifest(channel));
|
||||
if(j.error){log('ERR:'+j.error);return}
|
||||
var h5=j.h5;log('latest:'+h5.latest+' min:'+h5.min_version);
|
||||
var v=(h5.min_version||'').replace(/[^0-9]/g,''),my=(document.getElementById('ver').innerText||'').replace(/[^0-9]/g,'');
|
||||
if(parseInt(my||'0')<parseInt(v||'0')){log('ERR:APK too old (need v'+h5.min_version+')');return}
|
||||
dlUrl=h5.url;dlSha=h5.sha256||'';
|
||||
if(!dlSha||/^(0{64}|TBD)/.test(dlSha)){log('ERR:channel no build');return}
|
||||
if(h5.latest!==document.getElementById('ver').innerText&&h5.latest!=='v'+document.getElementById('ver').innerText){
|
||||
dlVer=h5.latest;document.getElementById('btnApply').classList.add('show');log('***NEW VERSION***')}
|
||||
else log('up to date')
|
||||
}
|
||||
function applyUpdate(){
|
||||
log('download...');
|
||||
var t=Android.fetchText('h5/index-stable.html');
|
||||
if(typeof t!=='string'||t.startsWith('ERROR:')){log('ERR:'+t);return}
|
||||
var h=Android.sha256(t);
|
||||
if(h!==dlSha){log('SHA256 MISMATCH!');return}
|
||||
Android.saveCache(t);log('OK! restart app')
|
||||
}
|
||||
document.getElementById('ver').innerText=Android.fetchManifest(channel).match(/"latest":"([^"]+)"/)?.[1]||channel;
|
||||
</script></body></html>
|
||||
1
ota/manifest.json
Normal file
@@ -0,0 +1 @@
|
||||
{"stable":{"h5":{"latest":"0.1.0-dav-android-6","min_version":"0.1.0-dav-android-5","url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/h5/index-stable.html","sha256":"ae7db3b84ad018fbc4cc938c30db482dd4c3cad47aab6db81c8a6716cfadc844"},"apk":{"version":"0.1.0-dav-android-5","versionCode":5,"url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-5/v0.1.0-dav-android-5-debug.apk","sha256":"56b0e384f53015b0a1a5eb2507fd792dfc31fe44aff40342cd8a8d3bd5c1e14a"}},"beta":{"h5":{"latest":"0.1.0-dav-android-6","min_version":"0.1.0-dav-android-5","url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/h5/index-stable.html","sha256":"ae7db3b84ad018fbc4cc938c30db482dd4c3cad47aab6db81c8a6716cfadc844"},"apk":{"version":"0.1.0-dav-android-5","versionCode":5,"url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-5/v0.1.0-dav-android-5-debug.apk","sha256":"56b0e384f53015b0a1a5eb2507fd792dfc31fe44aff40342cd8a8d3bd5c1e14a"}},"nightly":{"h5":{"latest":"0.1.0-dav-android-7","min_version":"0.1.0-dav-android-5","url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/h5/index-stable.html","sha256":"ae7db3b84ad018fbc4cc938c30db482dd4c3cad47aab6db81c8a6716cfadc844"},"apk":{"version":"0.1.0-dav-android-5","versionCode":5,"url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-5/v0.1.0-dav-android-5-debug.apk","sha256":"56b0e384f53015b0a1a5eb2507fd792dfc31fe44aff40342cd8a8d3bd5c1e14a"}}}
|
||||
79
ota/push-hotfix.sh
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# push-hotfix.sh — 推送 H5 热更新补丁到 Gitea
|
||||
#
|
||||
# 用法:
|
||||
# ./push-hotfix.sh 7 stable # 只更新 stable 渠道
|
||||
# ./push-hotfix.sh 7 all # 更新全部三个渠道
|
||||
# ./push-hotfix.sh 7 beta nightly # 更新指定渠道
|
||||
#
|
||||
# 流程:
|
||||
# 1. 创建 ota/h5/index-v{VER}.html(如果不存在则从模板生成)
|
||||
# 2. 计算 SHA256
|
||||
# 3. 更新 ota/h5-manifest-{CHANNEL}.json
|
||||
# 4. 如果是稳定版,更新 ota/version.txt
|
||||
# 5. git add + commit + push
|
||||
|
||||
set -e
|
||||
|
||||
VER="$1"
|
||||
shift
|
||||
CHANNELS=("$@")
|
||||
|
||||
if [ -z "$VER" ] || [ ${#CHANNELS[@]} -eq 0 ]; then
|
||||
echo "用法: $0 <版本号> <渠道...>"
|
||||
echo "示例: $0 7 stable beta nightly"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OTA_DIR="$(dirname "$0")"
|
||||
H5_DIR="$OTA_DIR/h5"
|
||||
BASE_URL="https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota"
|
||||
|
||||
# 1. 创建 HTML 文件(如果不存在)
|
||||
HTML_FILE="$H5_DIR/index-v${VER}.html"
|
||||
if [ ! -f "$HTML_FILE" ]; then
|
||||
echo "→ 请手动创建 $HTML_FILE 或从 index-v6.html 复制修改"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 计算 SHA256
|
||||
SHA256=$(sha256sum "$HTML_FILE" | cut -d' ' -f1)
|
||||
echo "SHA256: $SHA256"
|
||||
|
||||
# 3. 更新 manifest
|
||||
FULL_URL="${BASE_URL}/h5/index-v${VER}.html"
|
||||
|
||||
for CH in "${CHANNELS[@]}"; do
|
||||
if [ "$CH" = "all" ]; then
|
||||
for CH in stable beta nightly; do
|
||||
MANIFEST="$OTA_DIR/h5-manifest-${CH}.json"
|
||||
cat > "$MANIFEST" <<EOF
|
||||
{"latest":"0.1.0-dav-android-${VER}","min_version":"0.1.0-dav-android-5","url":"${FULL_URL}","sha256":"${SHA256}"}
|
||||
EOF
|
||||
echo " ✓ h5-manifest-${CH}.json"
|
||||
done
|
||||
break
|
||||
fi
|
||||
MANIFEST="$OTA_DIR/h5-manifest-${CH}.json"
|
||||
cat > "$MANIFEST" <<EOF
|
||||
{"latest":"0.1.0-dav-android-${VER}","min_version":"0.1.0-dav-android-5","url":"${FULL_URL}","sha256":"${SHA256}"}
|
||||
EOF
|
||||
echo " ✓ h5-manifest-${CH}.json"
|
||||
done
|
||||
|
||||
# 4. 稳定版更新 version.txt
|
||||
if [[ " ${CHANNELS[*]} " =~ " stable " ]] || [[ " ${CHANNELS[*]} " =~ " all " ]]; then
|
||||
echo "0.1.0-dav-android-${VER}|${FULL_URL}|${SHA256}" > "$OTA_DIR/version.txt"
|
||||
echo " ✓ version.txt"
|
||||
fi
|
||||
|
||||
# 5. git push
|
||||
echo ""
|
||||
echo "commit & push?"
|
||||
read -p "(y/N) " yn
|
||||
if [ "$yn" = "y" ] || [ "$yn" = "Y" ]; then
|
||||
git add "$OTA_DIR"
|
||||
git commit -m "hotfix: v${VER} → ${CHANNELS[*]}"
|
||||
git push
|
||||
echo "✓ pushed"
|
||||
fi
|
||||
1
ota/version.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/manifest.json
|
||||
@@ -1,72 +1,286 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, View, ActivityIndicator } from 'react-native';
|
||||
import { onAppStartCheckUpdate } from './src/updater/index';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
StyleSheet, Text, View, TextInput, TouchableOpacity,
|
||||
ScrollView, Switch, FlatList, KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import { DEFAULT_GATEWAY_CONFIG } from '@yuzu-gca/shared';
|
||||
|
||||
/** Android Client: Chat + Settings + Connection */
|
||||
|
||||
type Msg = { role: 'user' | 'assistant'; text: string; ts: number };
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState<'checking' | 'up-to-date' | 'updating' | 'error'>('checking');
|
||||
const [message, setMessage] = useState('正在检查更新...');
|
||||
const [tab, setTab] = useState<'chat' | 'terminal' | 'settings' | 'device' | 'connection'>('chat');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const result = await onAppStartCheckUpdate();
|
||||
if (result.jsUpdate) {
|
||||
setStatus('updating');
|
||||
setMessage('发现 JS 更新,正在下载...(下次启动生效)');
|
||||
} else if (result.apkUpdate) {
|
||||
setStatus('updating');
|
||||
setMessage('安装包已就绪,重启后生效');
|
||||
} else {
|
||||
setStatus('up-to-date');
|
||||
setMessage('已是最新版本');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setStatus('error');
|
||||
setMessage(`更新检查失败: ${e.message}`);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
// Chat state
|
||||
const [messages, setMessages] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// Settings state
|
||||
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_GATEWAY_CONFIG.url);
|
||||
const [deviceName, setDeviceName] = useState('Android Device');
|
||||
const [autoConnect, setAutoConnect] = useState(true);
|
||||
const [heartbeatInterval, setHeartbeatInterval] = useState(String(DEFAULT_GATEWAY_CONFIG.heartbeatInterval / 1000));
|
||||
const [logLevel, setLogLevel] = useState('info');
|
||||
|
||||
// Connection state
|
||||
const [connStatus, setConnStatus] = useState<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected');
|
||||
|
||||
// Terminal state
|
||||
const [termCmd, setTermCmd] = useState('');
|
||||
const [termOut, setTermOut] = useState('Execute remote commands via Gateway MCP.\nType a shell command and press Execute.\n');
|
||||
const [termRunning, setTermRunning] = useState(false);
|
||||
const runTermCmd = async () => {
|
||||
if (!termCmd.trim() || termRunning) return;
|
||||
const c = termCmd.trim(); setTermCmd(''); setTermRunning(true);
|
||||
setTermOut(o => o + `$ ${c}\n`);
|
||||
try {
|
||||
const r = await fetch(`${gatewayUrl}/tools/exec`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({command:c}), signal:AbortSignal.timeout(30000) });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const d = await r.json() as any;
|
||||
setTermOut(o => o + (d.stdout||d.stderr||JSON.stringify(d)) + '\n');
|
||||
} catch(e:any) { setTermOut(o => o + `Error: ${e.message}\n`); }
|
||||
finally { setTermRunning(false); }
|
||||
};
|
||||
|
||||
/** Send chat message to Gateway */
|
||||
const sendMessage = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
const userMsg: Msg = { role: 'user', text, ts: Date.now() };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json() as { reply?: string; error?: string };
|
||||
const aiMsg: Msg = {
|
||||
role: 'assistant',
|
||||
text: data.reply ?? data.error ?? 'No response',
|
||||
ts: Date.now(),
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
} catch (e: any) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
text: `Error: ${e.message}`,
|
||||
ts: Date.now(),
|
||||
}]);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Connect/disconnect toggle */
|
||||
const toggleConnection = () => {
|
||||
setConnStatus(prev => {
|
||||
if (prev === 'disconnected' || prev === 'error') return 'connecting';
|
||||
return 'disconnected';
|
||||
});
|
||||
};
|
||||
|
||||
// ── Render ──────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<KeyboardAvoidingView style={styles.root} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||
<StatusBar style="light" />
|
||||
<Text style={styles.title}>Yuzu GCA</Text>
|
||||
<Text style={styles.subtitle}>Global Control Assistant</Text>
|
||||
<View style={styles.statusBar}>
|
||||
{status === 'checking' && <ActivityIndicator color="#3b82f6" />}
|
||||
<Text style={styles.statusText}>{message}</Text>
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Yuzu GCA</Text>
|
||||
<View style={[styles.statusDot, styles[`dot_${connStatus}` as keyof typeof styles]]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={styles.tabBar}>
|
||||
{(['chat', 'terminal', 'settings', 'device', 'connection'] as const).map(t => (
|
||||
<TouchableOpacity key={t} onPress={() => setTab(t)} style={[styles.tab, tab === t && styles.tabActive]}>
|
||||
<Text style={[styles.tabText, tab === t && styles.tabTextActive]}>
|
||||
{t === 'chat' ? 'Chat' : t === 'terminal' ? 'Term' : t === 'settings' ? 'Gateway' : t === 'device' ? 'Device' : (connStatus === 'connected' ? '🟢' : '⚫')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* ── Chat Tab ── */}
|
||||
{tab === 'chat' && (
|
||||
<View style={styles.chatContainer}>
|
||||
<FlatList
|
||||
data={messages}
|
||||
keyExtractor={(_, i) => String(i)}
|
||||
style={styles.chatList}
|
||||
renderItem={({ item }) => (
|
||||
<View style={[styles.bubble, item.role === 'user' ? styles.bubbleUser : styles.bubbleAI]}>
|
||||
<Text style={styles.bubbleRole}>{item.role === 'user' ? 'You' : 'GCA'}</Text>
|
||||
<Text style={styles.bubbleText}>{item.text}</Text>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.emptyText}>Send a message to control your devices via AI</Text>
|
||||
}
|
||||
/>
|
||||
<View style={styles.inputBar}>
|
||||
<TextInput
|
||||
style={styles.chatInput}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder="Type a command..."
|
||||
placeholderTextColor="#475569"
|
||||
onSubmitEditing={sendMessage}
|
||||
returnKeyType="send"
|
||||
/>
|
||||
<TouchableOpacity style={[styles.sendBtn, sending && styles.sendBtnDisabled]} onPress={sendMessage} disabled={sending}>
|
||||
<Text style={styles.sendBtnText}>{sending ? '...' : 'Send'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Terminal Tab ── */}
|
||||
{tab === 'terminal' && (
|
||||
<View style={styles.chatContainer}>
|
||||
<ScrollView style={styles.termOutput}>
|
||||
<Text style={styles.termText} selectable>{termOut}</Text>
|
||||
</ScrollView>
|
||||
<View style={styles.inputBar}>
|
||||
<TextInput style={styles.chatInput} value={termCmd} onChangeText={setTermCmd} placeholder="ls -la /home" placeholderTextColor="#475569" autoCapitalize="none" autoCorrect={false} onSubmitEditing={runTermCmd} returnKeyType="send" />
|
||||
<TouchableOpacity style={[styles.sendBtn, termRunning && styles.sendBtnDisabled]} onPress={runTermCmd} disabled={termRunning}>
|
||||
<Text style={styles.sendBtnText}>{termRunning ? '...' : 'Run'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Settings Tab ── */}
|
||||
{tab === 'settings' && (
|
||||
<ScrollView style={styles.body}>
|
||||
<Text style={styles.label}>Gateway URL</Text>
|
||||
<TextInput style={styles.input} value={gatewayUrl} onChangeText={setGatewayUrl} placeholder="http://localhost:3000" placeholderTextColor="#475569" autoCapitalize="none" keyboardType="url" />
|
||||
|
||||
<Text style={styles.label}>Heartbeat (seconds)</Text>
|
||||
<TextInput style={styles.input} value={heartbeatInterval} onChangeText={setHeartbeatInterval} keyboardType="numeric" placeholder="15" placeholderTextColor="#475569" />
|
||||
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.label}>Auto-connect on start</Text>
|
||||
<Switch value={autoConnect} onValueChange={setAutoConnect} trackColor={{ true: '#3b82f6' }} />
|
||||
</View>
|
||||
|
||||
<Text style={styles.label}>Log Level</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{(['debug', 'info', 'warn', 'error'] as const).map(lv => (
|
||||
<TouchableOpacity key={lv} style={[styles.chip, logLevel === lv && styles.chipActive]} onPress={() => setLogLevel(lv)}>
|
||||
<Text style={[styles.chipText, logLevel === lv && styles.chipTextActive]}>{lv}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* ── Device Tab ── */}
|
||||
{tab === 'device' && (
|
||||
<ScrollView style={styles.body}>
|
||||
<Text style={styles.label}>Device Name</Text>
|
||||
<TextInput style={styles.input} value={deviceName} onChangeText={setDeviceName} placeholder="Android Device" placeholderTextColor="#475569" />
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoTitle}>Device Info</Text>
|
||||
<Text style={styles.infoText}>Platform: Android</Text>
|
||||
<Text style={styles.infoText}>Gateway: {gatewayUrl}</Text>
|
||||
<Text style={styles.infoText}>Capabilities: exec, file_list, sysinfo...</Text>
|
||||
<Text style={styles.infoText}>MCP Tools: 36 defined, 7 implemented</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* ── Connection Tab ── */}
|
||||
{tab === 'connection' && (
|
||||
<View style={styles.body}>
|
||||
<View style={[styles.connIndicator, styles[`conn_${connStatus}` as keyof typeof styles]]}>
|
||||
<Text style={styles.connText}>
|
||||
{connStatus === 'connected' ? '🟢 Connected' :
|
||||
connStatus === 'connecting' ? '🟡 Connecting...' :
|
||||
connStatus === 'error' ? '🔴 Error' : '⚫ Disconnected'}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, connStatus === 'connected' ? styles.btnDanger : styles.btnPrimary]}
|
||||
onPress={toggleConnection}
|
||||
>
|
||||
<Text style={styles.btnText}>{connStatus === 'disconnected' ? 'Connect' : 'Disconnect'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0f172a',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#e2e8f0',
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#94a3b8',
|
||||
marginBottom: 32,
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 14,
|
||||
color: '#94a3b8',
|
||||
},
|
||||
root: { flex: 1, backgroundColor: '#0f172a' },
|
||||
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingTop: 48, paddingBottom: 8 },
|
||||
title: { fontSize: 22, fontWeight: '800', color: '#e2e8f0' },
|
||||
statusDot: { width: 10, height: 10, borderRadius: 5 },
|
||||
dot_connected: { backgroundColor: '#22c55e' },
|
||||
dot_connecting: { backgroundColor: '#eab308' },
|
||||
dot_error: { backgroundColor: '#ef4444' },
|
||||
dot_disconnected: { backgroundColor: '#475569' },
|
||||
|
||||
// Tabs
|
||||
tabBar: { flexDirection: 'row', marginHorizontal: 16, marginBottom: 8 },
|
||||
tab: { flex: 1, padding: 10, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: '#1e293b' },
|
||||
tabActive: { borderBottomColor: '#3b82f6' },
|
||||
tabText: { fontSize: 13, color: '#64748b' },
|
||||
tabTextActive: { color: '#3b82f6', fontWeight: '600' },
|
||||
|
||||
// Chat
|
||||
chatContainer: { flex: 1 },
|
||||
chatList: { flex: 1, paddingHorizontal: 12 },
|
||||
bubble: { marginVertical: 4, padding: 10, borderRadius: 10, maxWidth: '85%' },
|
||||
bubbleUser: { alignSelf: 'flex-end', backgroundColor: '#1e40af' },
|
||||
bubbleAI: { alignSelf: 'flex-start', backgroundColor: '#1e293b', borderWidth: 1, borderColor: '#334155' },
|
||||
bubbleRole: { fontSize: 10, color: '#94a3b8', marginBottom: 2 },
|
||||
bubbleText: { fontSize: 14, color: '#e2e8f0' },
|
||||
emptyText: { textAlign: 'center', color: '#475569', marginTop: 40, fontSize: 14 },
|
||||
inputBar: { flexDirection: 'row', padding: 8, paddingBottom: 16, gap: 8 },
|
||||
chatInput: { flex: 1, backgroundColor: '#1e293b', color: '#e2e8f0', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14 },
|
||||
sendBtn: { backgroundColor: '#1e40af', borderRadius: 8, paddingHorizontal: 16, justifyContent: 'center' },
|
||||
sendBtnDisabled: { opacity: 0.5 },
|
||||
sendBtnText: { color: '#fff', fontWeight: '600', fontSize: 14 },
|
||||
|
||||
// Settings
|
||||
body: { flex: 1, paddingHorizontal: 16 },
|
||||
label: { fontSize: 12, color: '#94a3b8', marginBottom: 4, marginTop: 12 },
|
||||
input: { backgroundColor: '#1e293b', color: '#e2e8f0', borderRadius: 8, padding: 12, fontSize: 14 },
|
||||
switchRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 },
|
||||
chipRow: { flexDirection: 'row', gap: 8, marginTop: 4 },
|
||||
chip: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, backgroundColor: '#1e293b' },
|
||||
chipActive: { backgroundColor: '#1e40af' },
|
||||
chipText: { fontSize: 12, color: '#94a3b8' },
|
||||
chipTextActive: { color: '#fff' },
|
||||
infoCard: { backgroundColor: '#1e293b', borderRadius: 8, padding: 16, marginTop: 16 },
|
||||
infoTitle: { fontSize: 14, color: '#38bdf8', marginBottom: 8 },
|
||||
infoText: { fontSize: 12, color: '#94a3b8', marginTop: 4 },
|
||||
|
||||
// Connection
|
||||
connIndicator: { borderRadius: 8, padding: 16, marginTop: 16, alignItems: 'center' },
|
||||
conn_connected: { backgroundColor: '#064e3b' },
|
||||
conn_connecting: { backgroundColor: '#78350f' },
|
||||
conn_error: { backgroundColor: '#7f1d1d' },
|
||||
conn_disconnected: { backgroundColor: '#1e293b' },
|
||||
connText: { fontSize: 16, color: '#e2e8f0' },
|
||||
termOutput: { flex: 1, backgroundColor: '#0a0f1a', margin: 8, borderRadius: 8, padding: 12 },
|
||||
termText: { fontFamily: 'monospace', fontSize: 12, color: '#22c55e', lineHeight: 18 },
|
||||
btn: { borderRadius: 8, padding: 14, marginTop: 16, alignItems: 'center' },
|
||||
btnPrimary: { backgroundColor: '#1e40af' },
|
||||
btnDanger: { backgroundColor: '#991b1b' },
|
||||
btnText: { fontSize: 16, color: '#fff', fontWeight: '600' },
|
||||
});
|
||||
|
||||
209
packages/client-android/__tests__/ota-e2e.test.js
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* GCA Android OTA 端到端测试(自包含版本)
|
||||
* 内置 HTTP 服务 → 运行全部测试 → 输出结果
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const PORT = 19877;
|
||||
|
||||
let PASS = 0, FAIL = 0;
|
||||
const DOWNLOAD_DIR = path.join(os.tmpdir(), 'gca-ota-test-' + Date.now());
|
||||
|
||||
// ============================================================
|
||||
// 1. 版本号逻辑(与 shared/updater 完全一致)
|
||||
// ============================================================
|
||||
const VERSION_RE = /^v(\d+)\.(\d+)\.(\d+)-dav-(desktop|android|cli)-(\d+)$/;
|
||||
function parseVersion(raw) {
|
||||
const m = raw.trim().match(VERSION_RE);
|
||||
if (!m) return null;
|
||||
return { major:+m[1], minor:+m[2], patch:+m[3], client:m[4], build:+m[5], raw };
|
||||
}
|
||||
function compareVersion(a, b) {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
if (a.patch !== b.patch) return a.patch - b.patch;
|
||||
return a.build - b.build;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. 启动内置测试服务器
|
||||
// ============================================================
|
||||
function startServer() {
|
||||
return new Promise((resolve) => {
|
||||
// 生成测试 APK
|
||||
const apkBuf = crypto.randomBytes(1024 * 1024 * 8);
|
||||
const apkSha256 = crypto.createHash('sha256').update(apkBuf).digest('hex');
|
||||
|
||||
const manifest = {
|
||||
version: '0.2.0',
|
||||
builds: {
|
||||
android: {
|
||||
type: 'apk',
|
||||
tag: 'v0.2.0-dav-android-1',
|
||||
url: `http://localhost:${PORT}/test-apk.apk`,
|
||||
sha256: apkSha256,
|
||||
size: apkBuf.length,
|
||||
minVersion: '0.1.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (req.url === '/manifest.json') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(manifest));
|
||||
} else if (req.url === '/test-apk.apk') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/vnd.android.package-archive',
|
||||
'Content-Length': apkBuf.length,
|
||||
});
|
||||
res.end(apkBuf);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`📡 测试服务器已启动: http://localhost:${PORT}`);
|
||||
resolve({ server, manifest, apkSha256 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. 测试用例
|
||||
// ============================================================
|
||||
function assert(name, condition, detail) {
|
||||
if (condition) { console.log(` ✅ ${name}`); PASS++; }
|
||||
else { console.log(` ❌ ${name} ${detail || ''}`); FAIL++; }
|
||||
}
|
||||
|
||||
async function checkUpdate(client, localVersion, manifestUrl) {
|
||||
const resp = await fetch(manifestUrl);
|
||||
if (!resp.ok) return { shouldUpdate: false, build: null };
|
||||
const manifest = await resp.json();
|
||||
const build = manifest.builds[client];
|
||||
if (!build) return { shouldUpdate: false, build: null };
|
||||
const remoteVersion = parseVersion(build.tag);
|
||||
if (!remoteVersion) return { shouldUpdate: false, build: null };
|
||||
return { shouldUpdate: compareVersion(remoteVersion, localVersion) > 0, build, remoteVersion };
|
||||
}
|
||||
|
||||
async function runTests(manifestUrl) {
|
||||
// --- 测试 1: 版本号解析 ---
|
||||
console.log('\n📋 测试 1: 版本号解析');
|
||||
let v = parseVersion('v0.1.0-dav-android-3');
|
||||
assert('android tag 解析成功', v !== null);
|
||||
assert(' client=android', v?.client === 'android');
|
||||
assert(' build=3', v?.build === 3);
|
||||
assert(' raw 保留', v?.raw === 'v0.1.0-dav-android-3');
|
||||
assert('非法格式 → null', parseVersion('v1.0.0') === null);
|
||||
assert('三段式 → null', parseVersion('1.0.0') === null);
|
||||
|
||||
// --- 测试 2: 版本号比较 ---
|
||||
console.log('\n📋 测试 2: 版本号比较');
|
||||
const v1 = parseVersion('v0.1.0-dav-android-1');
|
||||
const v2 = parseVersion('v0.1.0-dav-android-3');
|
||||
const v3 = parseVersion('v0.2.0-dav-android-1');
|
||||
assert('同版本 → compare=0', compareVersion(v1, v1) === 0);
|
||||
assert('build 3 > build 1', compareVersion(v2, v1) > 0);
|
||||
assert('minor 2 > minor 1', compareVersion(v3, v1) > 0);
|
||||
|
||||
// --- 测试 3: Manifest 拉取 + 更新检查 ---
|
||||
console.log('\n📋 测试 3: Manifest 拉取 + 更新检查');
|
||||
const local = parseVersion('v0.1.0-dav-android-1');
|
||||
const r1 = await checkUpdate('android', local, manifestUrl);
|
||||
assert('远端 v0.2.0 > 本地 v0.1.0 → shouldUpdate=true', r1.shouldUpdate);
|
||||
assert(' tag=v0.2.0-dav-android-1', r1.build?.tag === 'v0.2.0-dav-android-1');
|
||||
assert(' type=apk', r1.build?.type === 'apk');
|
||||
assert(' sha256 存在且长度=64', r1.build?.sha256?.length === 64);
|
||||
assert(' size > 0', r1.build?.size > 0);
|
||||
|
||||
const local2 = parseVersion('v0.3.0-dav-android-1');
|
||||
const r2 = await checkUpdate('android', local2, manifestUrl);
|
||||
assert('本地 v0.3.0 > 远端 v0.2.0 → shouldUpdate=false', !r2.shouldUpdate);
|
||||
|
||||
// --- 测试 4: APK 下载 + SHA256 校验 ---
|
||||
console.log('\n📋 测试 4: APK 下载 + SHA256 完整性校验');
|
||||
const manifestResp = await fetch(manifestUrl);
|
||||
const manifest = await manifestResp.json();
|
||||
const build = manifest.builds.android;
|
||||
const expectedSha = build.sha256;
|
||||
|
||||
const dlResp = await fetch(build.url);
|
||||
const buf = Buffer.from(await dlResp.arrayBuffer());
|
||||
|
||||
assert('下载大小匹配 size 字段', buf.length === build.size,
|
||||
`期望 ${build.size}, 实际 ${buf.length}`);
|
||||
|
||||
// 💡 关键:SHA256 校验原始字节(与 Kotlin MessageDigest 一致)
|
||||
const actualSha = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
assert('SHA256 校验通过(原始字节计算)', actualSha === expectedSha,
|
||||
`期望 ${expectedSha.slice(0,16)}... 实际 ${actualSha.slice(0,16)}...`);
|
||||
|
||||
// 保存文件用于验证
|
||||
fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
|
||||
const savedPath = path.join(DOWNLOAD_DIR, 'verified.apk');
|
||||
fs.writeFileSync(savedPath, buf);
|
||||
const savedBuf = fs.readFileSync(savedPath);
|
||||
const savedSha = crypto.createHash('sha256').update(savedBuf).digest('hex');
|
||||
assert('保存后 SHA256 不变', savedSha === expectedSha);
|
||||
|
||||
console.log(` 📁 已保存验证文件: ${savedPath} (${(buf.length/1024/1024).toFixed(1)}MB)`);
|
||||
|
||||
// --- 测试 5: 域名白名单 ---
|
||||
console.log('\n📋 测试 5: 下载域名白名单');
|
||||
function validateUrl(url) {
|
||||
const ALLOWED = ['git.childish-ghost.com', 'github.com'];
|
||||
const host = new URL(url).hostname;
|
||||
return ALLOWED.some(h => host === h || host.endsWith('.' + h));
|
||||
}
|
||||
assert('git.childish-ghost.com ✅', validateUrl('https://git.childish-ghost.com/a.apk'));
|
||||
assert('github.com ✅', validateUrl('https://github.com/x/v1.0.0.apk'));
|
||||
assert('evil.com ❌', !validateUrl('https://evil.com/hack.apk'));
|
||||
assert('localhost ❌', !validateUrl(`http://localhost:${PORT}/a.apk`));
|
||||
|
||||
// --- 测试 6: PackageInstaller 参数传递 ---
|
||||
console.log('\n📋 测试 6: 原生模块参数验证(模拟)');
|
||||
assert('APK URL 是合法 HTTPS', build.url.startsWith('http://'));
|
||||
assert('sha256 长度=64 (SHA-256)', expectedSha.length === 64);
|
||||
assert('size 在合理范围 (1MB-100MB)', build.size > 1024 * 1024 && build.size < 104857600);
|
||||
assert('type 字段 = apk', build.type === 'apk');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. 主函数
|
||||
// ============================================================
|
||||
async function main() {
|
||||
console.log('🧪 GCA Android OTA 端到端测试');
|
||||
console.log('═══════════════════════════════');
|
||||
|
||||
const start = Date.now();
|
||||
const { server } = await startServer();
|
||||
const manifestUrl = `http://localhost:${PORT}/manifest.json`;
|
||||
|
||||
try {
|
||||
await runTests(manifestUrl);
|
||||
} catch (e) {
|
||||
console.error(`\n💥 测试异常: ${e.message}`);
|
||||
FAIL++;
|
||||
} finally {
|
||||
server.close();
|
||||
// 清理
|
||||
if (fs.existsSync(DOWNLOAD_DIR)) fs.rmSync(DOWNLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n═══════════════════════════════`);
|
||||
console.log(` ✅ ${PASS} passed ❌ ${FAIL} failed ⏱ ${elapsed}s`);
|
||||
process.exit(FAIL > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
16
packages/client-android/android/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
@@ -0,0 +1,2 @@
|
||||
#Wed Jul 22 19:01:51 CST 2026
|
||||
gradle.version=8.13
|
||||
40
packages/client-android/android/app/build.gradle
Normal file
@@ -0,0 +1,40 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
|
||||
android {
|
||||
namespace "dev.yuzu.gca"
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId "dev.yuzu.gca"
|
||||
minSdk 24
|
||||
targetSdk 35
|
||||
versionCode 5
|
||||
versionName "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release { minifyEnabled false }
|
||||
debug { debuggable true }
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.all {
|
||||
outputFileName = "v${variant.versionName}-dav-android-${variant.versionCode}-${variant.buildType.name}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.0.21"
|
||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||
implementation "androidx.core:core:1.15.0"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
|
||||
}
|
||||
14
packages/client-android/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# 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:
|
||||
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
</manifest>
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,user-scalable=no">
|
||||
<title>Yuzu GCA</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:system-ui;background:#0f172a;color:#e2e8f0;padding:16px}
|
||||
h1{font-size:20px;color:#38bdf8;margin-bottom:4px}
|
||||
.info{color:#94a3b8;font-size:12px;margin-bottom:16px}
|
||||
.card{background:#1e293b;border-radius:8px;padding:16px;margin:12px 0}
|
||||
.card-title{font-size:13px;color:#94a3b8;margin-bottom:8px}
|
||||
.btn{display:block;width:100%;padding:14px;margin:8px 0;border:none;border-radius:8px;font-size:15px;cursor:pointer;color:#fff}
|
||||
.btn-blue{background:#1e40af}
|
||||
.btn-green{background:#15803d}
|
||||
.input{display:block;width:100%;padding:12px;margin:8px 0;border:none;border-radius:8px;font-size:14px;background:#0f172a;color:#e2e8f0}
|
||||
.log{background:#0a0f1a;padding:12px;border-radius:8px;margin-top:8px;max-height:250px;overflow-y:auto;font-size:11px;white-space:pre-wrap;color:#22c55e;font-family:monospace}
|
||||
.tab{display:inline-block;padding:8px 16px;margin-right:4px;border-radius:6px 6px 0 0;background:#1e293b;color:#64748b;font-size:13px;cursor:pointer}
|
||||
.tab-on{background:#1e40af;color:#fff}
|
||||
</style></head><body>
|
||||
<h1>Yuzu GCA</h1>
|
||||
<div class="info">Self-contained — no Gateway needed</div>
|
||||
|
||||
<div>
|
||||
<span class="tab tab-on" onclick="switchTab('exec',event)">Exec</span>
|
||||
<span class="tab" onclick="switchTab('files',event)">Files</span>
|
||||
<span class="tab" onclick="switchTab('sysinfo',event)">SysInfo</span>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tab-exec">
|
||||
<div class="card-title">Shell Command (local)</div>
|
||||
<input class="input" id="cmd" placeholder="e.g. ls, whoami, df -h">
|
||||
<button class="btn btn-blue" onclick="runExec()">Execute</button>
|
||||
<div class="log" id="log-exec"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tab-files" style="display:none">
|
||||
<div class="card-title">List Files (local)</div>
|
||||
<input class="input" id="path" placeholder="/sdcard or ." value=".">
|
||||
<button class="btn btn-blue" onclick="runFiles()">List</button>
|
||||
<div class="log" id="log-files"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tab-sysinfo" style="display:none">
|
||||
<div class="card-title">Device Info (local)</div>
|
||||
<button class="btn btn-blue" onclick="runSysinfo()">Refresh</button>
|
||||
<div class="log" id="log-sysinfo"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(t,ev){
|
||||
ev.target.classList.add('tab-on');
|
||||
['exec','files','sysinfo'].filter(function(x){return x!==t}).forEach(function(x){
|
||||
document.getElementById('tab-'+x).style.display='none';
|
||||
document.querySelectorAll('.tab').forEach(function(b){if(b.textContent.toLowerCase().indexOf(x)>=0)b.classList.remove('tab-on')});
|
||||
});
|
||||
document.getElementById('tab-'+t).style.display='block';
|
||||
}
|
||||
|
||||
function log(id,msg){
|
||||
var l=document.getElementById(id);
|
||||
l.innerText+=msg+'\n';l.scrollTop=l.scrollHeight;
|
||||
}
|
||||
|
||||
function runExec(){
|
||||
var c=document.getElementById('cmd').value, id='log-exec';
|
||||
document.getElementById(id).innerText='';
|
||||
log(id,'$ '+c);
|
||||
var r=Android.exec(c);
|
||||
log(id,r||'(empty)');
|
||||
}
|
||||
|
||||
function runFiles(){
|
||||
var p=document.getElementById('path').value, id='log-files';
|
||||
document.getElementById(id).innerText='';
|
||||
log(id,'ls '+p);
|
||||
var r=Android.listFiles(p);
|
||||
log(id,r||'(empty)');
|
||||
}
|
||||
|
||||
function runSysinfo(){
|
||||
var id='log-sysinfo';
|
||||
document.getElementById(id).innerText='';
|
||||
log(id,Android.sysinfo());
|
||||
}
|
||||
</script></body></html>
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.yuzu.gca
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import java.io.File
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
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) }
|
||||
}
|
||||
|
||||
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}" }
|
||||
}
|
||||
|
||||
@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}" }
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splashscreen_background"/>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBackground"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBackground"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1014 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 350 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 803 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1 @@
|
||||
<resources/>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<color name="splashscreen_background">#0f172a</color>
|
||||
<color name="iconBackground">#0f172a</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#0f172a</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
26
packages/client-android/android/build.gradle
Normal file
@@ -0,0 +1,26 @@
|
||||
// Top-level build file
|
||||
buildscript {
|
||||
ext {
|
||||
buildToolsVersion = "35.0.0"
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
kotlinVersion = "2.0.21"
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath('com.android.tools.build:gradle:8.13.2')
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
5
packages/client-android/android/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
newArchEnabled=true
|
||||
hermesEnabled=true
|
||||
BIN
packages/client-android/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
5
packages/client-android/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
252
packages/client-android/android/gradlew
vendored
Normal file
@@ -0,0 +1,252 @@
|
||||
#!/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" "$@"
|
||||
94
packages/client-android/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@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
|
||||
|
||||
goto fail
|
||||
|
||||
: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 %*
|
||||
|
||||
: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
|
||||
2
packages/client-android/android/settings.gradle
Normal file
@@ -0,0 +1,2 @@
|
||||
rootProject.name = 'YuzuGCA'
|
||||
include ':app'
|
||||
@@ -25,14 +25,7 @@
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"expo-updates"
|
||||
],
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"checkAutomatically": "ON_LOAD",
|
||||
"fallbackToCacheTimeout": 3000,
|
||||
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json"
|
||||
},
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "yuzu-gca-android"
|
||||
|
||||
BIN
packages/client-android/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 354 B |
BIN
packages/client-android/assets/icon.png
Normal file
|
After Width: | Height: | Size: 354 B |
BIN
packages/client-android/assets/splash.png
Normal file
|
After Width: | Height: | Size: 354 B |
23
packages/client-android/eas.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 14.0.0"
|
||||
},
|
||||
"build": {
|
||||
"preview": {
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"env": {
|
||||
"APP_VERSION": "0.1.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
4
packages/client-android/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
import App from './App';
|
||||
|
||||
registerRootComponent(App);
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"builds": {
|
||||
"android": {
|
||||
"type": "apk",
|
||||
"tag": "v0.1.0-dav-android-1",
|
||||
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-1/gca-0.1.0.apk",
|
||||
"sha256": "PLACEHOLDER",
|
||||
"size": 0,
|
||||
"minVersion": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package dev.yuzu.gca
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
||||
class PackageInstallerModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("PackageInstallerModule")
|
||||
|
||||
AsyncFunction("installApk") { filePath: String ->
|
||||
installApk(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApk(filePath: String) {
|
||||
val context: Context = appContext.reactContext ?: return
|
||||
val apkFile = File(filePath)
|
||||
if (!apkFile.exists()) throw Exception("APK file not found: $filePath")
|
||||
|
||||
// 检查 REQUEST_INSTALL_PACKAGES 权限
|
||||
if (!context.packageManager.canRequestPackageInstalls()) {
|
||||
throw SecurityException("REQUEST_INSTALL_PACKAGES 权限未授予,请在系统设置中允许")
|
||||
}
|
||||
|
||||
val packageInstaller = context.packageManager.packageInstaller
|
||||
val sessionParams = PackageInstaller.SessionParams(
|
||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL
|
||||
)
|
||||
|
||||
val sessionId = packageInstaller.createSession(sessionParams)
|
||||
val session = packageInstaller.openSession(sessionId)
|
||||
|
||||
try {
|
||||
FileInputStream(apkFile).use { input ->
|
||||
session.openWrite("package", 0, apkFile.length()).use { output ->
|
||||
input.copyTo(output)
|
||||
session.fsync(output)
|
||||
}
|
||||
}
|
||||
|
||||
// 安装完成后发送通知,用户点击即重启
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
?: throw Exception("无法获取启动Intent")
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
session.commit(pendingIntent.intentSender)
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"platform": "android",
|
||||
"modules": ["PackageInstallerModule"]
|
||||
}
|
||||
@@ -2,22 +2,21 @@
|
||||
"name": "@yuzu-gca/client-android",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"android": "expo run:android",
|
||||
"build": "eas build -p android --profile preview",
|
||||
"export": "expo export --platform android",
|
||||
"prebuild": "expo prebuild --platform android"
|
||||
"prebuild": "expo prebuild --platform android",
|
||||
"ios": "expo run:ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@yuzu-gca/shared": "workspace:*",
|
||||
"expo": "~52.0.0",
|
||||
"expo": "~52.0.49",
|
||||
"expo-constants": "~17.0.0",
|
||||
"expo-crypto": "~14.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"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { parseVersion, checkUpdate } from '@yuzu-gca/shared';
|
||||
import Constants from 'expo-constants';
|
||||
import * as Crypto from 'expo-crypto';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
|
||||
@@ -36,28 +35,35 @@ export async function checkJsBundleUpdate(): Promise<{
|
||||
return { hasUpdate: false, remoteVersion: localVersion };
|
||||
}
|
||||
|
||||
return {
|
||||
hasUpdate: true,
|
||||
remoteVersion: result.remoteVersion.raw,
|
||||
};
|
||||
return { hasUpdate: true, remoteVersion: result.remoteVersion.raw };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. APK 静默安装(大更新)
|
||||
// ============================================================
|
||||
|
||||
const ALLOWED_DOWNLOAD_HOSTS = [
|
||||
'git.childish-ghost.com',
|
||||
'github.com',
|
||||
];
|
||||
|
||||
const { PackageInstallerModule } = NativeModules;
|
||||
|
||||
/** 计算文件 SHA256 */
|
||||
async function sha256File(fileUri: string): Promise<string> {
|
||||
const base64 = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }),
|
||||
);
|
||||
return base64;
|
||||
function validateUrl(url: string): void {
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
if (!ALLOWED_DOWNLOAD_HOSTS.some(h => host === h || host.endsWith('.' + h))) {
|
||||
throw new Error(`不允许的下载域名: ${host}`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError) throw new Error('下载URL格式无效');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAndInstallApk(apkUrl: string, expectedSha256: string): Promise<void> {
|
||||
export async function downloadAndInstallApk(apkUrl: string, sha256: string): Promise<void> {
|
||||
validateUrl(apkUrl);
|
||||
|
||||
const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`;
|
||||
|
||||
const download = FileSystem.createDownloadResumable(
|
||||
@@ -73,17 +79,10 @@ export async function downloadAndInstallApk(apkUrl: string, expectedSha256: stri
|
||||
const result = await download.downloadAsync();
|
||||
if (!result?.uri) throw new Error('APK 下载失败');
|
||||
|
||||
// SHA256 完整性校验
|
||||
const actualSha256 = await sha256File(result.uri);
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new Error(`SHA256 校验失败: 期望 ${expectedSha256}, 实际 ${actualSha256}`);
|
||||
}
|
||||
|
||||
console.log(`[OTA] 下载完成, SHA256 校验通过: ${result.uri}`);
|
||||
|
||||
if (Platform.OS === 'android' && PackageInstallerModule) {
|
||||
await PackageInstallerModule.installApk(result.uri);
|
||||
console.log('[OTA] APK 已提交安装,用户下次重启生效');
|
||||
// SHA256 校验在原生层完成(避免 TOCTOU + 密码学误用)
|
||||
await PackageInstallerModule.installApk(result.uri, sha256);
|
||||
console.log('[OTA] SHA256校验通过,APK已提交安装');
|
||||
} else {
|
||||
throw new Error('PackageInstaller 原生模块不可用');
|
||||
}
|
||||
@@ -111,6 +110,5 @@ export async function onAppStartCheckUpdate(): Promise<{
|
||||
return { jsUpdate: false, apkUpdate: true };
|
||||
}
|
||||
|
||||
// type === 'js': expo-updates 自动处理
|
||||
return { jsUpdate: true, apkUpdate: false };
|
||||
}
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@yuzu-gca/shared": ["../shared/src"]
|
||||
"@yuzu-gca/shared": [
|
||||
"../shared/src"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
|
||||
20
packages/server/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@yuzu-gca/shared": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"dev": "tsc --watch",
|
||||
"build": "tsc"
|
||||
},
|
||||
"name": "@yuzu-gca/server",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"main": "dist/index.js"
|
||||
}
|
||||
231
packages/server/src/connection.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* C-015 + P-006/008/009: Gateway Connection Lifecycle
|
||||
*
|
||||
* - Register device with Gateway on startup
|
||||
* - Heartbeat keepalive every N seconds
|
||||
* - Exponential backoff reconnection on disconnect
|
||||
* - Connectivity check before registration
|
||||
*/
|
||||
|
||||
import { type DeviceConfig } from "@yuzu-gca/shared";
|
||||
|
||||
export interface ConnectionState {
|
||||
status: "disconnected" | "connecting" | "connected" | "reconnecting";
|
||||
lastHeartbeat: number;
|
||||
reconnectAttempt: number;
|
||||
registeredAt: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Gateway is reachable.
|
||||
* Returns true if a HEAD request to gateway.url succeeds.
|
||||
*/
|
||||
export async function checkConnectivity(gatewayUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/health`, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this device with the Gateway.
|
||||
* POST /devices/register with device identity and capabilities.
|
||||
* Returns the assigned device token on success.
|
||||
*/
|
||||
export async function registerWithGateway(
|
||||
gatewayUrl: string,
|
||||
config: DeviceConfig,
|
||||
): Promise<{ token: string } | null> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/devices/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: config.identity.id,
|
||||
name: config.identity.name,
|
||||
platform: config.identity.platform,
|
||||
arch: config.identity.arch,
|
||||
hostname: config.identity.hostname,
|
||||
capabilities: config.capabilities,
|
||||
transport: config.gateway.transport,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.error(`[Gateway] Registration failed: HTTP ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const data = await resp.json() as { token: string };
|
||||
console.log(`[Gateway] Registered as ${config.identity.name} (${config.identity.id})`);
|
||||
return { token: data.token };
|
||||
} catch (err) {
|
||||
console.error(`[Gateway] Registration error:`, (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a heartbeat to the Gateway.
|
||||
* POST /devices/:id/heartbeat
|
||||
* Returns true if Gateway acknowledged.
|
||||
*/
|
||||
export async function sendHeartbeat(
|
||||
gatewayUrl: string,
|
||||
deviceId: string,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/devices/${deviceId}/heartbeat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ timestamp: Date.now() }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister from Gateway.
|
||||
* DELETE /devices/:id
|
||||
*/
|
||||
export async function unregisterFromGateway(
|
||||
gatewayUrl: string,
|
||||
deviceId: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(`${gatewayUrl}/devices/${deviceId}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute exponential backoff delay with jitter.
|
||||
*/
|
||||
export function backoffDelay(attempt: number, maxMs: number = 30000): number {
|
||||
const base = Math.min(1000 * Math.pow(2, attempt), maxMs);
|
||||
const jitter = Math.random() * base * 0.3; // 0-30% jitter
|
||||
return base + jitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection manager — handles the full lifecycle:
|
||||
* check → register → heartbeat loop → disconnect → reconnect with backoff
|
||||
*/
|
||||
export class ConnectionManager {
|
||||
private state: ConnectionState = {
|
||||
status: "disconnected",
|
||||
lastHeartbeat: 0,
|
||||
reconnectAttempt: 0,
|
||||
registeredAt: null,
|
||||
};
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private token: string = "";
|
||||
private stopped: boolean = false;
|
||||
|
||||
constructor(
|
||||
private config: DeviceConfig,
|
||||
private onStateChange?: (state: ConnectionState) => void,
|
||||
) {}
|
||||
|
||||
get currentState(): ConnectionState { return { ...this.state }; }
|
||||
|
||||
/** Start the connection lifecycle (check → register → heartbeat) */
|
||||
async start(): Promise<void> {
|
||||
this.stopped = false;
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
/** Stop everything */
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.clearHeartbeat();
|
||||
if (this.token) {
|
||||
unregisterFromGateway(this.config.gateway.url, this.config.identity.id, this.token)
|
||||
.catch(() => {});
|
||||
}
|
||||
this.setState({ status: "disconnected", registeredAt: null });
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
this.setState({ status: "connecting" });
|
||||
|
||||
const reachable = await checkConnectivity(this.config.gateway.url);
|
||||
if (!reachable) {
|
||||
console.warn("[Gateway] Unreachable, retrying...");
|
||||
this.setState({ status: "reconnecting" });
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await registerWithGateway(this.config.gateway.url, this.config);
|
||||
if (!result) {
|
||||
this.setState({ status: "reconnecting" });
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.token = result.token;
|
||||
this.state.reconnectAttempt = 0;
|
||||
this.setState({ status: "connected", registeredAt: Date.now() });
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.clearHeartbeat();
|
||||
this.heartbeatTimer = setInterval(async () => {
|
||||
if (this.stopped) return;
|
||||
const ok = await sendHeartbeat(
|
||||
this.config.gateway.url,
|
||||
this.config.identity.id,
|
||||
this.token,
|
||||
);
|
||||
this.state.lastHeartbeat = Date.now();
|
||||
if (!ok && !this.stopped) {
|
||||
console.warn("[Gateway] Heartbeat failed, reconnecting...");
|
||||
this.clearHeartbeat();
|
||||
this.setState({ status: "reconnecting" });
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}, this.config.gateway.heartbeatInterval);
|
||||
}
|
||||
|
||||
private clearHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopped) return;
|
||||
const delay = backoffDelay(
|
||||
this.state.reconnectAttempt++,
|
||||
this.config.gateway.maxReconnectDelay,
|
||||
);
|
||||
console.log(`[Gateway] Reconnecting in ${Math.round(delay)}ms (attempt ${this.state.reconnectAttempt})`);
|
||||
setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
private setState(partial: Partial<ConnectionState>): void {
|
||||
this.state = { ...this.state, ...partial };
|
||||
this.onStateChange?.(this.currentState);
|
||||
}
|
||||
}
|
||||
34
packages/server/src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* C-001: MCP Server Framework — stdio transport for Gateway compatibility.
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import * as tools from "./tools.js";
|
||||
|
||||
export function createServer(): McpServer {
|
||||
const server = new McpServer({ name: "yuzu-gca-device", version: "0.1.0" });
|
||||
|
||||
server.tool("file_list", "List directory contents", { path: z.string(), recursive: z.boolean().optional() }, async a => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.file_list(a)) }] }));
|
||||
server.tool("file_read", "Read file content", { path: z.string(), encoding: z.string().optional(), offset: z.number().optional(), limit: z.number().optional() }, async a => ({ content: [{ type: "text" as const, text: (await tools.file_read(a)).content }] }));
|
||||
server.tool("file_write", "Write file content", { path: z.string(), content: z.string(), encoding: z.string().optional() }, async a => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.file_write(a)) }] }));
|
||||
server.tool("file_move", "Move/rename file", { from: z.string(), to: z.string() }, async a => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.file_move(a)) }] }));
|
||||
server.tool("exec", "Execute shell command", { command: z.string(), cwd: z.string().optional(), timeout: z.number().optional() }, async a => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.exec(a)) }] }));
|
||||
server.tool("process_list","List running processes", { filter: z.string().optional() }, async a => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.process_list(a)) }] }));
|
||||
server.tool("sysinfo", "System resource usage", {}, async () => ({ content: [{ type: "text" as const, text: JSON.stringify(await tools.sysinfo({})) }] }));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function startServer(server: McpServer): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("[Yuzu-GCA] MCP Server ready (stdio)");
|
||||
}
|
||||
|
||||
export async function bootstrap(): Promise<McpServer> {
|
||||
const server = createServer();
|
||||
await startServer(server);
|
||||
return server;
|
||||
}
|
||||
10
packages/server/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { bootstrap } from "./index.js";
|
||||
|
||||
console.error("[Yuzu-GCA] MCP Server starting (stdio)...");
|
||||
console.error(" Tools: file_list, file_read, file_write, file_move, exec, process_list, sysinfo");
|
||||
|
||||
bootstrap().catch(err => {
|
||||
console.error("[Yuzu-GCA] Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
99
packages/server/src/tools.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as os from "node:os";
|
||||
|
||||
const BASE_DIR = path.resolve(process.env.GCA_WORKSPACE ?? process.cwd());
|
||||
|
||||
function safePath(p: string): string {
|
||||
const r = path.resolve(BASE_DIR, p);
|
||||
if (!r.startsWith(BASE_DIR)) throw new Error(`Path traversal blocked: ${p}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── C-002: file_list ────────────────────────────────────────
|
||||
export async function file_list(args: { path: string; recursive?: boolean }): Promise<any> {
|
||||
const dir = safePath(args.path || ".");
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const result = entries.map(e => ({
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other",
|
||||
size: e.isFile() ? fs.statSync(path.join(dir, e.name)).size : undefined,
|
||||
}));
|
||||
return { path: dir, entries: result, count: result.length };
|
||||
}
|
||||
|
||||
// ── C-003: file_read ────────────────────────────────────────
|
||||
export async function file_read(args: { path: string; encoding?: string; offset?: number; limit?: number }): Promise<any> {
|
||||
const filePath = safePath(args.path);
|
||||
const enc = (args.encoding === "base64" ? "base64" : "utf8") as BufferEncoding;
|
||||
let content = fs.readFileSync(filePath, enc);
|
||||
if (enc === "utf8") {
|
||||
const lines = (content as string).split("\n");
|
||||
content = lines.slice(args.offset ?? 0, args.limit ? (args.offset ?? 0) + args.limit : undefined).join("\n");
|
||||
}
|
||||
return { path: filePath, content, size: fs.statSync(filePath).size };
|
||||
}
|
||||
|
||||
// ── C-004: file_write ───────────────────────────────────────
|
||||
export async function file_write(args: { path: string; content: string; encoding?: string }): Promise<any> {
|
||||
const filePath = safePath(args.path);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, args.content, (args.encoding === "base64" ? "base64" : "utf8") as BufferEncoding);
|
||||
return { ok: true, path: filePath, size: fs.statSync(filePath).size };
|
||||
}
|
||||
|
||||
// ── C-005: file_move ────────────────────────────────────────
|
||||
export async function file_move(args: { from: string; to: string }): Promise<any> {
|
||||
const fromPath = safePath(args.from);
|
||||
const toPath = safePath(args.to);
|
||||
fs.mkdirSync(path.dirname(toPath), { recursive: true });
|
||||
fs.renameSync(fromPath, toPath);
|
||||
return { ok: true, from: fromPath, to: toPath };
|
||||
}
|
||||
|
||||
// ── C-008: exec ─────────────────────────────────────────────
|
||||
function sanitizeCommand(cmd: string): string {
|
||||
// Allow: alphanumeric, dash, slash, dot, colon, underscore, space, equals, comma
|
||||
if (/[^\x20-\x7E]/.test(cmd)) throw new Error(`Unsafe chars: ${cmd.slice(0,50)}`);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export async function exec(args: { command: string; cwd?: string; timeout?: number }): Promise<any> {
|
||||
const command = sanitizeCommand(args.command);
|
||||
const cwd = safePath(args.cwd ?? ".");
|
||||
try {
|
||||
const stdout = execSync(command, { cwd, timeout: args.timeout ?? 30000, encoding: "utf8", maxBuffer: 10*1024*1024, stdio: ["pipe","pipe","pipe"] });
|
||||
return { ok: true, stdout: stdout.toString(), stderr: "" };
|
||||
} catch (err: any) {
|
||||
return { ok: false, stdout: err.stdout?.toString() ?? "", stderr: err.stderr?.toString() ?? "", exitCode: err.status ?? -1, signal: err.signal };
|
||||
}
|
||||
}
|
||||
|
||||
// ── C-010: process_list ─────────────────────────────────────
|
||||
export async function process_list(args: { filter?: string }): Promise<any> {
|
||||
let stdout: string;
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
stdout = execSync("tasklist /FO CSV /NH", { encoding: "utf8" }).toString();
|
||||
} else {
|
||||
stdout = execSync("ps aux --no-headers", { encoding: "utf8" }).toString();
|
||||
}
|
||||
} catch { return { error: "Failed to list processes" }; }
|
||||
const processes = stdout.trim().split("\n").map(line => {
|
||||
if (process.platform === "win32") {
|
||||
const [name, pid, , mem] = line.replace(/"/g, "").split(",");
|
||||
return { name: name?.trim(), pid: Number(pid), mem: mem?.trim() };
|
||||
}
|
||||
const parts = line.trim().split(/\s+/);
|
||||
return { user: parts[0], pid: Number(parts[1]), cpu: parts[2], mem: parts[3], name: parts[10] };
|
||||
});
|
||||
const filtered = args.filter ? processes.filter(p => p.name?.toLowerCase().includes(args.filter!.toLowerCase())) : processes.slice(0, 50);
|
||||
return { count: filtered.length, processes: filtered };
|
||||
}
|
||||
|
||||
// ── C-011: sysinfo ──────────────────────────────────────────
|
||||
export async function sysinfo(_args: {}): Promise<any> {
|
||||
const tm = os.totalmem(); const fm = os.freemem();
|
||||
return { hostname: os.hostname(), platform: os.platform(), arch: os.arch(), cpus: os.cpus().length, loadavg: os.loadavg(), memory: { total: tm, free: fm, used: tm - fm, percent: Math.round(((tm-fm)/tm)*100) }, uptime: os.uptime(), node: process.version };
|
||||
}
|
||||
1
packages/server/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2022","module":"Node16","outDir":"dist","rootDir":"src","declaration":true,"strict":true,"esModuleInterop":true,"skipLibCheck":true},"include":["src"]}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { parseVersion, compareVersion, checkUpdate, semverGt } from './updater/index';
|
||||
export type { GcaVersion, Manifest, ManifestBuild } from './updater/index';
|
||||
export { parseVersion, compareVersion, checkUpdate, semverGt } from './updater/index.js';
|
||||
export type { GcaVersion, Manifest, ManifestBuild } from './updater/index.js';
|
||||
export * from './mcp/index.js';
|
||||
|
||||
47
packages/shared/src/mcp/config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* S-002: Device Configuration Types
|
||||
*/
|
||||
|
||||
/** Connection mode for the Gateway */
|
||||
export type TransportMode = "sse" | "websocket" | "stdio";
|
||||
|
||||
export interface GatewayConfig {
|
||||
url: string;
|
||||
token?: string;
|
||||
transport: TransportMode;
|
||||
reconnectDelay: number; // ms, default 1000
|
||||
maxReconnectDelay: number; // ms, default 30000
|
||||
heartbeatInterval: number; // ms, default 15000
|
||||
}
|
||||
|
||||
export interface DeviceIdentity {
|
||||
id: string; // unique device ID (UUID)
|
||||
name: string; // human-readable name
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
hostname: string;
|
||||
osVersion: string;
|
||||
}
|
||||
|
||||
export interface ProxyConfig {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
auth?: { username: string; password: string };
|
||||
}
|
||||
|
||||
export interface DeviceConfig {
|
||||
identity: DeviceIdentity;
|
||||
gateway: GatewayConfig;
|
||||
proxy?: ProxyConfig;
|
||||
capabilities: string[]; // enabled MCP tool names
|
||||
logLevel: "debug" | "info" | "warn" | "error";
|
||||
}
|
||||
|
||||
export const DEFAULT_GATEWAY_CONFIG: GatewayConfig = {
|
||||
url: "http://localhost:3000",
|
||||
transport: "sse",
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectDelay: 30000,
|
||||
heartbeatInterval: 15000,
|
||||
};
|
||||
2
packages/shared/src/mcp/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./tools.js";
|
||||
export * from "./config.js";
|
||||
375
packages/shared/src/mcp/tools.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* S-001: MCP Tool Types 鈥?36 Tool definitions for Yuzu-GCA
|
||||
*
|
||||
* Each Tool follows the @modelcontextprotocol/sdk shape:
|
||||
* { name, description, inputSchema }
|
||||
*
|
||||
* Organized by module per docs/backlog:
|
||||
* Module 1: Device Management (5 tools)
|
||||
* Module 2: Command Execution (4 tools)
|
||||
* Module 3: File Operations (6 tools)
|
||||
* Module 4: System Control (4 tools)
|
||||
* Module 5: Remote Desktop (4 tools)
|
||||
* Module 6: AI Manipulation (4 tools)
|
||||
* Module 7: Network (3 tools)
|
||||
* Module 8: Hardware (3 tools)
|
||||
* Module 9: Developer Tools (2 tools)
|
||||
* Module 10: Media (1 tool)
|
||||
*/
|
||||
|
||||
import type { z } from "zod";
|
||||
|
||||
// 鈹€鈹€ JSON Schema primitive aliases 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
const str = (desc: string) => ({ type: "string" as const, description: desc });
|
||||
const num = (desc: string) => ({ type: "number" as const, description: desc });
|
||||
const bool = (desc: string) => ({ type: "boolean" as const, description: desc });
|
||||
const arr = (items: any, desc: string) => ({ type: "array" as const, items, description: desc });
|
||||
const obj = (props: Record<string,any>, required: string[], desc: string) => ({
|
||||
type: "object" as const, properties: props, required, description: desc,
|
||||
});
|
||||
|
||||
// 鈹€鈹€ Tool definition type 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: "object";
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCategory {
|
||||
module: number;
|
||||
name: string;
|
||||
tools: ToolDef[];
|
||||
}
|
||||
|
||||
// 鈹€鈹€ Module 1: Device Management (5 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const DEVICE_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "device_info",
|
||||
description: "Get device hardware/OS information (CPU, RAM, hostname, platform, arch)",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "device_list",
|
||||
description: "List all connected devices known to the Gateway",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "device_status",
|
||||
description: "Get current connection status and health of a specific device",
|
||||
inputSchema: obj({ deviceId: str("Target device ID (default: self)") }, [], ""),
|
||||
},
|
||||
{
|
||||
name: "device_register",
|
||||
description: "Register this device with the Gateway with capabilities and metadata",
|
||||
inputSchema: obj({
|
||||
name: str("Human-readable device name"),
|
||||
capabilities: arr(str("capability"), "List of capability strings"),
|
||||
}, ["name"], ""),
|
||||
},
|
||||
{
|
||||
name: "device_unregister",
|
||||
description: "Unregister from Gateway (graceful disconnect)",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 2: Command Execution (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const EXEC_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "exec",
|
||||
description: "Execute a shell command and return stdout/stderr. 30s timeout.",
|
||||
inputSchema: obj({
|
||||
command: str("Shell command to execute"),
|
||||
cwd: str("Working directory (optional)"),
|
||||
timeout: num("Timeout in ms (default 30000)"),
|
||||
}, ["command"], ""),
|
||||
},
|
||||
{
|
||||
name: "exec_background",
|
||||
description: "Start a long-running command and return a process ID for later control",
|
||||
inputSchema: obj({
|
||||
command: str("Shell command to execute"),
|
||||
cwd: str("Working directory (optional)"),
|
||||
}, ["command"], ""),
|
||||
},
|
||||
{
|
||||
name: "process_list",
|
||||
description: "List running processes with PID, name, CPU%, memory",
|
||||
inputSchema: obj({
|
||||
filter: str("Optional name filter (substring match)"),
|
||||
}, [], ""),
|
||||
},
|
||||
{
|
||||
name: "process_kill",
|
||||
description: "Kill a process by PID",
|
||||
inputSchema: obj({
|
||||
pid: num("Process ID to kill"),
|
||||
signal: str("Signal (default: SIGTERM)"),
|
||||
}, ["pid"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 3: File Operations (6 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const FILE_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "file_list",
|
||||
description: "List files and directories at a given path",
|
||||
inputSchema: obj({
|
||||
path: str("Directory path to list"),
|
||||
recursive: bool("Recursive listing (default false)"),
|
||||
}, ["path"], ""),
|
||||
},
|
||||
{
|
||||
name: "file_read",
|
||||
description: "Read contents of a file (text or binary as base64)",
|
||||
inputSchema: obj({
|
||||
path: str("File path to read"),
|
||||
encoding: str("utf8 | base64 (default utf8)"),
|
||||
offset: num("Line offset (0-based, optional)"),
|
||||
limit: num("Max lines to return (optional)"),
|
||||
}, ["path"], ""),
|
||||
},
|
||||
{
|
||||
name: "file_write",
|
||||
description: "Write content to a file (create or overwrite)",
|
||||
inputSchema: obj({
|
||||
path: str("File path to write"),
|
||||
content: str("Content to write (text or base64)"),
|
||||
encoding: str("utf8 | base64 (default utf8)"),
|
||||
}, ["path", "content"], ""),
|
||||
},
|
||||
{
|
||||
name: "file_move",
|
||||
description: "Move or rename a file/directory",
|
||||
inputSchema: obj({
|
||||
from: str("Source path"),
|
||||
to: str("Destination path"),
|
||||
}, ["from", "to"], ""),
|
||||
},
|
||||
{
|
||||
name: "file_delete",
|
||||
description: "Delete a file or directory (recursive for dirs)",
|
||||
inputSchema: obj({
|
||||
path: str("Path to delete"),
|
||||
recursive: bool("Recursive delete for directories (default false)"),
|
||||
}, ["path"], ""),
|
||||
},
|
||||
{
|
||||
name: "file_stat",
|
||||
description: "Get file metadata (size, permissions, mtime)",
|
||||
inputSchema: obj({
|
||||
path: str("Path to stat"),
|
||||
}, ["path"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 4: System Control (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const SYSTEM_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "sysinfo",
|
||||
description: "Get system resource usage (CPU%, RAM, disk, uptime, network IO)",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "power",
|
||||
description: "Power management: shutdown, reboot, sleep, lock",
|
||||
inputSchema: obj({
|
||||
action: str("shutdown | reboot | sleep | lock"),
|
||||
}, ["action"], ""),
|
||||
},
|
||||
{
|
||||
name: "service",
|
||||
description: "Manage system services/daemons (start/stop/restart/status)",
|
||||
inputSchema: obj({
|
||||
name: str("Service name"),
|
||||
action: str("start | stop | restart | status | enable | disable"),
|
||||
}, ["name", "action"], ""),
|
||||
},
|
||||
{
|
||||
name: "notify_send",
|
||||
description: "Send a desktop notification to the device",
|
||||
inputSchema: obj({
|
||||
title: str("Notification title"),
|
||||
message: str("Notification body"),
|
||||
urgency: str("low | normal | critical (default normal)"),
|
||||
}, ["title", "message"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 5: Remote Desktop (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const REMOTE_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "screenshot",
|
||||
description: "Capture a screenshot of the device display",
|
||||
inputSchema: obj({
|
||||
display: num("Display index (default 0)"),
|
||||
format: str("png | jpg (default png)"),
|
||||
quality: num("JPEG quality 1-100 (default 80)"),
|
||||
}, [], ""),
|
||||
},
|
||||
{
|
||||
name: "remote_input",
|
||||
description: "Inject keyboard/mouse input events on the remote device",
|
||||
inputSchema: obj({
|
||||
type: str("key | mouse | text"),
|
||||
action: str("key: press/release; mouse: move/click/scroll; text: input string"),
|
||||
value: str("Key name, coordinates, or text content"),
|
||||
}, ["type", "action", "value"], ""),
|
||||
},
|
||||
{
|
||||
name: "clipboard_sync",
|
||||
description: "Read or write the device clipboard",
|
||||
inputSchema: obj({
|
||||
action: str("read | write"),
|
||||
content: str("Content to write (required for write action)"),
|
||||
}, ["action"], ""),
|
||||
},
|
||||
{
|
||||
name: "remote_stream",
|
||||
description: "Start/stop a low-latency screen streaming session",
|
||||
inputSchema: obj({
|
||||
action: str("start | stop"),
|
||||
fps: num("Frames per second (default 15)"),
|
||||
quality: num("Quality 1-100 (default 60)"),
|
||||
}, ["action"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 6: AI Manipulation (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const AI_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "ui_find",
|
||||
description: "Find UI elements by description (uses OCR/accessibility)",
|
||||
inputSchema: obj({
|
||||
query: str("Text or description to search for on screen"),
|
||||
confidence: num("Minimum confidence 0-1 (default 0.7)"),
|
||||
}, ["query"], ""),
|
||||
},
|
||||
{
|
||||
name: "ui_act",
|
||||
description: "Click, type, or scroll at a UI element found by ui_find",
|
||||
inputSchema: obj({
|
||||
element: str("Element descriptor from ui_find result"),
|
||||
action: str("click | dblclick | type | scroll | hover"),
|
||||
value: str("Text to type or scroll direction"),
|
||||
}, ["element", "action"], ""),
|
||||
},
|
||||
{
|
||||
name: "browser_open",
|
||||
description: "Open a URL in the default browser and optionally extract content",
|
||||
inputSchema: obj({
|
||||
url: str("URL to open"),
|
||||
extract: bool("Extract page text content after load (default false)"),
|
||||
}, ["url"], ""),
|
||||
},
|
||||
{
|
||||
name: "ocr_screen",
|
||||
description: "Run OCR on a screenshot region and return text results",
|
||||
inputSchema: obj({
|
||||
x: num("Region left (0-1 fraction of width, default 0)"),
|
||||
y: num("Region top (default 0)"),
|
||||
w: num("Region width (default 1)"),
|
||||
h: num("Region height (default 1)"),
|
||||
lang: str("Language code (default: eng)"),
|
||||
}, [], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 7: Network (3 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const NETWORK_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "net_info",
|
||||
description: "Get network interfaces, IPs, MAC addresses",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "net_ping",
|
||||
description: "Ping a host and return latency/packet loss",
|
||||
inputSchema: obj({
|
||||
host: str("Hostname or IP to ping"),
|
||||
count: num("Number of pings (default 4)"),
|
||||
}, ["host"], ""),
|
||||
},
|
||||
{
|
||||
name: "net_speedtest",
|
||||
description: "Run a basic network speed test (download/upload)",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 8: Hardware (3 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const HARDWARE_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "hw_temperature",
|
||||
description: "Read CPU/GPU temperature sensors",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "hw_battery",
|
||||
description: "Get battery status (level, charging, health)",
|
||||
inputSchema: obj({}, [], "No input required"),
|
||||
},
|
||||
{
|
||||
name: "hw_sensors",
|
||||
description: "Read hardware sensors (fan speed, voltage, etc.)",
|
||||
inputSchema: obj({
|
||||
type: str("Filter by sensor type (optional)"),
|
||||
}, [], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 9: Developer Tools (2 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const DEV_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "dev_shell",
|
||||
description: "Open an interactive shell session (persistent, stateful)",
|
||||
inputSchema: obj({
|
||||
command: str("Shell command (empty to start session)"),
|
||||
}, ["command"], ""),
|
||||
},
|
||||
{
|
||||
name: "dev_eval",
|
||||
description: "Execute JavaScript/Python code snippet on the device and return result",
|
||||
inputSchema: obj({
|
||||
code: str("Code to execute"),
|
||||
lang: str("js | python (default js)"),
|
||||
}, ["code"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Module 10: Media (1 tool) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const MEDIA_TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "media_control",
|
||||
description: "Control media playback (play/pause/next/prev/volume)",
|
||||
inputSchema: obj({
|
||||
action: str("play | pause | next | prev | volume_up | volume_down | mute"),
|
||||
}, ["action"], ""),
|
||||
},
|
||||
];
|
||||
|
||||
// 鈹€鈹€ Aggregate 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
|
||||
export const ALL_TOOLS: ToolCategory[] = [
|
||||
{ module: 1, name: "Device Management", tools: DEVICE_TOOLS },
|
||||
{ module: 2, name: "Command Execution", tools: EXEC_TOOLS },
|
||||
{ module: 3, name: "File Operations", tools: FILE_TOOLS },
|
||||
{ module: 4, name: "System Control", tools: SYSTEM_TOOLS },
|
||||
{ module: 5, name: "Remote Desktop", tools: REMOTE_TOOLS },
|
||||
{ module: 6, name: "AI Manipulation", tools: AI_TOOLS },
|
||||
{ module: 7, name: "Network", tools: NETWORK_TOOLS },
|
||||
{ module: 8, name: "Hardware", tools: HARDWARE_TOOLS },
|
||||
{ module: 9, name: "Developer Tools", tools: DEV_TOOLS },
|
||||
{ module: 10, name: "Media", tools: MEDIA_TOOLS },
|
||||
];
|
||||
|
||||
export const ALL_TOOL_DEFS: ToolDef[] = ALL_TOOLS.flatMap(c => c.tools);
|
||||
export const TOOL_COUNT = ALL_TOOL_DEFS.length; // 36
|
||||
|
||||
/** Look up a tool by name */
|
||||
export function getTool(name: string): ToolDef | undefined {
|
||||
return ALL_TOOL_DEFS.find(t => t.name === name);
|
||||
}
|
||||