Compare commits
148 Commits
109ad813e6
...
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 |
10
.gitignore
vendored
@@ -39,10 +39,18 @@ src-tauri/target/
|
|||||||
|
|
||||||
# Android
|
# Android
|
||||||
android/app/build/
|
android/app/build/
|
||||||
android/.gradle/
|
**/android/.gradle/
|
||||||
android/build/
|
android/build/
|
||||||
*.hprof
|
*.hprof
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
local.properties
|
local.properties
|
||||||
screen.png
|
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 { StatusBar } from 'expo-status-bar';
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { StyleSheet, Text, View, ActivityIndicator } from 'react-native';
|
import {
|
||||||
import { onAppStartCheckUpdate } from './src/updater/index';
|
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() {
|
export default function App() {
|
||||||
const [status, setStatus] = useState<'checking' | 'up-to-date' | 'updating' | 'error'>('checking');
|
const [tab, setTab] = useState<'chat' | 'terminal' | 'settings' | 'device' | 'connection'>('chat');
|
||||||
const [message, setMessage] = useState('正在检查更新...');
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Chat state
|
||||||
(async () => {
|
const [messages, setMessages] = useState<Msg[]>([]);
|
||||||
try {
|
const [input, setInput] = useState('');
|
||||||
const result = await onAppStartCheckUpdate();
|
const [sending, setSending] = useState(false);
|
||||||
if (result.jsUpdate) {
|
|
||||||
setStatus('updating');
|
// Settings state
|
||||||
setMessage('发现 JS 更新,正在下载...(下次启动生效)');
|
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_GATEWAY_CONFIG.url);
|
||||||
} else if (result.apkUpdate) {
|
const [deviceName, setDeviceName] = useState('Android Device');
|
||||||
setStatus('updating');
|
const [autoConnect, setAutoConnect] = useState(true);
|
||||||
setMessage('安装包已就绪,重启后生效');
|
const [heartbeatInterval, setHeartbeatInterval] = useState(String(DEFAULT_GATEWAY_CONFIG.heartbeatInterval / 1000));
|
||||||
} else {
|
const [logLevel, setLogLevel] = useState('info');
|
||||||
setStatus('up-to-date');
|
|
||||||
setMessage('已是最新版本');
|
// Connection state
|
||||||
}
|
const [connStatus, setConnStatus] = useState<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected');
|
||||||
} catch (e: any) {
|
|
||||||
setStatus('error');
|
// Terminal state
|
||||||
setMessage(`更新检查失败: ${e.message}`);
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<KeyboardAvoidingView style={styles.root} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<Text style={styles.title}>Yuzu GCA</Text>
|
|
||||||
<Text style={styles.subtitle}>Global Control Assistant</Text>
|
{/* Header */}
|
||||||
<View style={styles.statusBar}>
|
<View style={styles.header}>
|
||||||
{status === 'checking' && <ActivityIndicator color="#3b82f6" />}
|
<Text style={styles.title}>Yuzu GCA</Text>
|
||||||
<Text style={styles.statusText}>{message}</Text>
|
<View style={[styles.statusDot, styles[`dot_${connStatus}` as keyof typeof styles]]} />
|
||||||
</View>
|
</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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
root: { flex: 1, backgroundColor: '#0f172a' },
|
||||||
flex: 1,
|
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingTop: 48, paddingBottom: 8 },
|
||||||
backgroundColor: '#0f172a',
|
title: { fontSize: 22, fontWeight: '800', color: '#e2e8f0' },
|
||||||
alignItems: 'center',
|
statusDot: { width: 10, height: 10, borderRadius: 5 },
|
||||||
justifyContent: 'center',
|
dot_connected: { backgroundColor: '#22c55e' },
|
||||||
padding: 24,
|
dot_connecting: { backgroundColor: '#eab308' },
|
||||||
},
|
dot_error: { backgroundColor: '#ef4444' },
|
||||||
title: {
|
dot_disconnected: { backgroundColor: '#475569' },
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: '800',
|
// Tabs
|
||||||
color: '#e2e8f0',
|
tabBar: { flexDirection: 'row', marginHorizontal: 16, marginBottom: 8 },
|
||||||
marginBottom: 4,
|
tab: { flex: 1, padding: 10, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: '#1e293b' },
|
||||||
},
|
tabActive: { borderBottomColor: '#3b82f6' },
|
||||||
subtitle: {
|
tabText: { fontSize: 13, color: '#64748b' },
|
||||||
fontSize: 14,
|
tabTextActive: { color: '#3b82f6', fontWeight: '600' },
|
||||||
color: '#94a3b8',
|
|
||||||
marginBottom: 32,
|
// Chat
|
||||||
},
|
chatContainer: { flex: 1 },
|
||||||
statusBar: {
|
chatList: { flex: 1, paddingHorizontal: 12 },
|
||||||
flexDirection: 'row',
|
bubble: { marginVertical: 4, padding: 10, borderRadius: 10, maxWidth: '85%' },
|
||||||
alignItems: 'center',
|
bubbleUser: { alignSelf: 'flex-end', backgroundColor: '#1e40af' },
|
||||||
gap: 8,
|
bubbleAI: { alignSelf: 'flex-start', backgroundColor: '#1e293b', borderWidth: 1, borderColor: '#334155' },
|
||||||
},
|
bubbleRole: { fontSize: 10, color: '#94a3b8', marginBottom: 2 },
|
||||||
statusText: {
|
bubbleText: { fontSize: 14, color: '#e2e8f0' },
|
||||||
fontSize: 14,
|
emptyText: { textAlign: 'center', color: '#475569', marginTop: 40, fontSize: 14 },
|
||||||
color: '#94a3b8',
|
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' },
|
||||||
});
|
});
|
||||||
|
|||||||
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
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
#Sun Jul 19 10:50:39 CST 2026
|
#Wed Jul 22 19:01:51 CST 2026
|
||||||
gradle.version=8.10.2
|
gradle.version=8.13
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
apply plugin: "com.android.application"
|
apply plugin: "com.android.application"
|
||||||
apply plugin: "org.jetbrains.kotlin.android"
|
apply plugin: "org.jetbrains.kotlin.android"
|
||||||
|
|
||||||
archivesBaseName = "gca-v0.1.0"
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "dev.yuzu.gca"
|
namespace "dev.yuzu.gca"
|
||||||
compileSdk 35
|
compileSdk 35
|
||||||
@@ -11,7 +9,7 @@ android {
|
|||||||
applicationId "dev.yuzu.gca"
|
applicationId "dev.yuzu.gca"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode 6
|
versionCode 5
|
||||||
versionName "0.1.0"
|
versionName "0.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,10 +24,17 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions { jvmTarget = "17" }
|
kotlinOptions { jvmTarget = "17" }
|
||||||
|
|
||||||
|
applicationVariants.all { variant ->
|
||||||
|
variant.outputs.all {
|
||||||
|
outputFileName = "v${variant.versionName}-dav-android-${variant.versionCode}-${variant.buildType.name}.apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.0.21"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.0.21"
|
||||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||||
|
implementation "androidx.core:core:1.15.0"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.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>
|
||||||
@@ -1,24 +1,29 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<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.REQUEST_INSTALL_PACKAGES"/>
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
<application
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
android:label="Yuzu GCA v0.1.0"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
android:allowBackup="false"
|
<queries>
|
||||||
android:supportsRtl="true"
|
<intent>
|
||||||
android:usesCleartextTraffic="true"
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
android:theme="@style/Theme.AppCompat.DayNight">
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
<activity
|
</intent>
|
||||||
android:name=".MainActivity"
|
</queries>
|
||||||
android:label="Yuzu GCA v0.1.0"
|
<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">
|
||||||
android:exported="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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
<intent-filter>
|
||||||
</application>
|
<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>
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,user-scalable=no">
|
||||||
|
<title>Yuzu GCA</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:system-ui;background:#0f172a;color:#e2e8f0;padding:16px}
|
||||||
|
h1{font-size:20px;color:#38bdf8;margin-bottom:4px}
|
||||||
|
.info{color:#94a3b8;font-size:12px;margin-bottom:16px}
|
||||||
|
.card{background:#1e293b;border-radius:8px;padding:16px;margin:12px 0}
|
||||||
|
.card-title{font-size:13px;color:#94a3b8;margin-bottom:8px}
|
||||||
|
.btn{display:block;width:100%;padding:14px;margin:8px 0;border:none;border-radius:8px;font-size:15px;cursor:pointer;color:#fff}
|
||||||
|
.btn-blue{background:#1e40af}
|
||||||
|
.btn-green{background:#15803d}
|
||||||
|
.input{display:block;width:100%;padding:12px;margin:8px 0;border:none;border-radius:8px;font-size:14px;background:#0f172a;color:#e2e8f0}
|
||||||
|
.log{background:#0a0f1a;padding:12px;border-radius:8px;margin-top:8px;max-height:250px;overflow-y:auto;font-size:11px;white-space:pre-wrap;color:#22c55e;font-family:monospace}
|
||||||
|
.tab{display:inline-block;padding:8px 16px;margin-right:4px;border-radius:6px 6px 0 0;background:#1e293b;color:#64748b;font-size:13px;cursor:pointer}
|
||||||
|
.tab-on{background:#1e40af;color:#fff}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>Yuzu GCA</h1>
|
||||||
|
<div class="info">Self-contained — no Gateway needed</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="tab tab-on" onclick="switchTab('exec',event)">Exec</span>
|
||||||
|
<span class="tab" onclick="switchTab('files',event)">Files</span>
|
||||||
|
<span class="tab" onclick="switchTab('sysinfo',event)">SysInfo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="tab-exec">
|
||||||
|
<div class="card-title">Shell Command (local)</div>
|
||||||
|
<input class="input" id="cmd" placeholder="e.g. ls, whoami, df -h">
|
||||||
|
<button class="btn btn-blue" onclick="runExec()">Execute</button>
|
||||||
|
<div class="log" id="log-exec"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="tab-files" style="display:none">
|
||||||
|
<div class="card-title">List Files (local)</div>
|
||||||
|
<input class="input" id="path" placeholder="/sdcard or ." value=".">
|
||||||
|
<button class="btn btn-blue" onclick="runFiles()">List</button>
|
||||||
|
<div class="log" id="log-files"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="tab-sysinfo" style="display:none">
|
||||||
|
<div class="card-title">Device Info (local)</div>
|
||||||
|
<button class="btn btn-blue" onclick="runSysinfo()">Refresh</button>
|
||||||
|
<div class="log" id="log-sysinfo"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function switchTab(t,ev){
|
||||||
|
ev.target.classList.add('tab-on');
|
||||||
|
['exec','files','sysinfo'].filter(function(x){return x!==t}).forEach(function(x){
|
||||||
|
document.getElementById('tab-'+x).style.display='none';
|
||||||
|
document.querySelectorAll('.tab').forEach(function(b){if(b.textContent.toLowerCase().indexOf(x)>=0)b.classList.remove('tab-on')});
|
||||||
|
});
|
||||||
|
document.getElementById('tab-'+t).style.display='block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(id,msg){
|
||||||
|
var l=document.getElementById(id);
|
||||||
|
l.innerText+=msg+'\n';l.scrollTop=l.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runExec(){
|
||||||
|
var c=document.getElementById('cmd').value, id='log-exec';
|
||||||
|
document.getElementById(id).innerText='';
|
||||||
|
log(id,'$ '+c);
|
||||||
|
var r=Android.exec(c);
|
||||||
|
log(id,r||'(empty)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFiles(){
|
||||||
|
var p=document.getElementById('path').value, id='log-files';
|
||||||
|
document.getElementById(id).innerText='';
|
||||||
|
log(id,'ls '+p);
|
||||||
|
var r=Android.listFiles(p);
|
||||||
|
log(id,r||'(empty)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSysinfo(){
|
||||||
|
var id='log-sysinfo';
|
||||||
|
document.getElementById(id).innerText='';
|
||||||
|
log(id,Android.sysinfo());
|
||||||
|
}
|
||||||
|
</script></body></html>
|
||||||
@@ -1,148 +1,58 @@
|
|||||||
package dev.yuzu.gca
|
package dev.yuzu.gca
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Button
|
import android.util.Log
|
||||||
import android.widget.LinearLayout
|
import android.webkit.WebView
|
||||||
import android.widget.ScrollView
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.BufferedReader
|
||||||
import java.net.HttpURLConnection
|
import java.io.InputStreamReader
|
||||||
import java.net.URL
|
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
private lateinit var logView: TextView
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WebView(this).apply {
|
||||||
val scroll = ScrollView(this)
|
settings.javaScriptEnabled = true
|
||||||
val layout = LinearLayout(this).apply {
|
settings.domStorageEnabled = true
|
||||||
orientation = LinearLayout.VERTICAL
|
settings.allowUniversalAccessFromFileURLs = true
|
||||||
setPadding(32, 32, 32, 32)
|
addJavascriptInterface(LocalBridge(), "Android")
|
||||||
}
|
loadUrl("file:///android_asset/index.html")
|
||||||
|
}.also { setContentView(it) }
|
||||||
val title = TextView(this).apply {
|
|
||||||
text = "Yuzu GCA — OTA 测试"
|
|
||||||
textSize = 22f
|
|
||||||
setPadding(0, 0, 0, 16)
|
|
||||||
}
|
|
||||||
layout.addView(title)
|
|
||||||
|
|
||||||
logView = TextView(this).apply {
|
|
||||||
textSize = 13f
|
|
||||||
}
|
|
||||||
layout.addView(logView)
|
|
||||||
|
|
||||||
val btn = Button(this).apply {
|
|
||||||
text = "检查更新并安装"
|
|
||||||
setOnClickListener {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch { doOtaTest() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
layout.addView(btn)
|
|
||||||
|
|
||||||
scroll.addView(layout)
|
|
||||||
setContentView(scroll)
|
|
||||||
|
|
||||||
appendLog("设备已就绪\n点击按钮开始 OTA 端到端测试")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun appendLog(msg: String) {
|
inner class LocalBridge {
|
||||||
runOnUiThread { logView.append("$msg\n") }
|
@android.webkit.JavascriptInterface
|
||||||
}
|
fun exec(cmd: String): String {
|
||||||
|
return try {
|
||||||
private suspend fun doOtaTest() = withContext(Dispatchers.IO) {
|
val p = Runtime.getRuntime().exec(arrayOf("sh", "-c", cmd))
|
||||||
appendLog("=== OTA 端到端测试开始 ===")
|
val out = BufferedReader(InputStreamReader(p.inputStream)).readText()
|
||||||
|
val err = BufferedReader(InputStreamReader(p.errorStream)).readText()
|
||||||
appendLog("[1/4] 拉取 manifest...")
|
p.waitFor()
|
||||||
try {
|
if (out.isNotEmpty()) out else err
|
||||||
val manifestJson = httpGet("https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-6/manifest.json")
|
} catch (e: Exception) { "ERR: ${e.message}" }
|
||||||
appendLog(" manifest: ${manifestJson.take(200)}...")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appendLog(" ⚠️ 网络不可用: ${e.message}")
|
|
||||||
appendLog(" 代码逻辑已验证(版本解析/SHA256/PackageInstaller)")
|
|
||||||
appendLog("=== OTA 测试完成(离线模式) ===")
|
|
||||||
return@withContext
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appendLog("[2/4] 解析 manifest JSON...")
|
@android.webkit.JavascriptInterface
|
||||||
var manifestSha256 = ""
|
fun listFiles(path: String): String {
|
||||||
var manifestApkUrl = ""
|
return try {
|
||||||
try {
|
val dir = File(path)
|
||||||
val manifestJson = httpGet("https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-6/manifest.json")
|
if (!dir.exists()) return "ERR: path not found"
|
||||||
// 简易 JSON 解析(不用 Gson,直接字符串提取)
|
if (!dir.isDirectory) return "ERR: not a directory"
|
||||||
val sha256Field = "\"sha256\": \""
|
dir.listFiles()?.joinToString("\n") { f ->
|
||||||
val sha256Start = manifestJson.indexOf(sha256Field) + sha256Field.length
|
(if (f.isDirectory) "[DIR] " else "[FILE] ") + f.name +
|
||||||
val sha256End = manifestJson.indexOf("\"", sha256Start)
|
(if (f.isFile) " (${f.length()}B)" else "")
|
||||||
manifestSha256 = manifestJson.substring(sha256Start, sha256End)
|
} ?: "empty"
|
||||||
|
} catch (e: Exception) { "ERR: ${e.message}" }
|
||||||
val urlField = "\"url\": \""
|
|
||||||
val urlStart = manifestJson.indexOf(urlField) + urlField.length
|
|
||||||
val urlEnd = manifestJson.indexOf("\"", urlStart)
|
|
||||||
manifestApkUrl = manifestJson.substring(urlStart, urlEnd)
|
|
||||||
|
|
||||||
appendLog(" SHA256: ${manifestSha256.take(16)}...")
|
|
||||||
appendLog(" APK URL: ${manifestApkUrl.take(50)}...")
|
|
||||||
appendLog(" 需要更新 ✅")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appendLog(" ⚠️ manifest 解析失败: ${e.message}")
|
|
||||||
appendLog("=== OTA 测试中止 ===")
|
|
||||||
return@withContext
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appendLog("[3/4] 下载 APK: ${manifestApkUrl.take(40)}...")
|
@android.webkit.JavascriptInterface
|
||||||
val apkFile = File(cacheDir, "test-update.apk")
|
fun sysinfo(): String {
|
||||||
try {
|
return """
|
||||||
downloadFile(manifestApkUrl, apkFile)
|
Device: ${android.os.Build.MODEL}
|
||||||
appendLog(" 下载完成: ${apkFile.length()} bytes ✅")
|
Android: ${android.os.Build.VERSION.RELEASE} (SDK ${android.os.Build.VERSION.SDK_INT})
|
||||||
} catch (e: Exception) {
|
CPU: ${Runtime.getRuntime().availableProcessors()} cores
|
||||||
appendLog(" ❌ 下载失败: ${e.message}")
|
Memory: ${Runtime.getRuntime().maxMemory() / 1024 / 1024}MB max
|
||||||
return@withContext
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
|
||||||
appendLog("[4/4] SHA256 校验...")
|
|
||||||
val actualSha256 = computeSha256(apkFile)
|
|
||||||
if (actualSha256 == manifestSha256) {
|
|
||||||
appendLog(" ✅ SHA256 校验通过!")
|
|
||||||
appendLog(" ${actualSha256.take(32)}...")
|
|
||||||
} else {
|
|
||||||
appendLog(" ❌ SHA256 校验失败!")
|
|
||||||
appendLog(" 期望: ${manifestSha256.take(32)}...")
|
|
||||||
appendLog(" 实际: ${actualSha256.take(32)}...")
|
|
||||||
}
|
|
||||||
|
|
||||||
appendLog("=== OTA 测试完成 ===")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun httpGet(url: String): String {
|
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
|
||||||
conn.connectTimeout = 10000
|
|
||||||
conn.readTimeout = 10000
|
|
||||||
return conn.inputStream.bufferedReader().readText()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun downloadFile(url: String, dest: File) {
|
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
|
||||||
conn.connectTimeout = 30000
|
|
||||||
conn.readTimeout = 120000
|
|
||||||
conn.inputStream.use { input ->
|
|
||||||
FileOutputStream(dest).use { output -> input.copyTo(output) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeSha256(file: File): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
file.inputStream().use { input ->
|
|
||||||
val buf = ByteArray(8192)
|
|
||||||
var read: Int
|
|
||||||
while (input.read(buf).also { read = it } != -1) {
|
|
||||||
digest.update(buf, 0, read)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
<resources>
|
||||||
<color name="primary">#3b82f6</color>
|
<color name="splashscreen_background">#0f172a</color>
|
||||||
<color name="background">#0f172a</color>
|
<color name="iconBackground">#0f172a</color>
|
||||||
|
<color name="colorPrimary">#023c69</color>
|
||||||
|
<color name="colorPrimaryDark">#0f172a</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Yuzu GCA v0.1.0</string>
|
<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>
|
</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>
|
||||||
@@ -12,7 +12,7 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath("com.android.tools.build:gradle:8.7.3")
|
classpath('com.android.tools.build:gradle:8.13.2')
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
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" "$@"
|
||||||
88
packages/client-android/android/gradlew.bat
vendored
@@ -1,26 +1,94 @@
|
|||||||
@echo off
|
@rem
|
||||||
setlocal
|
@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
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
set APP_HOME=%DIRNAME%
|
@rem This is normally unused
|
||||||
set APP_BASE_NAME=%~n0
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
set ANDROID_HOME=C:\Users\Middl\AppData\Local\Android\Sdk
|
|
||||||
@rem Find java.exe
|
@rem Find java.exe
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
set JAVA_EXE=java.exe
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if "%ERRORLEVEL%" == "0" goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
goto fail
|
||||||
:fail
|
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
exit /b 1
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
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
|
goto fail
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
:end
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
rootProject.name = 'YuzuGCA'
|
rootProject.name = 'YuzuGCA'
|
||||||
|
|
||||||
include ':app'
|
include ':app'
|
||||||
|
|||||||
@@ -25,14 +25,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"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": {
|
"extra": {
|
||||||
"eas": {
|
"eas": {
|
||||||
"projectId": "yuzu-gca-android"
|
"projectId": "yuzu-gca-android"
|
||||||
|
|||||||
BIN
packages/client-android/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 354 B |
BIN
packages/client-android/assets/icon.png
Normal file
|
After Width: | Height: | Size: 354 B |
BIN
packages/client-android/assets/splash.png
Normal file
|
After Width: | Height: | Size: 354 B |
4
packages/client-android/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRootComponent } from 'expo';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
registerRootComponent(App);
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.1.0",
|
|
||||||
"builds": {
|
|
||||||
"android": {
|
|
||||||
"type": "apk",
|
|
||||||
"tag": "v0.1.0-dav-android-6",
|
|
||||||
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-6/gca-v0.1.0.apk",
|
|
||||||
"sha256": "6cd6bbfe07e8b86b06bc328b7b08138dfb00defc71c8bcc98fbb256b2153aba6",
|
|
||||||
"size": 3223411,
|
|
||||||
"minVersion": "0.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,21 +2,21 @@
|
|||||||
"name": "@yuzu-gca/client-android",
|
"name": "@yuzu-gca/client-android",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "expo-router/entry",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"build": "eas build -p android --profile preview",
|
"build": "eas build -p android --profile preview",
|
||||||
"export": "expo export --platform android",
|
"export": "expo export --platform android",
|
||||||
"prebuild": "expo prebuild --platform android"
|
"prebuild": "expo prebuild --platform android",
|
||||||
|
"ios": "expo run:ios"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@yuzu-gca/shared": "workspace:*",
|
"@yuzu-gca/shared": "workspace:*",
|
||||||
"expo": "~52.0.0",
|
"expo": "~52.0.49",
|
||||||
"expo-constants": "~17.0.0",
|
"expo-constants": "~17.0.0",
|
||||||
"expo-file-system": "~18.0.0",
|
"expo-file-system": "~18.0.0",
|
||||||
"expo-status-bar": "~2.0.0",
|
"expo-status-bar": "~2.0.0",
|
||||||
"expo-updates": "~26.0.0",
|
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-native": "0.76.5"
|
"react-native": "0.76.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,9 +8,17 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@yuzu-gca/shared": ["../shared/src"]
|
"@yuzu-gca/shared": [
|
||||||
|
"../shared/src"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"**/*.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 { parseVersion, compareVersion, checkUpdate, semverGt } from './updater/index.js';
|
||||||
export type { GcaVersion, Manifest, ManifestBuild } from './updater/index';
|
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,
|
||||||
|
};
|
||||||