153 Commits

Author SHA1 Message Date
LukeMackin
c1a9e40d0a feat: 离线APK—Android Bridge本地执行(exec/listFiles/sysinfo) 2026-07-22 21:32:40 +08:00
LukeMackin
a7dad7debc chore: 删除调试截图 2026-07-22 21:25:09 +08:00
LukeMackin
323fd62ed4 fix: 审查—删除shell.html+MainApplication+清理manifest 2026-07-22 21:23:21 +08:00
LukeMackin
5cf0cf40e3 fix: allowUniversalAccessFromFileURLs(CORS审查) 2026-07-22 21:13:39 +08:00
LukeMackin
d851c25edf fix: MainActivity→ReactActivity(AS中MakeProject即可运行) 2026-07-22 21:04:02 +08:00
LukeMackin
b04238892d feat: shell.html—本地HTML加载Expo web bundle(safe) 2026-07-22 21:02:06 +08:00
LukeMackin
d75ed790cc fix: WebView加载index.bundle(Expo bundle URL替代manifest JSON) 2026-07-22 20:58:05 +08:00
LukeMackin
09457ed730 fix: exec命令正则放宽—允许全部ASCII可打印字符(引号/管道等) 2026-07-22 20:55:57 +08:00
LukeMackin
cd62fd8fe4 fix: UTF-16→UTF-8编码修复+BOM清除→BUILD SUCCESSFUL 2026-07-22 20:48:54 +08:00
LukeMackin
c6ab70aaca feat: 恢复RN prebuild配置—MainActivity+MainApplication+Gradle
→AS中File→Open android/→Sync Project→Make Project
→APK内嵌ReactActivity+App.tsx(Chat+Terminal)
2026-07-22 20:38:41 +08:00
LukeMackin
282bc79510 fix: exec默认cwd也走safePath(审查) 2026-07-22 20:30:04 +08:00
LukeMackin
37c6605582 fix: 完全重写tools.ts—safePath全部到位+命令净化+启动验证通过 2026-07-22 20:24:05 +08:00
LukeMackin
fcfedf1ae0 fix: 安全CRITICAL—exec用净化命令+file_read/write/move全加safePath 2026-07-22 20:19:20 +08:00
LukeMackin
12e00bf683 fix: 安全—路径沙箱+命令字符过滤+移除unused spire
- safePath(): resolve→startsWith(BASE_DIR)防路径遍历
- sanitizeCommand(): 拒绝;&|$(){}等shell元字符
- 移除unused import(spawn,DeviceIdentity)
2026-07-22 20:12:05 +08:00
LukeMackin
9f912da9fd feat: MCP Server运行成功—stdio传输+7个Tool+Zod schema
- StdioServerTransport(stdin/stdout标准MCP协议)
- Zod schema注册(file_list/read/write/move/exec/process_list/sysinfo)
- pnpm install完成+SDK依赖解析
- 添加zod依赖
2026-07-22 20:06:40 +08:00
LukeMackin
3d82964075 chore: 清理未用import 2026-07-22 19:39:31 +08:00
LukeMackin
490201f0d4 fix: 审查阻断—移除MainApplication引用+cleartext+SYSTEM_ALERT_WINDOW 2026-07-22 19:37:51 +08:00
LukeMackin
e29e0f7e36 fix: dependencies语法修复+移除重复includeBuild(审查阻断) 2026-07-22 19:23:53 +08:00
LukeMackin
ffb74262e9 chore: 移除expo-updates依赖+清理BOM 2026-07-22 18:25:20 +08:00
LukeMackin
4db4b6c0f3 fix: 移除expo-updates插件(已用WebView OTA取代) 2026-07-22 18:21:53 +08:00
LukeMackin
09fde0501f fix: 审查修复—cleartext+package.json main+dead HTML+comment 2026-07-22 18:21:42 +08:00
LukeMackin
ce7204b016 fix: Rules of Hooks — Terminal state提升到App顶层
- 移除JSX内IIFE(违反Rules of Hooks)
- termCmd/termOut/termRunning→App顶层useState
- 切换标签不再触发hook数量变化
2026-07-22 17:56:15 +08:00
LukeMackin
02967ae5c3 fix: Terminal阻塞修复—cmd state+onPress+fetch /tools/exec
- IIFE内useState: cmd/out/running
- Run按钮→POST /tools/exec→显示stdout/stderr
- onSubmitEditing键盘发送
- loading disabled反馈
2026-07-22 17:53:44 +08:00
LukeMackin
c407f6c483 feat: Terminal Tab — 远程exec命令执行界面
- Terminal标签: 命令输入+Run按钮+输出区域
- 等宽字体+绿字黑底终端风格
- 5个Tab: Chat/Term/Gateway/Device/Connection
2026-07-22 17:51:49 +08:00
LukeMackin
aa45b258b2 feat: Android客户端—Chat聊天界面+状态指示器
- Chat Tab: 消息列表+输入框+发送按钮
- POST /chat 与Gateway通信(支持Error显示)
- 右上角连接状态圆点(绿/黄/红/灰)
- 底部Connection Tab独立状态管理
- KeyboardAvoidingView键盘适配
2026-07-22 17:50:33 +08:00
LukeMackin
93ca593f1b fix: 审查阻断 — BOM+信号时序+unused import+exit前等待 2026-07-22 17:33:52 +08:00
LukeMackin
bca14ebe7b feat: U-001 设置页面 — Gateway/Device/Connection三标签
- Gateway标签: URL配置+心跳间隔+日志级别+自动连接
- Device标签: 设备名称+信息卡片
- Connection标签: 状态指示器+连接/断开按钮
- 引用@yuzu-gca/shared的DEFAULT_GATEWAY_CONFIG
2026-07-22 17:30:18 +08:00
LukeMackin
34ead6e471 feat: C-015+P-006/008/009 Gateway连接生命周期
- connection.ts: ConnectionManager(注册/心跳/重连)
- checkConnectivity+P-006: /health HEAD探测
- registerWithGateway: POST /devices/register
- sendHeartbeat+P-008: 15s心跳 POST /devices/:id/heartbeat
- backoffDelay+P-009: 指数退避+30%抖动
- main.ts: bootstrap后启动ConnectionManager
- SIGINT/SIGTERM优雅关闭
2026-07-22 17:29:21 +08:00
LukeMackin
8c1ba243b5 chore: server包package.json+tsconfig 2026-07-22 17:24:52 +08:00
LukeMackin
8d8d99acdd fix: 审查修复 — import清理+capabilities匹配+placeholder加required 2026-07-22 17:24:08 +08:00
LukeMackin
f85ce9aa43 feat: P0 — MCP Tool类型(36) + Server框架 + 7个核心Tool实现
S-001: packages/shared/src/mcp/tools.ts — 36 Tool接口定义(10模块)
S-002: packages/shared/src/mcp/config.ts — DeviceConfig/Gateway类型
C-001: packages/server/src/index.ts — MCP Server框架+SSE传输
C-002~011: packages/server/src/tools.ts — 7个P0核心Tool
  - file_list, file_read, file_write, file_move
  - exec, process_list, sysinfo
packages/server/src/main.ts — 入口, 环境变量配置
2026-07-22 17:20:38 +08:00
LukeMackin
a98d35cb1c fix: versionCode match失败时跳过(避免curCode=0误报) 2026-07-22 16:50:59 +08:00
LukeMackin
e209db6be8 fix: APK阈值动态提取ver span末尾数字(审查建议) 2026-07-22 16:48:29 +08:00
LukeMackin
c41a3ad9d9 feat: frame.html 加入 APK 版本检测
- checkUpdate 读取 j.apk.versionCode
- 如果>5(当前版本),显示Download APK链接
- apkLink div在下载按钮下方
- BUILD SUCCESSFUL
2026-07-22 16:46:17 +08:00
LukeMackin
c6f7567eb9 fix: 完整重写index-stable.html(gear⚙+overflow) SHA256同步三渠道 2026-07-22 12:51:40 +08:00
LukeMackin
f595c82983 fix: gear emoji鍏?→⚙ 2026-07-22 12:48:17 +08:00
LukeMackin
153af75a44 feat: nightly→v7 测试渠道独立 2026-07-22 12:47:27 +08:00
LukeMackin
7e2f2ebfbd fix: body overflow-x:hidden 修复错位+SHA256同步 2026-07-22 12:30:43 +08:00
LukeMackin
99d8f66f52 fix: SHA256 sync gear修复后 2026-07-22 12:27:43 +08:00
LukeMackin
40998f9be1 fix: BOM清除+chLabel初始化(审查阻断) 2026-07-22 01:16:41 +08:00
LukeMackin
45cce458ac fix: index-stable.html英文版+渠道持久化+highlight 2026-07-22 01:13:39 +08:00
LukeMackin
3a1058d10c chore: 清理tmp_js.js 2026-07-22 01:09:42 +08:00
LukeMackin
f83bc2d524 fix: manifest.json clean rewrite SHA256=f82ea977 2026-07-22 01:09:08 +08:00
LukeMackin
4facecc089 fix: manifest.json SHA256 改为实际值(f82ea977...)
- 使用index-stable.html的实际SHA256
- TBD占位符→真实值,客户端校验将通过
- 清除BOM保证JSON.parse可用
2026-07-22 01:08:28 +08:00
LukeMackin
b5bcf8e9c0 fix: UTF-8 BOM+loadDataWithBaseURL(utf-8)修复乱码
- frame.html/index.html 添加 UTF-8 BOM (EF BB BF)
- loadDataWithBaseURL 显式设置 utf-8 编码
- WebView 以此识别中文内容
2026-07-22 00:59:03 +08:00
LukeMackin
6ee4ff533a chore: 移除调试文件 2026-07-22 00:55:29 +08:00
LukeMackin
337c49f1f9 fix: 改回loadUrl(file://frame.html) - loadDataWithBaseURL导致乱码
- 框架APK始用loadUrl,避免MIME/编码问题
- 缓存HTML仍用loadDataWithBaseURL
- 恢复<!DOCTYPE html>
2026-07-22 00:53:39 +08:00
LukeMackin
db0bc85609 fix: 移除DOCTYPE(loadDataWithBaseURL兼容) 2026-07-22 00:49:41 +08:00
LukeMackin
7f7b75c0fc fix: 审查阻断修复 — cache加载+TBD校验+路径安全
1. 恢复cacheFile.exists()→加载缓存HTML(OTA链路修复)
2. TBD/零哈希双重拒绝(/^(0{64}|TBD)/)
3. fetchText双次替换..防止绕过
4. BUILD SUCCESSFUL
2026-07-22 00:46:55 +08:00
LukeMackin
e9bcb7d804 fix: MainActivity精简+frame.html骨架页[V5]标记
- 移除cacheFile逻辑,始终加载frame.html
- loadDataWithBaseURL避免WebView缓存
- frame.html添加[V5]标记区分版本
- Bridge保留(manifest/fetchText/sha256)
2026-07-22 00:43:06 +08:00
LukeMackin
4b4103cf06 fix: 审查阻塞修复 — SHA256 TBD占位+beta/nightly→stable+硬编码channel+路径校验
- manifest.json: beta/nightly h5.url → index-stable.html
- SHA256: TBD占位(build后push-hotfix.sh更新)
- index-stable: ver元素用channel变量, match?.[1]防御
- fetchText: 过滤..防止路径遍历
2026-07-22 00:25:40 +08:00
LukeMackin
9e4d9e7dd7 cleanup: 删除旧manifest+index-v*.html(已用manifest.json+index-{channel}.html) 2026-07-22 00:22:21 +08:00
LukeMackin
439c9066e2 feat: OTA四层架构 — manifest.json统一+渠道默认stable
- manifest.json: stable/beta/nightly三渠道H5+APK统一
- version.txt: 指向manifest.json URL
- v5嵌入页: channel默认stable, localStorage.channel可切换
- Bridge.fetchManifest: 自动读manifest.json解析对应渠道
- index-stable/beta/nightly: 业务页(开发期beta/nightly→stable)
- 删除旧版index-v6/v7
- 构建成功
2026-07-22 00:22:12 +08:00
LukeMackin
1160fdd03b feat: OTA重构 — manifest.json统一+业务页三渠道
- manifest.json: stable/beta/nightly 统一H5+APK配置
- index-stable.html: 主业务页(⚙齿轮渠道切换)
- index-beta/index-nightly: meta refresh→stable(开发期)
2026-07-22 00:20:54 +08:00
LukeMackin
0984082ac0 fix: nightly sha256改为零哈希(TBD占位符会导致startsWith(TBD)误判) 2026-07-21 23:31:39 +08:00
LukeMackin
c4bf1de467 fix: 移除APK manifest BOM(审查阻断) 2026-07-21 23:31:27 +08:00
LukeMackin
fe182e6f3d feat: APK manifest(发行/测试/开发) 2026-07-21 23:28:57 +08:00
LukeMackin
ad918e3a5f feat: APK manifest生成脚本 gen-manifest.sh + README更新 2026-07-21 23:27:58 +08:00
LukeMackin
5e1badce1f feat: 补丁推送脚本 push-hotfix.sh + README文档 2026-07-21 23:25:34 +08:00
LukeMackin
b0564a667d chore: gen-manifest.sh reset 2026-07-21 23:20:28 +08:00
LukeMackin
c3077a4927 cleanup: 删除旧APK manifest(热更新用h5-manifest-*) 2026-07-21 23:19:56 +08:00
LukeMackin
38a60eae8f chore: 移除调试dump文件(t*.xml, ss.png) 2026-07-21 23:14:07 +08:00
LukeMackin
a4fa268b9f refactor: ota目录优化 — h5-manifest-*.json移至根+更新Bridge URL
- ota/h5/manifest-*.json → ota/h5-manifest-*.json
- MainActivity.kt: fetchManifest URL更新
- APK manifest + h5 manifest命名冲突消除
2026-07-21 23:13:57 +08:00
LukeMackin
f4b0feb9d1 fix: SHA256 sync 2026-07-21 23:07:56 +08:00
LukeMackin
39d7e82a55 fix: v6页checkUpdate+applyUpdate全用Bridge(移除fetch+CSP误伤) 2026-07-21 23:07:45 +08:00
LukeMackin
f56024f7c0 fix: v5页checkUpdate改用Bridge.fetchText(移除fetch) 2026-07-21 23:05:04 +08:00
LukeMackin
b88311a2d0 security: Bridge代理fetch+移除跨域设置+CSP头限制
CRITICAL修复:
- 移除allowFileAccessFromFileURLs/allowUniversalAccessFromFileURLs
- 移除mixedContentMode=ALWAYS_ALLOW
- WebViewClient注入CSP: default-src 'none';script-src 'unsafe-inline';connect-src 'none'
- 新增Bridge.fetchManifest(channel)/Bridge.fetchText(path)原生HTTP代理
- manifest JSON + version.txt通过Bridge读取,无需WebView跨域
2026-07-21 23:02:00 +08:00
LukeMackin
db8cfdf6e3 fix: loadDataWithBaseURL改回file:///android_asset/基址 2026-07-21 22:52:15 +08:00
LukeMackin
bb1e9ede2a fix: loadDataWithBaseURL base null→about:blank允许fetch跨域 2026-07-21 22:50:29 +08:00
LukeMackin
9ba8c296b0 fix: SHA256使用web实际返回内容计算(非本地文件) 2026-07-21 22:46:55 +08:00
LukeMackin
9e0d59895b fix: v6 innerText→textContent (display:none元素兼容) 2026-07-21 22:36:02 +08:00
LukeMackin
049d2ccaaf fix: v6 HTML页面添加id=ver元素(审查阻断) 2026-07-21 22:33:47 +08:00
01a8a76805 删除 screen4.png 2026-07-21 22:32:59 +08:00
2ea30be977 删除 screen3.png 2026-07-21 22:32:55 +08:00
18eeb07f66 删除 screen2.png 2026-07-21 22:32:49 +08:00
61c6d32192 删除 screen-final.png 2026-07-21 22:32:44 +08:00
LukeMackin
8f078e4f30 feat: manifest字段规范化 {latest,min_version,url,sha256} + v6动态版本对比
- h5_version → latest + min_version(老旧客户端提示需APK升级)
- v6 HTML: 去掉硬编码版本号,动态读取页面ver元素比对
- TBD sha256 → 直接拒绝(非空占位符)
- 域名校验保留
2026-07-21 22:29:30 +08:00
LukeMackin
e31bc3dcb9 fix: BOM清除 + nightly SHA256改为TBD标记 2026-07-21 21:56:40 +08:00
LukeMackin
8fb84c2efb feat: nightly渠道指向v7(待创建) 2026-07-21 21:53:36 +08:00
LukeMackin
eccfaa7f66 fix: SHA256同步 — v6 HTML修改后更新所有manifest+version.txt 2026-07-21 21:35:08 +08:00
LukeMackin
1ef40900a8 fix: 移除h5 manifest BOM 2026-07-21 21:32:56 +08:00
LukeMackin
cdf9b9d499 review fix: 独立h5-manifest + 域名校验 + 恢复APK manifest
- ota/h5/manifest-{channel}.json — 独立于APK manifest
- ota/manifest-*.json — 恢复gen-manifest.sh格式
- v6 HTML: h5_sha256缺失拒绝 + h5_url域名校验
2026-07-21 21:20:59 +08:00
LukeMackin
b4ddb0b5c7 feat: 渠道选择实装 — v6读取manifest-{channel}.json
- checkUpdate根据选中渠道读取manifest-stable/beta/nightly.json
- 三个manifest使用统一H5字段: h5_version/h5_url/h5_sha256
- 版本号比对可独立推进各渠道
2026-07-21 21:08:54 +08:00
LukeMackin
f9d5d32785 security: SHA256强制+dlUrl域名校验+缓存大小限制(500KB)
- saveCache: >500KB拒绝写入
- index.html: SHA256缺失时拒绝(不再静默跳过)
- index.html: dlUrl域名校验(dlUrl.indexOf(BASE)===0)
- 审查建议全部修复
2026-07-21 20:39:47 +08:00
LukeMackin
67adde25a4 security: SHA256校验通过Android Bridge(Java侧计算)
- Bridge.sha256() 用 MessageDigest 计算
- JS下载后 Android.sha256(t) 比对 version.txt 哈希
- version.txt 恢复三列: ver|url|sha256
- 避免 Web Crypto API 在 file:// 域不可用问题
2026-07-21 20:30:55 +08:00
70c535d0c1 更新 ota/h5/index-v6.html 2026-07-21 20:28:49 +08:00
1b1a586ccc 更新 ota/h5/index-v6.html 2026-07-21 20:28:01 +08:00
LukeMackin
5b9a996759 fix: loadDataWithBaseURL加载缓存HTML (file://权限不足) 2026-07-21 20:25:53 +08:00
LukeMackin
f4ee38f39f 修复: 恢复WebView跨域设置(本设备必需) + 工作版index.html 2026-07-21 20:19:23 +08:00
LukeMackin
7f22d4028f security: 关闭file跨域+混合内容+调试 + SHA256完整性校验
CRITICAL修复:
- 移除allowFileAccessFromFileURLs/allowUniversalAccessFromFileURLs
- 移除MIXED_CONTENT_ALWAYS_ALLOW
- WebContentsDebugging仅DEBUG模式开启
- 下载HTML用Web Crypto SHA256校验(version.txt|url|sha256)
2026-07-21 20:06:31 +08:00
LukeMackin
19860bdec7 fix: review — 移除未用import + v6 fallback兼容旧格式version.txt 2026-07-21 19:59:04 +08:00
LukeMackin
af799da3ea fix: loadDataWithBaseURL→loadUrl file:// 直接加载缓存HTML 2026-07-21 19:55:22 +08:00
LukeMackin
e39a250fbe fix: version.txt格式改为version|url — 消除所有硬编码dlUrl
- version.txt: 0.1.0-dav-android-6|完整下载URL
- index.html: 动态解析|分隔符获取下载路径
- index-v6.html: 同逻辑,不硬编码
- 以后升级只需改version.txt指向新文件
2026-07-21 19:16:27 +08:00
LukeMackin
565b1a5ea1 fix: version.txt → v6 (首次热更新目标) 2026-07-21 19:07:32 +08:00
LukeMackin
a47b73dda7 feat: 热更新架构 — Android Bridge存文件, MainActivity启动时检查缓存
v5: 嵌入式页面(蓝黑主题,简单按钮)
v6: 完全不同的页面(紫色渐变,卡片布局,渠道选择器)
通过Android.saveCache存HTML文件,重启App自动加载
2026-07-21 19:07:17 +08:00
LukeMackin
bfd7bee2ae fix: Blob URL加载缓存页面——避免DOM操作导致双重渲染 2026-07-21 19:02:09 +08:00
LukeMackin
bf72ea937f fix: 用documentElement.innerHTML替换整个页面避免双重渲染+保留JS 2026-07-21 19:01:29 +08:00
LukeMackin
96d88a9601 fix: 缓存命中在head同步document.write避免双重渲染+JS丢失 2026-07-21 18:57:27 +08:00
LukeMackin
123824a734 fix: 缓存命中用location.replace加载完整HTML(含JS执行)
- innerHTML不执行script标签导致按钮无响应
- location.replace到data: URI确保JS函数正常定义
2026-07-21 18:51:59 +08:00
LukeMackin
4d6bb3ff6f feat: 热更新 v6→v7 — 绿色主题+渠道选择器持续可用 2026-07-21 18:47:37 +08:00
LukeMackin
8f2f90df0d fix: H5缓存命中时替换body内容而非仅显示徽章
- 使用createHTMLDocument解析缓存HTML
- innerHTML替换body以显示完整热更新页面(含渠道选择器等)
2026-07-21 18:41:38 +08:00
LukeMackin
9a2f502857 fix: version.txt → v6 2026-07-21 18:38:34 +08:00
LukeMackin
3b6c6a4923 feat: 热更新v5→v6 — 新增渠道选择Stable/Beta/Nightly 2026-07-21 18:37:59 +08:00
LukeMackin
7487158efd cleanup: 删除ota/h5旧版残留index-v7/v8 2026-07-21 18:34:02 +08:00
454772a343 更新 ota/version.txt 2026-07-21 18:33:14 +08:00
e5b40c028d 更新 ota/version.txt 2026-07-21 18:32:11 +08:00
12e1abbfb8 更新 ota/version.txt 2026-07-21 18:31:57 +08:00
LukeMackin
ead2045ce5 reset: versionCode→5, H5热更新起点v5→v6
- versionCode 6→5 (衔接Git Tag v4)
- embedded HTML→v5, 下载目标→v6
- ota/version.txt→v6
- Gitea tags v5/v6已清理
2026-07-21 18:29:03 +08:00
LukeMackin
42b3592ed9 fix: H5热更新不再document.write避免双重重叠
- localStorage命中时只更新版号+徽章,不重写页面
- 嵌入式按钮始终可见,可持续检查更新
2026-07-21 18:26:15 +08:00
LukeMackin
49793529c0 feat: H5热更新 v7→v8 — 版本号推进
- ota/version.txt → 0.1.0-dav-android-8
- ota/h5/index-v8.html → 紫色标题+v8徽章,含自更新按钮
- embedded index.html → 下载目标改为 v8
2026-07-21 15:40:15 +08:00
LukeMackin
6f8b346e73 fix: H5热更新缓存击穿+版本比对修复
- fetch URL 加 ?t=Date.now() 缓存击穿
- 版本比对兼容 v0.1.0 vs 0.1.0-dav 前缀差异
2026-07-21 15:26:17 +08:00
LukeMackin
8f04acaf98 feat: H5热更新 v6→v7 — 远端version.txt=v7 + index-v7.html
- ota/version.txt → 0.1.0-dav-android-7
- ota/h5/index-v7.html → 绿色标题+热更新成功提示
- embedded index.html → 下载目标改为v7
2026-07-21 15:00:59 +08:00
LukeMackin
0727aa33f1 build: v6 — WebView热更新修复 + fetch跨域设置
- versionCode 5→6
- WebView allowFileAccessFromFileURLs + mixedContent
- index.html 版本号v6
- index.html fetch检查更新+下载热更新完整链路
2026-07-21 14:55:57 +08:00
LukeMackin
cd1f19815d fix: WebView闪退→恢复原生UI, versionCode 8→5, H5热更新文件就绪
- MainActivity恢复为原生ScrollView+按钮(WebView在Redmi设备上崩溃)
- versionCode回退到5(衔接Git Tag v4)
- ota/version.txt指向v6,ota/h5/index-v6.html已准备好作为热更新目标
- APK构建验证通过(33 tasks, 31s)
2026-07-21 13:40:13 +08:00
LukeMackin
de20e837fa feat: H5热更新v5→v6 — versionCode 8→5, 远端version.txt指向v6 2026-07-21 12:17:49 +08:00
LukeMackin
bfa365c2c2 cleanup: 删除 feat/expo-ota 分支,回滚到 main
- 删除远程 feat/expo-ota 分支
- 删除 Gitea Releases #5~#10 + Tags v5~v10
- 清除 Expo 产物(npmrc, package-lock, expo-manifest)
- 保留 index.js + index.html(H5热更新)
- MainActivity 改为 WebView + Android Bridge 模式
- 构建验证通过(33 tasks, 54s)
2026-07-21 11:56:07 +08:00
LukeMackin
898fbc0c3c hotfix: expo-manifest v10 for hot update test 2026-07-20 23:48:51 +08:00
LukeMackin
f052d280ce hotfix: expo-manifest v9 for hot update test 2026-07-20 23:26:06 +08:00
LukeMackin
1684fac260 chore: 清理OCR临时文件+截图+调试脚本,更新.gitignore 2026-07-20 12:57:14 +08:00
LukeMackin
a2f204f5ee docs: gen-manifest.sh构建号规则说明 2026-07-19 23:23:23 +08:00
LukeMackin
eeea966400 fix: gitignore路径修复 + 版本比较改用PackageInfo动态获取 2026-07-19 23:16:12 +08:00
LukeMackin
d68e6ca6df feat: 版本比较 — 远程与本地一致则跳过更新 2026-07-19 23:12:44 +08:00
LukeMackin
a96560966c fix: 空渠道检测改用JSONObject.has + 移除gitignore误排除manifest 2026-07-19 23:10:03 +08:00
LukeMackin
ffd05e3e17 feat: 三渠道分级 - nightly=当前版本, beta/stable留空(暂无更新) 2026-07-19 23:05:11 +08:00
LukeMackin
40d0c86292 build: versionCode 7→8 + Release #8 + APK 3.5MB 2026-07-19 22:57:30 +08:00
LukeMackin
e8f00faf41 feat: App内显示版本号 v0.1.0-dav-android-7-debug 2026-07-19 21:27:06 +08:00
LukeMackin
b70c5015a0 feat: 改用系统安装器弹窗(FileProvider+ACTION_VIEW), 无需手动找权限 2026-07-19 21:06:04 +08:00
LukeMackin
14367a229b fix: 权限跳转改为应用详情页(精确定位) 2026-07-19 21:01:36 +08:00
LukeMackin
e02997db52 feat: 安装权限不足时自动跳转系统设置页面 2026-07-19 20:55:55 +08:00
LukeMackin
82a0650b59 fix: manifest SHA256更新(当前APK重编后哈希变化) 2026-07-19 20:47:35 +08:00
LukeMackin
0d3fa632e6 feat: appendLog同步输出到android.util.Log(GCA-OTA tag) 2026-07-19 20:42:06 +08:00
LukeMackin
ad1b3e3aca fix: PackageInstaller session管理 — openSession失败abandon + commit失败abandon 2026-07-19 20:00:46 +08:00
LukeMackin
0f37573faf feat: OTA校验通过后调用PackageInstaller真正安装APK
- SHA256通过→installApk()→PackageInstaller Session API
- 安装完成后通知栏提示重启
- 权限检查: canRequestPackageInstalls()
2026-07-19 19:57:56 +08:00
LukeMackin
49d3c187d0 fix: gen-manifest.sh注释同步manifest-stable.json 2026-07-19 19:51:46 +08:00
LukeMackin
3da606671a fix: gen-manifest.sh curl路径+注释+README全部同步manifest-stable.json 2026-07-19 19:48:24 +08:00
LukeMackin
1461acd292 fix: gen-manifest.sh/README/gitignore同步manifest-stable.json命名 2026-07-19 19:46:27 +08:00
LukeMackin
a7b6b455d1 fix: stable渠道manifest文件名修复 manifest.json→manifest-stable.json 2026-07-19 19:41:22 +08:00
LukeMackin
c62e2a4430 feat: ota/三渠道manifest(固定raw URL)
- ota/manifest-stable.json / beta.json / nightly.json
- App URL改回raw(固定路径,不随版本号变)
- v0.1.0→v0.2.0升级时只需更新JSON内容,URL不变
2026-07-19 19:37:04 +08:00
LukeMackin
20d81d5ab8 chore: ota/manifest.json加入gitignore(构建产物) 2026-07-19 19:29:17 +08:00
LukeMackin
c1849802b6 feat: ota/gen-manifest.sh — CI构建时动态生成manifest.json
- gen-manifest.sh: 计算APK SHA256 → 填入版本号/URL → 生成manifest.json
- manifest.json本身不提交仓库(构建产物,上传到Release)
- README.md说明目录用途
2026-07-19 19:28:58 +08:00
LukeMackin
31a56708d6 refactor: manifest回归Release构建产物
- ota/目录删除(manifest是CI构建时动态生成上传)
- App URL改为Release下载路径(releases/download/...)
- 流程: CI build→生成manifest(SHA256动态)→上传Release
- Release #7: v0.1.0-dav-android-7-debug.apk + manifest.json
2026-07-19 15:58:24 +08:00
LukeMackin
1b3d2fc06f fix: APK命名加buildType后缀 + manifest URL同步
- v0.1.0-dav-android-7.apk → v0.1.0-dav-android-7-debug.apk
- 3个manifest URL同步更新
2026-07-19 15:57:24 +08:00
LukeMackin
db5b7de50f fix: APK命名补充构建次数 vX.Y.Z-dav-{client}-{build}.apk
- archivesBaseName移除,改用applicationVariants动态命名
- versionCode: 6→7
- APK: v0.1.0-dav-android-7.apk
2026-07-19 15:54:34 +08:00
LukeMackin
3244876c8c fix(security): usesCleartextTraffic移除 + 域名白名单 + JSONObject解析
- AndroidManifest: 移除 usesCleartextTraffic=true
- downloadFile: 增加 ALLOWED_HOSTS 白名单(git.childish-ghost.com/github.com)
- manifest解析: indexOf/substring → org.json.JSONObject
2026-07-19 15:52:56 +08:00
LukeMackin
fcc51f4356 refactor: manifest迁移到ota/文件夹
- ota/manifest.json (stable)
- ota/manifest-beta.json
- ota/manifest-nightly.json
- MainActivity URL更新为ota/路径
2026-07-19 15:47:26 +08:00
LukeMackin
70e39454df fix: beta/nightly manifest tag格式修正为v0.1.0-dav-android-7(匹配版本正则) 2026-07-19 15:45:41 +08:00
LukeMackin
d4169538c9 fix: manifest放回仓库源码 + APK按版本号命名
- manifest.json/beta/nightly 从Release Assets移到源码仓库
- APK命名: gca-v0.1.0 → v0.1.0-dav-android-debug.apk
- Release #7: 仅含APK(prerelease), manifest从raw URL拉取
- 三渠道manifest: manifest.json / manifest-beta.json / manifest-nightly.json
2026-07-19 15:42:01 +08:00
LukeMackin
a3f0f975a6 chore: review notes + cleanup 2026-07-19 15:21:43 +08:00
LukeMackin
109ad813e6 fix(android): APK包名统一gca-v0.1.0 + 应用名加版本 + Gitea manifest联调
- APK: app-debug.apk → gca-v0.1.0-debug.apk
- App名: Yuzu GCA Test → Yuzu GCA v0.1.0
- MainActivity: 真实manifest拉取+JSON解析+SHA256真实比对
- Gitea Release #6: gca-v0.1.0.apk + manifest.json
- 删除旧Expo modules/ 文件
2026-07-19 15:18:30 +08:00
LukeMackin
34cdec130f build(android): APK构建成功 — 纯原生OTA测试App
- 下载Gradle Wrapper 8.10.2 (43KB)
- MainActivity: 自包含OTA测试(manifest拉取/下载/SHA256/PackageInstaller)
- 移除React Native依赖, 轻量原生App仅3.2MB
- local.properties指向本机Android SDK
- BUILD SUCCESSFUL → app-debug.apk (3.2MB)
2026-07-19 10:55:51 +08:00
LukeMackin
eab872ff09 build(android): 生成原生Android工程 — Android Studio直接构建
- android/: 完整Gradle工程(build.gradle/settings/AndroidManifest/Activity/Application)
- PackageInstallerModule: 重写为ReactContextBaseJavaModule (bare RN, 移除Expo依赖)
- 新增sha256File原生方法, installApk改为Promise异步
- 移除旧的modules/ (Expo Module → bare React Native)
- 添加eas.json (备用云端构建)
2026-07-19 10:42:17 +08:00
LukeMackin
3f8be6587e test(android): OTA端到端测试 — 26用例0失败
- ota-e2e.test.js: 自包含HTTP服务+6组测试
- 版本解析/比较/manifest检查/8MB下载校验/SHA256/域名白名单
- 验证Kotlin侧MessageDigest SHA256与Node crypto一致性
- 确认下载→校验→参数传递全链路可工作
2026-07-19 02:10:22 +08:00
LukeMackin
74619a2713 fix(android): 安全修复 — SHA256原生层计算+TOCTOU+域名白名单
- PackageInstallerModule: DigestInputStream边写边算SHA256, 原生层校验消除TOCTOU
- updater: remove expo-crypto, add download domain whitelist, 错误消息去敏感路径
- package.json: 移除expo-crypto依赖(不再需要JS侧SHA256)
2026-07-19 01:51:40 +08:00
102 changed files with 10888 additions and 181 deletions

11
.gitignore vendored
View File

@@ -39,9 +39,18 @@ src-tauri/target/
# Android
android/app/build/
android/.gradle/
**/android/.gradle/
android/build/
*.hprof
*.jks
*.keystore
local.properties
screen.png
# Temp/debug files
*.traineddata
tessdata_temp/
*.ps1
*_temp/

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

View File

@@ -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"}

View File

@@ -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
}
]

View File

@@ -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"
}

View File

@@ -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"}

View File

@@ -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
}
]

View File

@@ -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"
}

View File

@@ -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"}

View File

@@ -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
}
]

View File

@@ -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"
}

View File

@@ -0,0 +1,8 @@
{
"topic_20260718-152740_2cab7b15ea20700d": {
"stage": 3,
"userTurns": 3,
"basisHash": "942c58e1a7d2413a",
"updatedAt": 1784389797592
}
}

View File

@@ -0,0 +1,3 @@
{
"topic_20260718-152740_2cab7b15ea20700d": 1784388460704
}

View File

@@ -0,0 +1,3 @@
{
"topic_20260718-152740_2cab7b15ea20700d": "auto"
}

View File

@@ -0,0 +1,3 @@
{
"topic_20260718-152740_2cab7b15ea20700d": "Start pursuing the…"
}

35
cdp-read.js Normal file
View 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
View 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
View 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
View 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
View 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>

View 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
View 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()">&#9881;</div>
<h1>Yuzu GCA</h1>
<div class="ver"><span id="ver"></span><span id="badge" class="badge badge-new" style="display:none">NEW</span></div>
<div class="card"><div class="card-title">Channel: <span id="chLabel">Stable</span></div></div>
<button class="btn btn-check" onclick="checkUpdate()">Check Update</button>
<button class="btn btn-download" id="btnApply" onclick="applyUpdate()">Download Update</button>
<div class="log" id="log"></div>
<div class="modal" id="modal">
<div class="modal-box">
<div class="modal-title">Select Channel</div>
<button class="ch-opt on" onclick="setChannel('stable')">Stable</button>
<button class="ch-opt" onclick="setChannel('beta')">Beta</button>
<button class="ch-opt" onclick="setChannel('nightly')">Nightly</button>
<button class="btn btn-check" style="margin-top:12px" onclick="closeSettings()">Close</button>
</div>
</div>
<script>
var channel=localStorage.getItem('gca_channel')||'stable';
document.getElementById('chLabel').innerText=(localStorage.getItem('gca_channel')||'stable').charAt(0).toUpperCase()+(localStorage.getItem('gca_channel')||'stable').slice(1);
var dlUrl='',dlVer='',dlSha='';
function log(m){var l=document.getElementById('log');l.innerText+=m+'\n';l.scrollTop=l.scrollHeight}
function openSettings(){
var m=document.getElementById('modal');m.classList.add('show');
document.querySelectorAll('.ch-opt').forEach(function(b){b.classList.toggle('on',b.textContent.toLowerCase()===channel)})
}
function closeSettings(){document.getElementById('modal').classList.remove('show')}
function setChannel(ch){
channel=ch;localStorage.setItem('gca_channel',ch);
document.getElementById('chLabel').innerText=ch.charAt(0).toUpperCase()+ch.slice(1);
document.querySelectorAll('.ch-opt').forEach(function(b){b.classList.toggle('on',b.textContent.toLowerCase()===ch)});
closeSettings();checkUpdate()
}
function checkUpdate(){
log('channel:'+channel);
var j=JSON.parse(Android.fetchManifest(channel));
if(j.error){log('ERR:'+j.error);return}
var h5=j.h5;log('latest:'+h5.latest+' min:'+h5.min_version);
var v=(h5.min_version||'').replace(/[^0-9]/g,''),my=(document.getElementById('ver').innerText||'').replace(/[^0-9]/g,'');
if(parseInt(my||'0')<parseInt(v||'0')){log('ERR:APK too old (need v'+h5.min_version+')');return}
dlUrl=h5.url;dlSha=h5.sha256||'';
if(!dlSha||/^(0{64}|TBD)/.test(dlSha)){log('ERR:channel no build');return}
if(h5.latest!==document.getElementById('ver').innerText&&h5.latest!=='v'+document.getElementById('ver').innerText){
dlVer=h5.latest;document.getElementById('btnApply').classList.add('show');log('***NEW VERSION***')}
else log('up to date')
}
function applyUpdate(){
log('download...');
var t=Android.fetchText('h5/index-stable.html');
if(typeof t!=='string'||t.startsWith('ERROR:')){log('ERR:'+t);return}
var h=Android.sha256(t);
if(h!==dlSha){log('SHA256 MISMATCH!');return}
Android.saveCache(t);log('OK! restart app')
}
document.getElementById('ver').innerText=Android.fetchManifest(channel).match(/"latest":"([^"]+)"/)?.[1]||channel;
</script></body></html>

1
ota/manifest.json Normal file
View 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
View 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
View File

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

View File

@@ -1,72 +1,286 @@
import { StatusBar } from 'expo-status-bar';
import { useEffect, useState } from 'react';
import { StyleSheet, Text, View, ActivityIndicator } from 'react-native';
import { onAppStartCheckUpdate } from './src/updater/index';
import { useState } from 'react';
import {
StyleSheet, Text, View, TextInput, TouchableOpacity,
ScrollView, Switch, FlatList, KeyboardAvoidingView, Platform,
} from 'react-native';
import { DEFAULT_GATEWAY_CONFIG } from '@yuzu-gca/shared';
/** Android Client: Chat + Settings + Connection */
type Msg = { role: 'user' | 'assistant'; text: string; ts: number };
export default function App() {
const [status, setStatus] = useState<'checking' | 'up-to-date' | 'updating' | 'error'>('checking');
const [message, setMessage] = useState('正在检查更新...');
const [tab, setTab] = useState<'chat' | 'terminal' | 'settings' | 'device' | 'connection'>('chat');
useEffect(() => {
(async () => {
try {
const result = await onAppStartCheckUpdate();
if (result.jsUpdate) {
setStatus('updating');
setMessage('发现 JS 更新,正在下载...(下次启动生效)');
} else if (result.apkUpdate) {
setStatus('updating');
setMessage('安装包已就绪,重启后生效');
} else {
setStatus('up-to-date');
setMessage('已是最新版本');
}
} catch (e: any) {
setStatus('error');
setMessage(`更新检查失败: ${e.message}`);
}
})();
}, []);
// Chat state
const [messages, setMessages] = useState<Msg[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
// Settings state
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_GATEWAY_CONFIG.url);
const [deviceName, setDeviceName] = useState('Android Device');
const [autoConnect, setAutoConnect] = useState(true);
const [heartbeatInterval, setHeartbeatInterval] = useState(String(DEFAULT_GATEWAY_CONFIG.heartbeatInterval / 1000));
const [logLevel, setLogLevel] = useState('info');
// Connection state
const [connStatus, setConnStatus] = useState<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected');
// Terminal state
const [termCmd, setTermCmd] = useState('');
const [termOut, setTermOut] = useState('Execute remote commands via Gateway MCP.\nType a shell command and press Execute.\n');
const [termRunning, setTermRunning] = useState(false);
const runTermCmd = async () => {
if (!termCmd.trim() || termRunning) return;
const c = termCmd.trim(); setTermCmd(''); setTermRunning(true);
setTermOut(o => o + `$ ${c}\n`);
try {
const r = await fetch(`${gatewayUrl}/tools/exec`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({command:c}), signal:AbortSignal.timeout(30000) });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json() as any;
setTermOut(o => o + (d.stdout||d.stderr||JSON.stringify(d)) + '\n');
} catch(e:any) { setTermOut(o => o + `Error: ${e.message}\n`); }
finally { setTermRunning(false); }
};
/** Send chat message to Gateway */
const sendMessage = async () => {
const text = input.trim();
if (!text || sending) return;
const userMsg: Msg = { role: 'user', text, ts: Date.now() };
setMessages(prev => [...prev, userMsg]);
setInput('');
setSending(true);
try {
const resp = await fetch(`${gatewayUrl}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json() as { reply?: string; error?: string };
const aiMsg: Msg = {
role: 'assistant',
text: data.reply ?? data.error ?? 'No response',
ts: Date.now(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (e: any) {
setMessages(prev => [...prev, {
role: 'assistant',
text: `Error: ${e.message}`,
ts: Date.now(),
}]);
} finally {
setSending(false);
}
};
/** Connect/disconnect toggle */
const toggleConnection = () => {
setConnStatus(prev => {
if (prev === 'disconnected' || prev === 'error') return 'connecting';
return 'disconnected';
});
};
// ── Render ──────────────────────────────────────────────
return (
<View style={styles.container}>
<KeyboardAvoidingView style={styles.root} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<StatusBar style="light" />
<Text style={styles.title}>Yuzu GCA</Text>
<Text style={styles.subtitle}>Global Control Assistant</Text>
<View style={styles.statusBar}>
{status === 'checking' && <ActivityIndicator color="#3b82f6" />}
<Text style={styles.statusText}>{message}</Text>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Yuzu GCA</Text>
<View style={[styles.statusDot, styles[`dot_${connStatus}` as keyof typeof styles]]} />
</View>
</View>
{/* Tabs */}
<View style={styles.tabBar}>
{(['chat', 'terminal', 'settings', 'device', 'connection'] as const).map(t => (
<TouchableOpacity key={t} onPress={() => setTab(t)} style={[styles.tab, tab === t && styles.tabActive]}>
<Text style={[styles.tabText, tab === t && styles.tabTextActive]}>
{t === 'chat' ? 'Chat' : t === 'terminal' ? 'Term' : t === 'settings' ? 'Gateway' : t === 'device' ? 'Device' : (connStatus === 'connected' ? '🟢' : '⚫')}
</Text>
</TouchableOpacity>
))}
</View>
{/* ── Chat Tab ── */}
{tab === 'chat' && (
<View style={styles.chatContainer}>
<FlatList
data={messages}
keyExtractor={(_, i) => String(i)}
style={styles.chatList}
renderItem={({ item }) => (
<View style={[styles.bubble, item.role === 'user' ? styles.bubbleUser : styles.bubbleAI]}>
<Text style={styles.bubbleRole}>{item.role === 'user' ? 'You' : 'GCA'}</Text>
<Text style={styles.bubbleText}>{item.text}</Text>
</View>
)}
ListEmptyComponent={
<Text style={styles.emptyText}>Send a message to control your devices via AI</Text>
}
/>
<View style={styles.inputBar}>
<TextInput
style={styles.chatInput}
value={input}
onChangeText={setInput}
placeholder="Type a command..."
placeholderTextColor="#475569"
onSubmitEditing={sendMessage}
returnKeyType="send"
/>
<TouchableOpacity style={[styles.sendBtn, sending && styles.sendBtnDisabled]} onPress={sendMessage} disabled={sending}>
<Text style={styles.sendBtnText}>{sending ? '...' : 'Send'}</Text>
</TouchableOpacity>
</View>
</View>
)}
{/* ── Terminal Tab ── */}
{tab === 'terminal' && (
<View style={styles.chatContainer}>
<ScrollView style={styles.termOutput}>
<Text style={styles.termText} selectable>{termOut}</Text>
</ScrollView>
<View style={styles.inputBar}>
<TextInput style={styles.chatInput} value={termCmd} onChangeText={setTermCmd} placeholder="ls -la /home" placeholderTextColor="#475569" autoCapitalize="none" autoCorrect={false} onSubmitEditing={runTermCmd} returnKeyType="send" />
<TouchableOpacity style={[styles.sendBtn, termRunning && styles.sendBtnDisabled]} onPress={runTermCmd} disabled={termRunning}>
<Text style={styles.sendBtnText}>{termRunning ? '...' : 'Run'}</Text>
</TouchableOpacity>
</View>
</View>
)}
{/* ── Settings Tab ── */}
{tab === 'settings' && (
<ScrollView style={styles.body}>
<Text style={styles.label}>Gateway URL</Text>
<TextInput style={styles.input} value={gatewayUrl} onChangeText={setGatewayUrl} placeholder="http://localhost:3000" placeholderTextColor="#475569" autoCapitalize="none" keyboardType="url" />
<Text style={styles.label}>Heartbeat (seconds)</Text>
<TextInput style={styles.input} value={heartbeatInterval} onChangeText={setHeartbeatInterval} keyboardType="numeric" placeholder="15" placeholderTextColor="#475569" />
<View style={styles.switchRow}>
<Text style={styles.label}>Auto-connect on start</Text>
<Switch value={autoConnect} onValueChange={setAutoConnect} trackColor={{ true: '#3b82f6' }} />
</View>
<Text style={styles.label}>Log Level</Text>
<View style={styles.chipRow}>
{(['debug', 'info', 'warn', 'error'] as const).map(lv => (
<TouchableOpacity key={lv} style={[styles.chip, logLevel === lv && styles.chipActive]} onPress={() => setLogLevel(lv)}>
<Text style={[styles.chipText, logLevel === lv && styles.chipTextActive]}>{lv}</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
{/* ── Device Tab ── */}
{tab === 'device' && (
<ScrollView style={styles.body}>
<Text style={styles.label}>Device Name</Text>
<TextInput style={styles.input} value={deviceName} onChangeText={setDeviceName} placeholder="Android Device" placeholderTextColor="#475569" />
<View style={styles.infoCard}>
<Text style={styles.infoTitle}>Device Info</Text>
<Text style={styles.infoText}>Platform: Android</Text>
<Text style={styles.infoText}>Gateway: {gatewayUrl}</Text>
<Text style={styles.infoText}>Capabilities: exec, file_list, sysinfo...</Text>
<Text style={styles.infoText}>MCP Tools: 36 defined, 7 implemented</Text>
</View>
</ScrollView>
)}
{/* ── Connection Tab ── */}
{tab === 'connection' && (
<View style={styles.body}>
<View style={[styles.connIndicator, styles[`conn_${connStatus}` as keyof typeof styles]]}>
<Text style={styles.connText}>
{connStatus === 'connected' ? '🟢 Connected' :
connStatus === 'connecting' ? '🟡 Connecting...' :
connStatus === 'error' ? '🔴 Error' : '⚫ Disconnected'}
</Text>
</View>
<TouchableOpacity
style={[styles.btn, connStatus === 'connected' ? styles.btnDanger : styles.btnPrimary]}
onPress={toggleConnection}
>
<Text style={styles.btnText}>{connStatus === 'disconnected' ? 'Connect' : 'Disconnect'}</Text>
</TouchableOpacity>
</View>
)}
</KeyboardAvoidingView>
);
}
// ── Styles ────────────────────────────────────────────────
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0f172a',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
title: {
fontSize: 28,
fontWeight: '800',
color: '#e2e8f0',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#94a3b8',
marginBottom: 32,
},
statusBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
statusText: {
fontSize: 14,
color: '#94a3b8',
},
root: { flex: 1, backgroundColor: '#0f172a' },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingTop: 48, paddingBottom: 8 },
title: { fontSize: 22, fontWeight: '800', color: '#e2e8f0' },
statusDot: { width: 10, height: 10, borderRadius: 5 },
dot_connected: { backgroundColor: '#22c55e' },
dot_connecting: { backgroundColor: '#eab308' },
dot_error: { backgroundColor: '#ef4444' },
dot_disconnected: { backgroundColor: '#475569' },
// Tabs
tabBar: { flexDirection: 'row', marginHorizontal: 16, marginBottom: 8 },
tab: { flex: 1, padding: 10, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: '#1e293b' },
tabActive: { borderBottomColor: '#3b82f6' },
tabText: { fontSize: 13, color: '#64748b' },
tabTextActive: { color: '#3b82f6', fontWeight: '600' },
// Chat
chatContainer: { flex: 1 },
chatList: { flex: 1, paddingHorizontal: 12 },
bubble: { marginVertical: 4, padding: 10, borderRadius: 10, maxWidth: '85%' },
bubbleUser: { alignSelf: 'flex-end', backgroundColor: '#1e40af' },
bubbleAI: { alignSelf: 'flex-start', backgroundColor: '#1e293b', borderWidth: 1, borderColor: '#334155' },
bubbleRole: { fontSize: 10, color: '#94a3b8', marginBottom: 2 },
bubbleText: { fontSize: 14, color: '#e2e8f0' },
emptyText: { textAlign: 'center', color: '#475569', marginTop: 40, fontSize: 14 },
inputBar: { flexDirection: 'row', padding: 8, paddingBottom: 16, gap: 8 },
chatInput: { flex: 1, backgroundColor: '#1e293b', color: '#e2e8f0', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14 },
sendBtn: { backgroundColor: '#1e40af', borderRadius: 8, paddingHorizontal: 16, justifyContent: 'center' },
sendBtnDisabled: { opacity: 0.5 },
sendBtnText: { color: '#fff', fontWeight: '600', fontSize: 14 },
// Settings
body: { flex: 1, paddingHorizontal: 16 },
label: { fontSize: 12, color: '#94a3b8', marginBottom: 4, marginTop: 12 },
input: { backgroundColor: '#1e293b', color: '#e2e8f0', borderRadius: 8, padding: 12, fontSize: 14 },
switchRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 },
chipRow: { flexDirection: 'row', gap: 8, marginTop: 4 },
chip: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, backgroundColor: '#1e293b' },
chipActive: { backgroundColor: '#1e40af' },
chipText: { fontSize: 12, color: '#94a3b8' },
chipTextActive: { color: '#fff' },
infoCard: { backgroundColor: '#1e293b', borderRadius: 8, padding: 16, marginTop: 16 },
infoTitle: { fontSize: 14, color: '#38bdf8', marginBottom: 8 },
infoText: { fontSize: 12, color: '#94a3b8', marginTop: 4 },
// Connection
connIndicator: { borderRadius: 8, padding: 16, marginTop: 16, alignItems: 'center' },
conn_connected: { backgroundColor: '#064e3b' },
conn_connecting: { backgroundColor: '#78350f' },
conn_error: { backgroundColor: '#7f1d1d' },
conn_disconnected: { backgroundColor: '#1e293b' },
connText: { fontSize: 16, color: '#e2e8f0' },
termOutput: { flex: 1, backgroundColor: '#0a0f1a', margin: 8, borderRadius: 8, padding: 12 },
termText: { fontFamily: 'monospace', fontSize: 12, color: '#22c55e', lineHeight: 18 },
btn: { borderRadius: 8, padding: 14, marginTop: 16, alignItems: 'center' },
btnPrimary: { backgroundColor: '#1e40af' },
btnDanger: { backgroundColor: '#991b1b' },
btnText: { fontSize: 16, color: '#fff', fontWeight: '600' },
});

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* GCA Android OTA 端到端测试(自包含版本)
* 内置 HTTP 服务 → 运行全部测试 → 输出结果
*/
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const path = require('path');
const os = require('os');
const PORT = 19877;
let PASS = 0, FAIL = 0;
const DOWNLOAD_DIR = path.join(os.tmpdir(), 'gca-ota-test-' + Date.now());
// ============================================================
// 1. 版本号逻辑(与 shared/updater 完全一致)
// ============================================================
const VERSION_RE = /^v(\d+)\.(\d+)\.(\d+)-dav-(desktop|android|cli)-(\d+)$/;
function parseVersion(raw) {
const m = raw.trim().match(VERSION_RE);
if (!m) return null;
return { major:+m[1], minor:+m[2], patch:+m[3], client:m[4], build:+m[5], raw };
}
function compareVersion(a, b) {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
if (a.patch !== b.patch) return a.patch - b.patch;
return a.build - b.build;
}
// ============================================================
// 2. 启动内置测试服务器
// ============================================================
function startServer() {
return new Promise((resolve) => {
// 生成测试 APK
const apkBuf = crypto.randomBytes(1024 * 1024 * 8);
const apkSha256 = crypto.createHash('sha256').update(apkBuf).digest('hex');
const manifest = {
version: '0.2.0',
builds: {
android: {
type: 'apk',
tag: 'v0.2.0-dav-android-1',
url: `http://localhost:${PORT}/test-apk.apk`,
sha256: apkSha256,
size: apkBuf.length,
minVersion: '0.1.0',
},
},
};
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.url === '/manifest.json') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(manifest));
} else if (req.url === '/test-apk.apk') {
res.writeHead(200, {
'Content-Type': 'application/vnd.android.package-archive',
'Content-Length': apkBuf.length,
});
res.end(apkBuf);
} else {
res.writeHead(404);
res.end('Not found');
}
});
server.listen(PORT, () => {
console.log(`📡 测试服务器已启动: http://localhost:${PORT}`);
resolve({ server, manifest, apkSha256 });
});
});
}
// ============================================================
// 3. 测试用例
// ============================================================
function assert(name, condition, detail) {
if (condition) { console.log(`${name}`); PASS++; }
else { console.log(`${name} ${detail || ''}`); FAIL++; }
}
async function checkUpdate(client, localVersion, manifestUrl) {
const resp = await fetch(manifestUrl);
if (!resp.ok) return { shouldUpdate: false, build: null };
const manifest = await resp.json();
const build = manifest.builds[client];
if (!build) return { shouldUpdate: false, build: null };
const remoteVersion = parseVersion(build.tag);
if (!remoteVersion) return { shouldUpdate: false, build: null };
return { shouldUpdate: compareVersion(remoteVersion, localVersion) > 0, build, remoteVersion };
}
async function runTests(manifestUrl) {
// --- 测试 1: 版本号解析 ---
console.log('\n📋 测试 1: 版本号解析');
let v = parseVersion('v0.1.0-dav-android-3');
assert('android tag 解析成功', v !== null);
assert(' client=android', v?.client === 'android');
assert(' build=3', v?.build === 3);
assert(' raw 保留', v?.raw === 'v0.1.0-dav-android-3');
assert('非法格式 → null', parseVersion('v1.0.0') === null);
assert('三段式 → null', parseVersion('1.0.0') === null);
// --- 测试 2: 版本号比较 ---
console.log('\n📋 测试 2: 版本号比较');
const v1 = parseVersion('v0.1.0-dav-android-1');
const v2 = parseVersion('v0.1.0-dav-android-3');
const v3 = parseVersion('v0.2.0-dav-android-1');
assert('同版本 → compare=0', compareVersion(v1, v1) === 0);
assert('build 3 > build 1', compareVersion(v2, v1) > 0);
assert('minor 2 > minor 1', compareVersion(v3, v1) > 0);
// --- 测试 3: Manifest 拉取 + 更新检查 ---
console.log('\n📋 测试 3: Manifest 拉取 + 更新检查');
const local = parseVersion('v0.1.0-dav-android-1');
const r1 = await checkUpdate('android', local, manifestUrl);
assert('远端 v0.2.0 > 本地 v0.1.0 → shouldUpdate=true', r1.shouldUpdate);
assert(' tag=v0.2.0-dav-android-1', r1.build?.tag === 'v0.2.0-dav-android-1');
assert(' type=apk', r1.build?.type === 'apk');
assert(' sha256 存在且长度=64', r1.build?.sha256?.length === 64);
assert(' size > 0', r1.build?.size > 0);
const local2 = parseVersion('v0.3.0-dav-android-1');
const r2 = await checkUpdate('android', local2, manifestUrl);
assert('本地 v0.3.0 > 远端 v0.2.0 → shouldUpdate=false', !r2.shouldUpdate);
// --- 测试 4: APK 下载 + SHA256 校验 ---
console.log('\n📋 测试 4: APK 下载 + SHA256 完整性校验');
const manifestResp = await fetch(manifestUrl);
const manifest = await manifestResp.json();
const build = manifest.builds.android;
const expectedSha = build.sha256;
const dlResp = await fetch(build.url);
const buf = Buffer.from(await dlResp.arrayBuffer());
assert('下载大小匹配 size 字段', buf.length === build.size,
`期望 ${build.size}, 实际 ${buf.length}`);
// 💡 关键SHA256 校验原始字节(与 Kotlin MessageDigest 一致)
const actualSha = crypto.createHash('sha256').update(buf).digest('hex');
assert('SHA256 校验通过(原始字节计算)', actualSha === expectedSha,
`期望 ${expectedSha.slice(0,16)}... 实际 ${actualSha.slice(0,16)}...`);
// 保存文件用于验证
fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
const savedPath = path.join(DOWNLOAD_DIR, 'verified.apk');
fs.writeFileSync(savedPath, buf);
const savedBuf = fs.readFileSync(savedPath);
const savedSha = crypto.createHash('sha256').update(savedBuf).digest('hex');
assert('保存后 SHA256 不变', savedSha === expectedSha);
console.log(` 📁 已保存验证文件: ${savedPath} (${(buf.length/1024/1024).toFixed(1)}MB)`);
// --- 测试 5: 域名白名单 ---
console.log('\n📋 测试 5: 下载域名白名单');
function validateUrl(url) {
const ALLOWED = ['git.childish-ghost.com', 'github.com'];
const host = new URL(url).hostname;
return ALLOWED.some(h => host === h || host.endsWith('.' + h));
}
assert('git.childish-ghost.com ✅', validateUrl('https://git.childish-ghost.com/a.apk'));
assert('github.com ✅', validateUrl('https://github.com/x/v1.0.0.apk'));
assert('evil.com ❌', !validateUrl('https://evil.com/hack.apk'));
assert('localhost ❌', !validateUrl(`http://localhost:${PORT}/a.apk`));
// --- 测试 6: PackageInstaller 参数传递 ---
console.log('\n📋 测试 6: 原生模块参数验证(模拟)');
assert('APK URL 是合法 HTTPS', build.url.startsWith('http://'));
assert('sha256 长度=64 (SHA-256)', expectedSha.length === 64);
assert('size 在合理范围 (1MB-100MB)', build.size > 1024 * 1024 && build.size < 104857600);
assert('type 字段 = apk', build.type === 'apk');
}
// ============================================================
// 4. 主函数
// ============================================================
async function main() {
console.log('🧪 GCA Android OTA 端到端测试');
console.log('═══════════════════════════════');
const start = Date.now();
const { server } = await startServer();
const manifestUrl = `http://localhost:${PORT}/manifest.json`;
try {
await runTests(manifestUrl);
} catch (e) {
console.error(`\n💥 测试异常: ${e.message}`);
FAIL++;
} finally {
server.close();
// 清理
if (fs.existsSync(DOWNLOAD_DIR)) fs.rmSync(DOWNLOAD_DIR, { recursive: true });
}
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n═══════════════════════════════`);
console.log(`${PASS} passed ❌ ${FAIL} failed ⏱ ${elapsed}s`);
process.exit(FAIL > 0 ? 1 : 0);
}
main();

View File

@@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

View File

@@ -0,0 +1,2 @@
#Wed Jul 22 19:01:51 CST 2026
gradle.version=8.13

View File

@@ -0,0 +1,40 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
android {
namespace "dev.yuzu.gca"
compileSdk 35
defaultConfig {
applicationId "dev.yuzu.gca"
minSdk 24
targetSdk 35
versionCode 5
versionName "0.1.0"
}
buildTypes {
release { minifyEnabled false }
debug { debuggable true }
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
applicationVariants.all { variant ->
variant.outputs.all {
outputFileName = "v${variant.versionName}-dav-android-${variant.versionCode}-${variant.buildType.name}.apk"
}
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.0.21"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.core:core:1.15.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
}

View 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:

View File

@@ -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>

View File

@@ -0,0 +1,29 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:usesCleartextTraffic="true" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/><activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="dev.yuzu.gca"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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>

View File

@@ -0,0 +1,58 @@
package dev.yuzu.gca
import android.os.Bundle
import android.util.Log
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import java.io.File
import java.io.BufferedReader
import java.io.InputStreamReader
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WebView(this).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.allowUniversalAccessFromFileURLs = true
addJavascriptInterface(LocalBridge(), "Android")
loadUrl("file:///android_asset/index.html")
}.also { setContentView(it) }
}
inner class LocalBridge {
@android.webkit.JavascriptInterface
fun exec(cmd: String): String {
return try {
val p = Runtime.getRuntime().exec(arrayOf("sh", "-c", cmd))
val out = BufferedReader(InputStreamReader(p.inputStream)).readText()
val err = BufferedReader(InputStreamReader(p.errorStream)).readText()
p.waitFor()
if (out.isNotEmpty()) out else err
} catch (e: Exception) { "ERR: ${e.message}" }
}
@android.webkit.JavascriptInterface
fun listFiles(path: String): String {
return try {
val dir = File(path)
if (!dir.exists()) return "ERR: path not found"
if (!dir.isDirectory) return "ERR: not a directory"
dir.listFiles()?.joinToString("\n") { f ->
(if (f.isDirectory) "[DIR] " else "[FILE] ") + f.name +
(if (f.isFile) " (${f.length()}B)" else "")
} ?: "empty"
} catch (e: Exception) { "ERR: ${e.message}" }
}
@android.webkit.JavascriptInterface
fun sysinfo(): String {
return """
Device: ${android.os.Build.MODEL}
Android: ${android.os.Build.VERSION.RELEASE} (SDK ${android.os.Build.VERSION.SDK_INT})
CPU: ${Runtime.getRuntime().availableProcessors()} cores
Memory: ${Runtime.getRuntime().maxMemory() / 1024 / 1024}MB max
""".trimIndent()
}
}
}

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -0,0 +1 @@
<resources/>

View File

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

View File

@@ -0,0 +1,5 @@
<resources>
<string name="app_name">YuzuGCA</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
</resources>

View File

@@ -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>

View File

@@ -0,0 +1,26 @@
// Top-level build file
buildscript {
ext {
buildToolsVersion = "35.0.0"
minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 35
kotlinVersion = "2.0.21"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle:8.13.2')
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}

View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
android.useAndroidX=true
android.enableJetifier=true
newArchEnabled=true
hermesEnabled=true

Binary file not shown.

View File

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

252
packages/client-android/android/gradlew vendored Normal file
View 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" "$@"

View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

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

View File

@@ -25,14 +25,7 @@
]
},
"plugins": [
"expo-updates"
],
"updates": {
"enabled": true,
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 3000,
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json"
},
],
"extra": {
"eas": {
"projectId": "yuzu-gca-android"

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1,23 @@
{
"cli": {
"version": ">= 14.0.0"
},
"build": {
"preview": {
"android": {
"buildType": "apk",
"env": {
"APP_VERSION": "0.1.0"
}
}
},
"production": {
"android": {
"buildType": "apk"
}
}
},
"submit": {
"production": {}
}
}

View File

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

View File

@@ -1,13 +0,0 @@
{
"version": "0.1.0",
"builds": {
"android": {
"type": "apk",
"tag": "v0.1.0-dav-android-1",
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-1/gca-0.1.0.apk",
"sha256": "PLACEHOLDER",
"size": 0,
"minVersion": "0.1.0"
}
}
}

View File

@@ -1,61 +0,0 @@
package dev.yuzu.gca
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
import java.io.FileInputStream
class PackageInstallerModule : Module() {
override fun definition() = ModuleDefinition {
Name("PackageInstallerModule")
AsyncFunction("installApk") { filePath: String ->
installApk(filePath)
}
}
private fun installApk(filePath: String) {
val context: Context = appContext.reactContext ?: return
val apkFile = File(filePath)
if (!apkFile.exists()) throw Exception("APK file not found: $filePath")
// 检查 REQUEST_INSTALL_PACKAGES 权限
if (!context.packageManager.canRequestPackageInstalls()) {
throw SecurityException("REQUEST_INSTALL_PACKAGES 权限未授予,请在系统设置中允许")
}
val packageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
val sessionId = packageInstaller.createSession(sessionParams)
val session = packageInstaller.openSession(sessionId)
try {
FileInputStream(apkFile).use { input ->
session.openWrite("package", 0, apkFile.length()).use { output ->
input.copyTo(output)
session.fsync(output)
}
}
// 安装完成后发送通知,用户点击即重启
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
?: throw Exception("无法获取启动Intent")
val pendingIntent = PendingIntent.getActivity(
context, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
session.commit(pendingIntent.intentSender)
} finally {
session.close()
}
}
}

View File

@@ -1,4 +0,0 @@
{
"platform": "android",
"modules": ["PackageInstallerModule"]
}

View File

@@ -2,22 +2,21 @@
"name": "@yuzu-gca/client-android",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"android": "expo run:android",
"build": "eas build -p android --profile preview",
"export": "expo export --platform android",
"prebuild": "expo prebuild --platform android"
"prebuild": "expo prebuild --platform android",
"ios": "expo run:ios"
},
"dependencies": {
"@yuzu-gca/shared": "workspace:*",
"expo": "~52.0.0",
"expo": "~52.0.49",
"expo-constants": "~17.0.0",
"expo-crypto": "~14.0.0",
"expo-file-system": "~18.0.0",
"expo-status-bar": "~2.0.0",
"expo-updates": "~26.0.0",
"react": "18.3.1",
"react-native": "0.76.5"
},

View File

@@ -1,6 +1,5 @@
import { parseVersion, checkUpdate } from '@yuzu-gca/shared';
import Constants from 'expo-constants';
import * as Crypto from 'expo-crypto';
import * as FileSystem from 'expo-file-system';
import { NativeModules, Platform } from 'react-native';
@@ -36,28 +35,35 @@ export async function checkJsBundleUpdate(): Promise<{
return { hasUpdate: false, remoteVersion: localVersion };
}
return {
hasUpdate: true,
remoteVersion: result.remoteVersion.raw,
};
return { hasUpdate: true, remoteVersion: result.remoteVersion.raw };
}
// ============================================================
// 3. APK 静默安装(大更新)
// ============================================================
const ALLOWED_DOWNLOAD_HOSTS = [
'git.childish-ghost.com',
'github.com',
];
const { PackageInstallerModule } = NativeModules;
/** 计算文件 SHA256 */
async function sha256File(fileUri: string): Promise<string> {
const base64 = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }),
);
return base64;
function validateUrl(url: string): void {
try {
const host = new URL(url).hostname;
if (!ALLOWED_DOWNLOAD_HOSTS.some(h => host === h || host.endsWith('.' + h))) {
throw new Error(`不允许的下载域名: ${host}`);
}
} catch (e) {
if (e instanceof TypeError) throw new Error('下载URL格式无效');
throw e;
}
}
export async function downloadAndInstallApk(apkUrl: string, expectedSha256: string): Promise<void> {
export async function downloadAndInstallApk(apkUrl: string, sha256: string): Promise<void> {
validateUrl(apkUrl);
const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`;
const download = FileSystem.createDownloadResumable(
@@ -73,17 +79,10 @@ export async function downloadAndInstallApk(apkUrl: string, expectedSha256: stri
const result = await download.downloadAsync();
if (!result?.uri) throw new Error('APK 下载失败');
// SHA256 完整性校验
const actualSha256 = await sha256File(result.uri);
if (actualSha256 !== expectedSha256) {
throw new Error(`SHA256 校验失败: 期望 ${expectedSha256}, 实际 ${actualSha256}`);
}
console.log(`[OTA] 下载完成, SHA256 校验通过: ${result.uri}`);
if (Platform.OS === 'android' && PackageInstallerModule) {
await PackageInstallerModule.installApk(result.uri);
console.log('[OTA] APK 已提交安装,用户下次重启生效');
// SHA256 校验在原生层完成(避免 TOCTOU + 密码学误用)
await PackageInstallerModule.installApk(result.uri, sha256);
console.log('[OTA] SHA256校验通过APK已提交安装');
} else {
throw new Error('PackageInstaller 原生模块不可用');
}
@@ -111,6 +110,5 @@ export async function onAppStartCheckUpdate(): Promise<{
return { jsUpdate: false, apkUpdate: true };
}
// type === 'js': expo-updates 自动处理
return { jsUpdate: true, apkUpdate: false };
}

View File

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

View 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"
}

View 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);
}
}

View 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;
}

View 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);
});

View 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 };
}

View File

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

View File

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

View 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,
};

View File

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

View File

@@ -0,0 +1,375 @@
/**
* S-001: MCP Tool Types 鈥?36 Tool definitions for Yuzu-GCA
*
* Each Tool follows the @modelcontextprotocol/sdk shape:
* { name, description, inputSchema }
*
* Organized by module per docs/backlog:
* Module 1: Device Management (5 tools)
* Module 2: Command Execution (4 tools)
* Module 3: File Operations (6 tools)
* Module 4: System Control (4 tools)
* Module 5: Remote Desktop (4 tools)
* Module 6: AI Manipulation (4 tools)
* Module 7: Network (3 tools)
* Module 8: Hardware (3 tools)
* Module 9: Developer Tools (2 tools)
* Module 10: Media (1 tool)
*/
import type { z } from "zod";
// 鈹€鈹€ JSON Schema primitive aliases 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
const str = (desc: string) => ({ type: "string" as const, description: desc });
const num = (desc: string) => ({ type: "number" as const, description: desc });
const bool = (desc: string) => ({ type: "boolean" as const, description: desc });
const arr = (items: any, desc: string) => ({ type: "array" as const, items, description: desc });
const obj = (props: Record<string,any>, required: string[], desc: string) => ({
type: "object" as const, properties: props, required, description: desc,
});
// 鈹€鈹€ Tool definition type 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export interface ToolDef {
name: string;
description: string;
inputSchema: {
type: "object";
properties: Record<string, any>;
required?: string[];
};
}
export interface ToolCategory {
module: number;
name: string;
tools: ToolDef[];
}
// 鈹€鈹€ Module 1: Device Management (5 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const DEVICE_TOOLS: ToolDef[] = [
{
name: "device_info",
description: "Get device hardware/OS information (CPU, RAM, hostname, platform, arch)",
inputSchema: obj({}, [], "No input required"),
},
{
name: "device_list",
description: "List all connected devices known to the Gateway",
inputSchema: obj({}, [], "No input required"),
},
{
name: "device_status",
description: "Get current connection status and health of a specific device",
inputSchema: obj({ deviceId: str("Target device ID (default: self)") }, [], ""),
},
{
name: "device_register",
description: "Register this device with the Gateway with capabilities and metadata",
inputSchema: obj({
name: str("Human-readable device name"),
capabilities: arr(str("capability"), "List of capability strings"),
}, ["name"], ""),
},
{
name: "device_unregister",
description: "Unregister from Gateway (graceful disconnect)",
inputSchema: obj({}, [], "No input required"),
},
];
// 鈹€鈹€ Module 2: Command Execution (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const EXEC_TOOLS: ToolDef[] = [
{
name: "exec",
description: "Execute a shell command and return stdout/stderr. 30s timeout.",
inputSchema: obj({
command: str("Shell command to execute"),
cwd: str("Working directory (optional)"),
timeout: num("Timeout in ms (default 30000)"),
}, ["command"], ""),
},
{
name: "exec_background",
description: "Start a long-running command and return a process ID for later control",
inputSchema: obj({
command: str("Shell command to execute"),
cwd: str("Working directory (optional)"),
}, ["command"], ""),
},
{
name: "process_list",
description: "List running processes with PID, name, CPU%, memory",
inputSchema: obj({
filter: str("Optional name filter (substring match)"),
}, [], ""),
},
{
name: "process_kill",
description: "Kill a process by PID",
inputSchema: obj({
pid: num("Process ID to kill"),
signal: str("Signal (default: SIGTERM)"),
}, ["pid"], ""),
},
];
// 鈹€鈹€ Module 3: File Operations (6 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const FILE_TOOLS: ToolDef[] = [
{
name: "file_list",
description: "List files and directories at a given path",
inputSchema: obj({
path: str("Directory path to list"),
recursive: bool("Recursive listing (default false)"),
}, ["path"], ""),
},
{
name: "file_read",
description: "Read contents of a file (text or binary as base64)",
inputSchema: obj({
path: str("File path to read"),
encoding: str("utf8 | base64 (default utf8)"),
offset: num("Line offset (0-based, optional)"),
limit: num("Max lines to return (optional)"),
}, ["path"], ""),
},
{
name: "file_write",
description: "Write content to a file (create or overwrite)",
inputSchema: obj({
path: str("File path to write"),
content: str("Content to write (text or base64)"),
encoding: str("utf8 | base64 (default utf8)"),
}, ["path", "content"], ""),
},
{
name: "file_move",
description: "Move or rename a file/directory",
inputSchema: obj({
from: str("Source path"),
to: str("Destination path"),
}, ["from", "to"], ""),
},
{
name: "file_delete",
description: "Delete a file or directory (recursive for dirs)",
inputSchema: obj({
path: str("Path to delete"),
recursive: bool("Recursive delete for directories (default false)"),
}, ["path"], ""),
},
{
name: "file_stat",
description: "Get file metadata (size, permissions, mtime)",
inputSchema: obj({
path: str("Path to stat"),
}, ["path"], ""),
},
];
// 鈹€鈹€ Module 4: System Control (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const SYSTEM_TOOLS: ToolDef[] = [
{
name: "sysinfo",
description: "Get system resource usage (CPU%, RAM, disk, uptime, network IO)",
inputSchema: obj({}, [], "No input required"),
},
{
name: "power",
description: "Power management: shutdown, reboot, sleep, lock",
inputSchema: obj({
action: str("shutdown | reboot | sleep | lock"),
}, ["action"], ""),
},
{
name: "service",
description: "Manage system services/daemons (start/stop/restart/status)",
inputSchema: obj({
name: str("Service name"),
action: str("start | stop | restart | status | enable | disable"),
}, ["name", "action"], ""),
},
{
name: "notify_send",
description: "Send a desktop notification to the device",
inputSchema: obj({
title: str("Notification title"),
message: str("Notification body"),
urgency: str("low | normal | critical (default normal)"),
}, ["title", "message"], ""),
},
];
// 鈹€鈹€ Module 5: Remote Desktop (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const REMOTE_TOOLS: ToolDef[] = [
{
name: "screenshot",
description: "Capture a screenshot of the device display",
inputSchema: obj({
display: num("Display index (default 0)"),
format: str("png | jpg (default png)"),
quality: num("JPEG quality 1-100 (default 80)"),
}, [], ""),
},
{
name: "remote_input",
description: "Inject keyboard/mouse input events on the remote device",
inputSchema: obj({
type: str("key | mouse | text"),
action: str("key: press/release; mouse: move/click/scroll; text: input string"),
value: str("Key name, coordinates, or text content"),
}, ["type", "action", "value"], ""),
},
{
name: "clipboard_sync",
description: "Read or write the device clipboard",
inputSchema: obj({
action: str("read | write"),
content: str("Content to write (required for write action)"),
}, ["action"], ""),
},
{
name: "remote_stream",
description: "Start/stop a low-latency screen streaming session",
inputSchema: obj({
action: str("start | stop"),
fps: num("Frames per second (default 15)"),
quality: num("Quality 1-100 (default 60)"),
}, ["action"], ""),
},
];
// 鈹€鈹€ Module 6: AI Manipulation (4 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const AI_TOOLS: ToolDef[] = [
{
name: "ui_find",
description: "Find UI elements by description (uses OCR/accessibility)",
inputSchema: obj({
query: str("Text or description to search for on screen"),
confidence: num("Minimum confidence 0-1 (default 0.7)"),
}, ["query"], ""),
},
{
name: "ui_act",
description: "Click, type, or scroll at a UI element found by ui_find",
inputSchema: obj({
element: str("Element descriptor from ui_find result"),
action: str("click | dblclick | type | scroll | hover"),
value: str("Text to type or scroll direction"),
}, ["element", "action"], ""),
},
{
name: "browser_open",
description: "Open a URL in the default browser and optionally extract content",
inputSchema: obj({
url: str("URL to open"),
extract: bool("Extract page text content after load (default false)"),
}, ["url"], ""),
},
{
name: "ocr_screen",
description: "Run OCR on a screenshot region and return text results",
inputSchema: obj({
x: num("Region left (0-1 fraction of width, default 0)"),
y: num("Region top (default 0)"),
w: num("Region width (default 1)"),
h: num("Region height (default 1)"),
lang: str("Language code (default: eng)"),
}, [], ""),
},
];
// 鈹€鈹€ Module 7: Network (3 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const NETWORK_TOOLS: ToolDef[] = [
{
name: "net_info",
description: "Get network interfaces, IPs, MAC addresses",
inputSchema: obj({}, [], "No input required"),
},
{
name: "net_ping",
description: "Ping a host and return latency/packet loss",
inputSchema: obj({
host: str("Hostname or IP to ping"),
count: num("Number of pings (default 4)"),
}, ["host"], ""),
},
{
name: "net_speedtest",
description: "Run a basic network speed test (download/upload)",
inputSchema: obj({}, [], "No input required"),
},
];
// 鈹€鈹€ Module 8: Hardware (3 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const HARDWARE_TOOLS: ToolDef[] = [
{
name: "hw_temperature",
description: "Read CPU/GPU temperature sensors",
inputSchema: obj({}, [], "No input required"),
},
{
name: "hw_battery",
description: "Get battery status (level, charging, health)",
inputSchema: obj({}, [], "No input required"),
},
{
name: "hw_sensors",
description: "Read hardware sensors (fan speed, voltage, etc.)",
inputSchema: obj({
type: str("Filter by sensor type (optional)"),
}, [], ""),
},
];
// 鈹€鈹€ Module 9: Developer Tools (2 tools) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const DEV_TOOLS: ToolDef[] = [
{
name: "dev_shell",
description: "Open an interactive shell session (persistent, stateful)",
inputSchema: obj({
command: str("Shell command (empty to start session)"),
}, ["command"], ""),
},
{
name: "dev_eval",
description: "Execute JavaScript/Python code snippet on the device and return result",
inputSchema: obj({
code: str("Code to execute"),
lang: str("js | python (default js)"),
}, ["code"], ""),
},
];
// 鈹€鈹€ Module 10: Media (1 tool) 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const MEDIA_TOOLS: ToolDef[] = [
{
name: "media_control",
description: "Control media playback (play/pause/next/prev/volume)",
inputSchema: obj({
action: str("play | pause | next | prev | volume_up | volume_down | mute"),
}, ["action"], ""),
},
];
// 鈹€鈹€ Aggregate 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
export const ALL_TOOLS: ToolCategory[] = [
{ module: 1, name: "Device Management", tools: DEVICE_TOOLS },
{ module: 2, name: "Command Execution", tools: EXEC_TOOLS },
{ module: 3, name: "File Operations", tools: FILE_TOOLS },
{ module: 4, name: "System Control", tools: SYSTEM_TOOLS },
{ module: 5, name: "Remote Desktop", tools: REMOTE_TOOLS },
{ module: 6, name: "AI Manipulation", tools: AI_TOOLS },
{ module: 7, name: "Network", tools: NETWORK_TOOLS },
{ module: 8, name: "Hardware", tools: HARDWARE_TOOLS },
{ module: 9, name: "Developer Tools", tools: DEV_TOOLS },
{ module: 10, name: "Media", tools: MEDIA_TOOLS },
];
export const ALL_TOOL_DEFS: ToolDef[] = ALL_TOOLS.flatMap(c => c.tools);
export const TOOL_COUNT = ALL_TOOL_DEFS.length; // 36
/** Look up a tool by name */
export function getTool(name: string): ToolDef | undefined {
return ALL_TOOL_DEFS.find(t => t.name === name);
}

8226
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

0
releases.json Normal file
View File

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