118 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
96 changed files with 2628 additions and 679 deletions

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

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

View File

@@ -1,18 +1,44 @@
# OTA 文件夹
# OTA 热更新目录
此目录存放 manifest 文件。manifest-stable.json 是 CI 构建时动态生成的产物,
也提交到仓库通过 raw URL 供客户端拉取。
## 文件说明
## 文件
| 文件 | 用途 |
|------|------|
| `version.txt` | v5 嵌入式初始化——格式: `版本\|URL\|SHA256` |
| `h5-manifest-stable.json` | 发行渠道stable——格式: `{latest, min_version, url, sha256}` |
| `h5-manifest-beta.json` | 测试渠道beta |
| `h5-manifest-nightly.json` | 开发渠道nightly |
| `h5/index-vN.html` | 可下载的热更新页面 |
- `gen-manifest.sh` — 构建后运行,计算 APK SHA256 并生成 manifest-stable.json
- `manifest-stable.json` — stable 渠道
- `manifest-beta.json` — beta 渠道
- `manifest-nightly.json` — nightly 渠道
## 推送补丁
## 流程
```bash
# 更新所有渠道到 v7
cd ota
bash push-hotfix.sh 7 all
# 只更新 nightly 渠道
bash push-hotfix.sh 7 nightly
# 手动推送
git add ota/
git commit -m "hotfix: v7"
git push
```
CI 构建 APK → ./ota/gen-manifest.sh → git push manifest-stable.json
手机 App → raw URL 拉取 manifest → 比较版本 → 下载 APK → SHA256 校验
## 格式
**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...
```

View File

@@ -1,12 +0,0 @@
{
"id": "e5ce52a-0000-0000-0000-000000000000",
"createdAt": "2026-07-19T15:30:00.000Z",
"runtimeVersion": "0.1.0",
"launchAsset": {
"key": "bundle",
"contentType": "application/javascript",
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-8/bundle.js"
},
"assets": [],
"metadata": {}
}

View File

@@ -1,38 +1,54 @@
#!/bin/bash
# OTA manifest 生成脚本
# 用法: ./ota/gen-manifest.sh <versionName> <versionCode> <apk-path>
# 构建号规则: 每次生成APK时 versionCode +1, 对应 git tag vX.Y.Z-dav-android-N
# CI 构建时自动运行, SHA256动态填入, 生成 manifest-stable.json 后上传到 Gitea Release
# gen-manifest.sh — 生成 APK 发行版 manifest
# 在 Android Studio 构建 Release APK 后运行
# 用法: bash gen-manifest.sh 6 stable
# bash gen-manifest.sh 6 all
VERSION_NAME="${1:-0.1.0}"
VERSION_CODE="${2:-7}"
APK_PATH="${3:-packages/client-android/android/app/build/outputs/apk/debug/v0.1.0-dav-android-7-debug.apk}"
RELEASE_TAG="v${VERSION_NAME}-dav-android-${VERSION_CODE}"
APK_NAME="v${VERSION_NAME}-dav-android-${VERSION_CODE}-debug.apk"
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)
SIZE=$(stat -c%s "$APK_PATH")
echo "APK : $APK_PATH"
echo "SHA256: $SHA256"
cat > ota/manifest-stable.json <<JSON
{
"version": "${VERSION_NAME}",
"builds": {
"android": {
"type": "apk",
"tag": "${RELEASE_TAG}",
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/${RELEASE_TAG}/${APK_NAME}",
"sha256": "${SHA256}",
"size": ${SIZE},
"channel": "stable",
"minVersion": "${VERSION_NAME}"
}
}
}
JSON
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 "Generated: ota/manifest-stable.json"
echo " tag: ${RELEASE_TAG}"
echo " sha256: ${SHA256}"
echo " size: ${SIZE}"
echo ""
echo "Upload: curl -X POST 'https://git.childish-ghost.com/api/v1/repos/LukeMackin/Yuzu-GCA/releases/\${RELEASE_ID}/assets?name=manifest-stable.json' -H 'Authorization: token \$TOKEN' -H 'Content-Type: application/json' --data-binary @ota/manifest-stable.json"
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>

View File

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

View File

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

View File

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

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

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

View File

@@ -2,14 +2,14 @@ apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
android {
namespace 'dev.yuzu.gca'
namespace "dev.yuzu.gca"
compileSdk 35
defaultConfig {
applicationId 'dev.yuzu.gca'
applicationId "dev.yuzu.gca"
minSdk 24
targetSdk 35
versionCode 8
versionCode 5
versionName "0.1.0"
}

View File

@@ -2,14 +2,18 @@
<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"/>
<application android:name=".MainApplication" android:label="Yuzu GCA v0.1.0" android:allowBackup="false" android:supportsRtl="true" android:theme="@style/Theme.AppCompat.DayNight">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="3000"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/expo-manifest.json"/>
<activity android:name=".MainActivity" android:label="Yuzu GCA v0.1.0" android:launchMode="singleTask" android:exported="true" android:screenOrientation="portrait" android:windowSoftInputMode="adjustResize">
<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"/>
@@ -21,8 +25,5 @@
<data android:scheme="dev.yuzu.gca"/>
</intent-filter>
</activity>
<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths"/>
</provider>
</application>
</manifest>

View File

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

@@ -1,255 +1,58 @@
package dev.yuzu.gca
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import android.util.Log
import androidx.core.content.FileProvider
import org.json.JSONObject
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import java.io.File
import java.io.BufferedReader
import java.io.InputStreamReader
class MainActivity : AppCompatActivity() {
private lateinit var logView: TextView
private var selectedChannel = "stable"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val scroll = ScrollView(this)
val layout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(24, 24, 24, 24)
}
val title = TextView(this).apply {
text = "Yuzu GCA"
textSize = 20f
setPadding(0, 0, 0, 4)
}
layout.addView(title)
val versionInfo = TextView(this).apply {
text = "v0.1.0-dav-android-8-debug"
textSize = 12f
setTextColor(Color.GRAY)
setPadding(0, 0, 0, 12)
}
layout.addView(versionInfo)
val channelLabel = TextView(this).apply {
text = "选择更新渠道:"
textSize = 13f
setPadding(0, 8, 0, 4)
}
layout.addView(channelLabel)
// 渠道按钮行
val channelRow = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, 0, 0, 12)
}
fun makeChannelBtn(label: String, channel: String): Button {
return Button(this).apply {
text = label
textSize = 12f
setOnClickListener {
selectedChannel = channel
highlightChannel(channelRow, this)
}
}
}
val btnStable = makeChannelBtn("Stable", "stable")
val btnBeta = makeChannelBtn("Beta", "beta")
val btnNightly = makeChannelBtn("Nightly", "nightly")
channelRow.addView(btnStable)
channelRow.addView(btnBeta)
channelRow.addView(btnNightly)
layout.addView(channelRow)
highlightChannel(channelRow, btnStable)
logView = TextView(this).apply {
textSize = 12f
setTextColor(Color.LTGRAY)
}
layout.addView(logView)
val btn = Button(this).apply {
text = "检查更新"
setOnClickListener {
val ch = selectedChannel
CoroutineScope(Dispatchers.IO).launch { doOtaTest(ch) }
}
}
layout.addView(btn)
scroll.addView(layout)
setContentView(scroll)
appendLog("渠道: stable | 点击[检查更新]开始")
WebView(this).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.allowUniversalAccessFromFileURLs = true
addJavascriptInterface(LocalBridge(), "Android")
loadUrl("file:///android_asset/index.html")
}.also { setContentView(it) }
}
private fun highlightChannel(row: LinearLayout, active: Button) {
for (i in 0 until row.childCount) {
val b = row.getChildAt(i) as Button
b.isSelected = (b == active)
}
}
private fun appendLog(msg: String) {
Log.i(TAG, msg)
runOnUiThread { logView.append("$msg\n") }
}
companion object { private const val TAG = "GCA-OTA" }
private fun manifestUrl(channel: String): String {
val base = "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota"
return when (channel) {
"beta" -> "$base/manifest-beta.json"
"nightly" -> "$base/manifest-nightly.json"
else -> "$base/manifest-stable.json"
}
}
private suspend fun doOtaTest(channel: String) = withContext(Dispatchers.IO) {
val url = manifestUrl(channel)
appendLog("=== OTA 渠道: $channel ===")
appendLog("manifest: $url")
// 1. 拉取 manifest
appendLog("[1/4] 拉取 manifest...")
val manifestJson: String
try {
manifestJson = httpGet(url)
appendLog(" ✅ 获取成功 (${manifestJson.length}B)")
} catch (e: Exception) {
appendLog(" ❌ 获取失败: ${e.message}")
appendLog("=== 测试中止 ===")
return@withContext
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}" }
}
// 2. 解析
appendLog("[2/4] 解析 manifest...")
var manifestSha256 = ""
var manifestApkUrl = ""
var manifestTag = ""
try {
val json = JSONObject(manifestJson)
if (!json.getJSONObject("builds").has("android")) {
appendLog(" 该渠道暂无可用更新")
appendLog("=== $channel 渠道测试完成 ===")
return@withContext
}
val android = json.getJSONObject("builds").getJSONObject("android")
manifestSha256 = android.getString("sha256")
manifestApkUrl = android.getString("url")
manifestTag = android.getString("tag")
appendLog(" 渠道: $channel 版本: $manifestTag")
appendLog(" SHA256: ${manifestSha256.take(16)}...")
// 版本比较:一致则跳过
val localTag = "v0.1.0-dav-android-${packageManager.getPackageInfo(packageName, 0).versionCode}"
if (manifestTag == localTag) {
appendLog(" ✅ 已是最新版本,无需更新")
appendLog("=== $channel 渠道测试完成 ===")
return@withContext
}
appendLog(" 发现新版本,开始更新...")
} catch (e: Exception) {
appendLog(" ❌ 解析失败: ${e.message}")
return@withContext
@android.webkit.JavascriptInterface
fun 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}" }
}
// 3. 下载
appendLog("[3/4] 下载 APK...")
val apkFile = File(cacheDir, "update-$channel.apk")
try {
downloadFile(manifestApkUrl, apkFile)
appendLog(" ✅ 下载完成: ${apkFile.length()} bytes")
} catch (e: Exception) {
appendLog(" ❌ 下载失败: ${e.message}")
return@withContext
@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()
}
// 4. SHA256 校验
appendLog("[4/4] SHA256 校验...")
val actualSha256 = computeSha256(apkFile)
if (actualSha256 == manifestSha256) {
appendLog(" ✅ SHA256 校验通过!")
// 5. 安装 APK
appendLog("[5/5] 提交安装...")
try {
installApk(apkFile)
appendLog(" ✅ 安装已提交,稍后通知栏点击重启")
} catch (e: Exception) {
appendLog(" ❌ 安装失败: ${e.message}")
}
} else {
appendLog(" ❌ SHA256 校验失败!")
appendLog(" expected: ${manifestSha256.take(32)}")
appendLog(" actual: ${actualSha256.take(32)}")
}
appendLog("=== $channel 渠道测试完成 ===")
}
private fun httpGet(url: String): String {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 10000
conn.readTimeout = 10000
return conn.inputStream.bufferedReader().readText()
}
private val ALLOWED_HOSTS = setOf("git.childish-ghost.com", "github.com")
private fun downloadFile(url: String, dest: File) {
val host = URL(url).host
if (!ALLOWED_HOSTS.any { host == it || host.endsWith(".$it") }) {
throw SecurityException("不允许的下载域名: $host")
}
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 30000
conn.readTimeout = 120000
conn.inputStream.use { input ->
FileOutputStream(dest).use { output -> input.copyTo(output) }
}
}
private fun installApk(apkFile: File) {
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", apkFile)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
}
private fun computeSha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buf = ByteArray(8192)
var read: Int
while (input.read(buf).also { read = it } != -1) {
digest.update(buf, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}

View File

@@ -1,24 +0,0 @@
package dev.yuzu.gca
import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.soloader.SoLoader
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply { add(PackageInstallerPackage()) }
override fun getJSMainModuleName() = "index"
override fun getUseDeveloperSupport() = false
}
override fun onCreate() {
super.onCreate()
SoLoader.init(this, false)
}
}

View File

@@ -1,51 +0,0 @@
package dev.yuzu.gca
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageInstaller
import com.facebook.react.bridge.*
import java.io.File
import java.security.MessageDigest
class PackageInstallerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "PackageInstallerModule"
@ReactMethod
fun installApk(filePath: String, expectedSha256: String, promise: Promise) {
try {
val context = reactApplicationContext
val apkFile = File(filePath)
if (!apkFile.exists()) { promise.reject("NOT_FOUND", "APK not found"); return }
if (!context.packageManager.canRequestPackageInstalls()) {
promise.reject("PERMISSION", "Install permission not granted"); return
}
// SHA256 verify
val sha256 = MessageDigest.getInstance("SHA-256")
apkFile.inputStream().use { input ->
val buf = ByteArray(8192); var read: Int
while (input.read(buf).also { read = it } != -1) sha256.update(buf, 0, read)
}
if (sha256.digest().joinToString("") { "%02x".format(it) } != expectedSha256) {
promise.reject("SHA256", "SHA256 mismatch"); return
}
val pi = context.packageManager.packageInstaller
val sessionId = pi.createSession(PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL))
val session = pi.openSession(sessionId)
try {
apkFile.inputStream().use { input ->
session.openWrite("package", 0, apkFile.length()).use { output ->
input.copyTo(output); session.fsync(output)
}
}
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
?: run { promise.reject("NO_INTENT", "No launch intent"); return }
session.commit(PendingIntent.getActivity(context, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE).intentSender)
promise.resolve("installed")
} finally { session.close() }
} catch (e: Exception) { promise.reject("ERROR", e.message) }
}
}

View File

@@ -1,12 +0,0 @@
package dev.yuzu.gca
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class PackageInstallerPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(PackageInstallerModule(reactContext))
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
}

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.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

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

@@ -2,5 +2,4 @@
<string name="app_name">YuzuGCA</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="expo_runtime_version">0.1.0</string>
</resources>

View File

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

View File

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

View File

@@ -1,56 +1,5 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
android.enableJetifier=true
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false

View File

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

View File

@@ -1,26 +1,94 @@
@echo off
setlocal
@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=.
set APP_HOME=%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"
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set ANDROID_HOME=C:\Users\Middl\AppData\Local\Android\Sdk
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:fail
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
exit /b 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

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

View File

@@ -3,26 +3,33 @@
"name": "YuzuGCA",
"slug": "yuzu-gca",
"version": "0.1.0",
"runtimeVersion": "0.1.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": { "backgroundColor": "#0f172a" },
"ios": { "supportsTablet": false, "bundleIdentifier": "dev.yuzu.gca" },
"splash": {
"backgroundColor": "#0f172a"
},
"ios": {
"supportsTablet": false,
"bundleIdentifier": "dev.yuzu.gca"
},
"android": {
"adaptiveIcon": { "backgroundColor": "#0f172a" },
"adaptiveIcon": {
"backgroundColor": "#0f172a"
},
"package": "dev.yuzu.gca",
"allowBackup": false,
"versionCode": 8,
"permissions": ["REQUEST_INSTALL_PACKAGES"]
"permissions": [
"REQUEST_INSTALL_PACKAGES"
]
},
"plugins": ["expo-updates"],
"updates": {
"enabled": true,
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 3000,
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/expo-manifest.json"
},
"extra": { "eas": { "projectId": "yuzu-gca-android" } }
"plugins": [
],
"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,4 @@
import { registerRootComponent } from 'expo';
import App from './App';
registerRootComponent(App);

View File

@@ -2,7 +2,7 @@
"name": "@yuzu-gca/client-android",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo run:android",
@@ -17,7 +17,6 @@
"expo-constants": "~17.0.0",
"expo-file-system": "~18.0.0",
"expo-status-bar": "~2.0.0",
"expo-updates": "~0.27.5",
"react": "18.3.1",
"react-native": "0.76.5"
},

View File

@@ -8,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);
}

855
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

0
releases.json Normal file
View File

BIN
s.png Normal file

Binary file not shown.

1
window_dump.xml Normal file

File diff suppressed because one or more lines are too long